diff --git a/install.sh b/install.sh index 0fb942c..53bc95b 100644 --- a/install.sh +++ b/install.sh @@ -3,6 +3,7 @@ set -euo pipefail CATALOG_URL=${XYMEDIA_CATALOG_URL:-https://git.keeper.work/admin/xymediavault-releases/raw/branch/main/catalog-v1.json} RELEASE_BASE=${XYMEDIA_RELEASE_BASE:-https://git.keeper.work/admin/xymediavault-releases/raw/branch/main} +INSTALL_NONCE=$(date +%s) SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd -P) INSTALL_DIR=${XYMEDIA_INSTALL_DIR:-$PWD} PG_HOST=postgres @@ -18,7 +19,123 @@ TVBOX_PORT=${XYMEDIA_TVBOX_PORT:-18082} TOTAL_STEPS=9 STEP=0 INSTALL_LOG= +TTY_IN=${XYMEDIA_INSTALL_TEST_TTY:-/dev/tty} +TTY_OUT=${XYMEDIA_INSTALL_TEST_TTY_OUTPUT:-/dev/tty} +TTY_TEST_MODE=0 +TTY_TEST_INDEX=0 +TTY_TEST_LINES=() +if [[ -n ${XYMEDIA_INSTALL_TEST_TTY:-} ]]; then TTY_TEST_MODE=1; mapfile -t TTY_TEST_LINES <"$TTY_IN"; fi +XIAOYA_DIR=${XYMEDIA_XIAOYA_DIR:-} +XIAOYA_CONTAINER_EXPLICIT=${XYMEDIA_XIAOYA_CONTAINER:+1} +XIAOYA_CONTAINER=${XYMEDIA_XIAOYA_CONTAINER:-xiaoya-alist} +CONTROLLER_ONLY=0 +MENU_PROFILES= +CONTROLLER_URL=${XYMEDIA_XIAOYA_CONTROLLER_URL:-} +CONTROLLER_BIND_ADDRESS=${XYMEDIA_CONTROLLER_BIND_ADDRESS:-0.0.0.0} +CONTROLLER_PORT=${XYMEDIA_CONTROLLER_PORT:-19090} +REMOTE_MODE=0 +REMOTE_TOKEN= +CONTROLLER_BIND_EXPLICIT=${XYMEDIA_CONTROLLER_BIND_ADDRESS:+1} +CONTROLLER_PORT_EXPLICIT=${XYMEDIA_CONTROLLER_PORT:+1} +MAINTENANCE_ACTION= +FUSE_PATH_INPUT= +confirm_fuse_action() { + local confirmation + confirmation=$(prompt_tty '该操作将停止并重新启动 app;输入 FUSE_CONFIRM 继续: ') + [[ $confirmation == FUSE_CONFIRM ]] || die '未确认 FUSE 生命周期操作' +} +require_public_app_installation() { + local missing=() + [[ -f "$INSTALL_DIR/.env" ]] || missing+=(.env) + [[ -f "$INSTALL_DIR/compose.yaml" ]] || missing+=(compose.yaml) + [[ -f "$INSTALL_DIR/config.yaml" ]] || missing+=(config.yaml) + [[ -f "$INSTALL_DIR/compose.fuse.yaml" ]] || missing+=(compose.fuse.yaml) + [[ -x "$INSTALL_DIR/remount-fuse.sh" ]] || missing+=(remount-fuse.sh) + grep -Eq '^ app:' "$INSTALL_DIR/compose.yaml" 2>/dev/null || missing+=(app-service) + if ((${#missing[@]})); then + printf '不支持 FUSE 维护:缺少完整 app 安装(%s)。\n' "${missing[*]}" >>$TTY_OUT + return 1 + fi + return 0 +} +read_install_env() { + awk -F= -v wanted="$1" '$1 == wanted {sub(/^[^=]*=/, ""); print; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true +} +print_fuse_status() { + local enabled path mount_id source fstype health + enabled=$(read_install_env XYMEDIA_FUSE_ENABLED) + path=$(read_install_env XYMEDIA_FUSE_HOST_PATH) + printf 'FUSE 状态:XYMEDIA_FUSE_ENABLED=%s\n' "${enabled:-未配置}" >>$TTY_OUT + printf 'FUSE 路径:XYMEDIA_FUSE_HOST_PATH=%s\n' "${path:-未配置}" >>$TTY_OUT + if [[ -z $path ]]; then + printf '挂载身份:未配置路径\n' >>$TTY_OUT + elif ! command -v mountpoint >/dev/null 2>&1 || ! command -v findmnt >/dev/null 2>&1; then + printf '挂载身份:无法确认(mountpoint/findmnt 不可用)\n' >>$TTY_OUT + elif ! mountpoint -q "$path" 2>/dev/null; then + printf '挂载身份:未挂载\n' >>$TTY_OUT + else + mount_id=$(findmnt -rn -M "$path" -o ID 2>/dev/null || true) + source=$(findmnt -rn -M "$path" -o SOURCE 2>/dev/null || true) + fstype=$(findmnt -rn -M "$path" -o FSTYPE 2>/dev/null || true) + if [[ -n $mount_id && $source == xymediavault ]] && { [[ $fstype == fuse.xymediavault ]] || [[ $fstype == fuse ]]; }; then + printf '挂载身份:owned xymediavault/fuse.xymediavault(ID=%s)\n' "$mount_id" >>$TTY_OUT + elif [[ -n $mount_id ]]; then + printf '挂载身份:foreign/unknown(ID=%s source=%s fstype=%s)\n' "$mount_id" "$source" "$fstype" >>$TTY_OUT + else + printf '挂载身份:无法确认\n' >>$TTY_OUT + fi + fi + health=$(curl --silent --show-error --max-time 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${API_PORT}/api/health" 2>/dev/null || true) + [[ $health =~ ^[0-9]{3}$ ]] || health='无法连接' + printf 'app health:%s\n' "$health" >>$TTY_OUT +} +while (($#)); do + case "$1" in + --install-dir) INSTALL_DIR=${2:?missing value}; shift 2;; + --postgres-host) PG_HOST=${2:?missing value}; shift 2;; + --postgres-port) PG_PORT=${2:?missing value}; shift 2;; + --postgres-db) PG_DB=${2:?missing value}; shift 2;; + --postgres-user) PG_USER=${2:?missing value}; shift 2;; + --postgres-password-file) PG_PASSWORD_FILE=${2:?missing value}; shift 2;; + --skip-components) SKIP_COMPONENTS=1; shift;; + --existing-db) EXISTING_DB=1; shift;; + -h|--help) usage; exit 0;; + *) die "unknown option: $1";; + esac +done die() { printf '\n\033[31mxymediavault installer: %s\033[0m\n' "$*" >&2; [[ -n $INSTALL_LOG ]] && printf '详情日志:%s\n' "$INSTALL_LOG" >&2; exit 1; } +INSTALL_TMP_DIR= +cleanup_installer_tmp() { [[ -z ${INSTALL_TMP_DIR:-} ]] || rm -rf -- "$INSTALL_TMP_DIR"; } +trap cleanup_installer_tmp EXIT INT TERM +init_installer_log() { + local tmp_parent=${TMPDIR:-/tmp} mode + [[ -d $tmp_parent && ! -L $tmp_parent ]] || die 'TMPDIR must be an existing non-symlink directory' + if (( EUID == 0 )); then + mode=$(stat -c '%a' "$tmp_parent") + [[ $(stat -c '%u' "$tmp_parent") -eq 0 ]] || die 'TMPDIR must be owned by root when running as root' + [[ $((mode & 022)) -eq 0 || $((mode & 1000)) -ne 0 ]] || die 'TMPDIR must not be group/world writable unless sticky' + fi + umask 077 + INSTALL_TMP_DIR=$(mktemp -d "$tmp_parent/xymediavault-install.XXXXXX") || die '无法创建私有安装临时目录' + chmod 700 "$INSTALL_TMP_DIR" + INSTALL_LOG=$(mktemp "$INSTALL_TMP_DIR/install.XXXXXX.log") || die '无法创建安装日志' + chmod 600 "$INSTALL_LOG" +} +guard_existing_install_tree() { + [[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || return 0 + INSTALL_PATH="$INSTALL_DIR" python3 - <<'PY' || die '安装目录路径不能包含符号链接' +import os, pathlib +p = pathlib.Path(os.environ['INSTALL_PATH']) +cur = pathlib.Path(p.root) +for part in p.parts[1:]: + cur /= part + if cur.exists() and cur.is_symlink(): raise SystemExit(1) +if p.exists(): + s = p.stat() + if os.geteuid() == 0 and (s.st_uid != 0 or s.st_mode & 0o022): raise SystemExit(2) +PY + [[ ! -e "$INSTALL_DIR/.env" || ! -L "$INSTALL_DIR/.env" ]] || die '.env must not be a symlink' +} progress() { STEP=$((STEP + 1)) printf '\033[2K\r[%d/%d] %s\n' "$STEP" "$TOTAL_STEPS" "$1" @@ -37,76 +154,373 @@ run_visible_logged() { return "${PIPESTATUS[0]}" } usage() { sed -n '1,20p' "$0"; } -while (($#)); do - case "$1" in - --install-dir) INSTALL_DIR=${2:?missing value}; shift 2;; - --postgres-host) PG_HOST=${2:?missing value}; shift 2;; - --postgres-port) PG_PORT=${2:?missing value}; shift 2;; - --postgres-db) PG_DB=${2:?missing value}; shift 2;; - --postgres-user) PG_USER=${2:?missing value}; shift 2;; - --postgres-password-file) PG_PASSWORD_FILE=${2:?missing value}; shift 2;; - --skip-components) SKIP_COMPONENTS=1; shift;; - --existing-db) EXISTING_DB=1; shift;; - -h|--help) usage; exit 0;; - *) die "unknown option: $1";; +read_tty() { if ((TTY_TEST_MODE)); then [[ $TTY_TEST_INDEX -lt ${#TTY_TEST_LINES[@]} ]] || return 1; printf -v "$1" '%s' "${TTY_TEST_LINES[$TTY_TEST_INDEX]}"; TTY_TEST_INDEX=$((TTY_TEST_INDEX + 1)); else IFS= read -r "$1" <"$TTY_IN"; fi; } +prompt_tty() { [[ -r $TTY_IN && -w $TTY_OUT ]] || die 'interactive menu requires a TTY; use menu option 1 from a terminal'; printf '%s' "$1" >>$TTY_OUT; read_tty value || die 'unable to read terminal input'; printf '%s' "$value"; } +prompt_tty_into() { [[ -r $TTY_IN && -w $TTY_OUT ]] || die 'interactive menu requires a TTY; use menu option 1 from a terminal'; printf '%s' "$2" >>$TTY_OUT; read_tty "$1" || die 'unable to read terminal input'; } +controller_token_file() { printf '%s/secrets/xiaoya-controller-token' "$INSTALL_DIR"; } +ensure_xiaoya_dir() { + local required=${1:-0} + [[ -n $XIAOYA_DIR ]] || prompt_tty_into XIAOYA_DIR "小雅安装/数据目录 [${INSTALL_DIR}/xiaoya]: " + [[ -n $XIAOYA_DIR || $required != 1 ]] || die '小雅目录不能为空' + [[ -n $XIAOYA_DIR ]] || XIAOYA_DIR="$INSTALL_DIR/xiaoya" + [[ $XIAOYA_DIR = /* && $XIAOYA_DIR != / ]] || die '小雅目录必须是绝对路径且不能是根目录' + if [[ -d $XIAOYA_DIR ]] && [[ -n $(find "$XIAOYA_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]]; then + printf '检测到已有非空小雅目录:%s\n' "$XIAOYA_DIR" >>$TTY_OUT + printf '请选择 attach / install-controller / separate / return:' >>$TTY_OUT + read_tty choice || die 'unable to read terminal input' + [[ $choice == attach || $choice == install-controller || $choice == separate || $choice == return ]] || die '无效选择,已停止以保护已有数据' + [[ $choice != return ]] || exit 0 + fi + mkdir -p "$XIAOYA_DIR" "$INSTALL_DIR/secrets" +} +write_controller_token() { + local file; mkdir -p "$INSTALL_DIR/secrets"; file=$(controller_token_file) + if [[ ! -s $file ]]; then umask 077; if command -v openssl >/dev/null; then openssl rand -hex 32 >"$file"; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$file"; fi; chmod 600 "$file"; fi +} +menu() { + [[ $# -eq 0 ]] || return 0 + [[ -r $TTY_IN && -w $TTY_OUT ]] || die 'no TTY: interactive installer menu cannot run from a non-interactive pipe' + while :; do + printf '\nXyMediaVault 安装器\n1) 仅安装管理平台\n2) 安装管理平台和本机小雅\n3) 安装管理平台并连接远程小雅控制器\n4) 仅安装小雅控制器\n5) 状态/诊断/维护\n0) 退出\n选择:' >>$TTY_OUT + read_tty choice || die 'unable to read terminal input' + case "$choice" in + 1) return 0;; + 2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;将安装小雅和小雅控制器。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-edge-controller:19090'; MENU_PROFILES='xiaoya xiaoya-control'; return 0;; + 3) local url name key; url=$(prompt_tty '控制器地址:'); key=$(prompt_tty '控制器密钥:'); name=$(prompt_tty '显示名称:'); [[ $url == https://* || ${ALLOW_HTTP:-0} == 1 ]] || die '远程连接默认要求 HTTPS;设置 ALLOW_HTTP=1 后才允许 HTTP'; curl --fail --silent --show-error --location --max-time 10 -H "X-XyMedia-Controller-Token: $key" "${url%/}/health" >/dev/null || die '控制器健康检查失败,未保存连接'; REMOTE_MODE=1; REMOTE_TOKEN=$key; CONTROLLER_URL="${url%/}"; printf '远程连接 %s (%s) 校验成功,配置已保存;控制器密钥不会显示。\n' "$name" "$CONTROLLER_URL" >>$TTY_OUT; return 0;; + 4) ensure_xiaoya_dir 1; if [[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} != 1 && -z $CONTROLLER_BIND_EXPLICIT ]]; then prompt_tty_into CONTROLLER_BIND_ADDRESS "控制器监听地址 [${CONTROLLER_BIND_ADDRESS}]: "; CONTROLLER_BIND_ADDRESS=${CONTROLLER_BIND_ADDRESS:-0.0.0.0}; fi; if [[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} != 1 && -z $CONTROLLER_PORT_EXPLICIT ]]; then prompt_tty_into CONTROLLER_PORT "控制器端口 [${CONTROLLER_PORT}]: "; CONTROLLER_PORT=${CONTROLLER_PORT:-19090}; fi; write_controller_token; CONTROLLER_ONLY=1; printf '小雅控制器安装目录已准备。\n' >>$TTY_OUT; MENU_PROFILES='xiaoya-control'; return 0;; + 5) while :; do + printf '\n维护菜单\n1) 查看状态\n2) 一键挂载本地媒体库\n3) 启动/重启已配置挂载\n4) 停用本地媒体库挂载\n0) 返回\n选择:' >>$TTY_OUT + read_tty maintenance_choice || die 'unable to read terminal input' + case "$maintenance_choice" in + 1) require_public_app_installation || continue; print_fuse_status;; + 2) require_public_app_installation || continue; prompt_tty_into FUSE_PATH_INPUT '宿主机媒体库目录(必须为空):'; confirm_fuse_action; MAINTENANCE_ACTION=configure; return 0;; + 3) require_public_app_installation || continue; confirm_fuse_action; MAINTENANCE_ACTION=restart; return 0;; + 4) require_public_app_installation || continue; confirm_fuse_action; MAINTENANCE_ACTION=disable; return 0;; + 0) continue 2;; *) printf '无效选择\n' >>$TTY_OUT;; + esac + done;; + 0) exit 0;; *) printf '无效选项\n' >>$TTY_OUT;; + esac + done +} +menu "$@" +if [[ ${XYMEDIA_INSTALL_TEST_MENU_ONLY:-0} == 1 ]]; then + printf 'TEST_MENU_PROFILES=%s\n' "$MENU_PROFILES" >$TTY_OUT + exit 0 +fi +validate_controller_settings() { + [[ $CONTROLLER_PORT =~ ^[0-9]+$ && $CONTROLLER_PORT -ge 1 && $CONTROLLER_PORT -le 65535 ]] || die '控制器端口必须是 1 到 65535 之间的数字' + [[ $CONTROLLER_BIND_ADDRESS != *$'\r'* && $CONTROLLER_BIND_ADDRESS != *$'\n'* ]] || die '控制器监听地址不能包含换行' + case "$CONTROLLER_BIND_ADDRESS" in + ''|localhost|0.0.0.0|::) return 0;; esac -done + if ! python3 - "$CONTROLLER_BIND_ADDRESS" <<'PY' +import ipaddress, sys +try: + ipaddress.ip_address(sys.argv[1]) +except ValueError: + raise SystemExit(1) +PY + then + die '控制器监听地址必须是 IPv4、IPv6、0.0.0.0、:: 或 localhost' + fi +} +validate_container_name() { + [[ $XIAOYA_CONTAINER =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}$ ]] || die '小雅容器名称不合法,不能包含换行或 Compose 特殊字符' +} progress '检查安装环境' [[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || die 'install directory must be an absolute non-root path' +guard_existing_install_tree +init_installer_log command -v docker >/dev/null || die 'Docker is required' if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); elif command -v docker-compose >/dev/null; then COMPOSE=(docker-compose); else die 'Docker Compose is required'; fi docker info >/dev/null 2>&1 || die 'cannot connect to Docker' command -v curl >/dev/null || die 'curl is required' command -v zstd >/dev/null || die 'zstd is required for release archives' command -v python3 >/dev/null || die 'python3 is required for safe archive extraction' -if [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi -if [[ $PG_HOST != postgres && -z $PG_PASSWORD_FILE ]]; then die 'an external database requires --postgres-password-file'; fi +if [[ -n $MAINTENANCE_ACTION ]]; then + require_public_app_installation || die '维护操作仅支持已有完整 app 安装' + case "$MAINTENANCE_ACTION" in + configure) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --configure "$FUSE_PATH_INPUT";; + restart) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --restart;; + disable) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --disable;; + esac +fi +discover_xiaoya_container() { + local id record selected choice image state + local -a ids=() candidates=() ordered=() + if [[ -n ${XIAOYA_CONTAINER_EXPLICIT:-} ]]; then + docker container inspect "$XIAOYA_CONTAINER" >/dev/null 2>&1 || die "未找到小雅容器:$XIAOYA_CONTAINER" + printf '使用指定的小雅容器:%s(Docker inspect 已确认)\n' "$XIAOYA_CONTAINER" >>$TTY_OUT + return 0 + fi + mapfile -t ids < <(docker ps -a -q) + for id in "${ids[@]}"; do + record=$(docker inspect "$id" 2>/dev/null | python3 -c ' +import json, re, sys +try: + item = json.load(sys.stdin)[0] + config = item.get("Config") or {} + name = (item.get("Name") or "").lstrip("/") + image = config.get("Image") or item.get("Image") or "" + labels = config.get("Labels") or {} + haystack = " ".join([name, image] + [str(k) + "=" + str(v) for k, v in labels.items()]).lower() + if not re.search(r"(?:alist|xiaoya|xymedia/xiaoya)", haystack): + raise SystemExit(0) + print(name + "\t" + image + "\t" + ((item.get("State") or {}).get("Status") or "unknown")) +except (ValueError, IndexError, TypeError, AttributeError): + raise SystemExit(1) +') || true + [[ -n $record ]] && candidates+=("$record") + done + for record in "${candidates[@]}"; do [[ $record == *$'\trunning' ]] && ordered+=("$record"); done + for record in "${candidates[@]}"; do [[ $record == *$'\trunning' ]] || ordered+=("$record"); done + if ((${#ordered[@]} == 0)); then + prompt_tty_into selected '未自动发现小雅容器,请输入容器名称(将执行 Docker inspect):' + [[ -n $selected ]] || die '小雅容器名称不能为空' + docker container inspect "$selected" >/dev/null 2>&1 || die "容器不存在或无法 inspect:$selected" + XIAOYA_CONTAINER=$selected + printf '已确认手动指定的小雅容器:%s\n' "$XIAOYA_CONTAINER" >>$TTY_OUT + elif ((${#ordered[@]} == 1)); then + IFS=$'\t' read -r XIAOYA_CONTAINER image state <<<"${ordered[0]}" + printf '自动发现小雅容器:%s(镜像:%s,状态:%s)\n' "$XIAOYA_CONTAINER" "$image" "$state" >>$TTY_OUT + [[ $state == running ]] || printf '警告:小雅容器当前已停止;仍允许安装控制器,状态页会提示。\n' >>$TTY_OUT + else + printf '发现多个可能的小雅容器(运行中的优先):\n' >>$TTY_OUT + local index=1 + for record in "${ordered[@]}"; do + IFS=$'\t' read -r name image state <<<"$record" + printf '%d) %s(镜像:%s,状态:%s)\n' "$index" "$name" "$image" "$state" >>$TTY_OUT + index=$((index + 1)) + done + prompt_tty_into choice '请选择容器编号:' + [[ $choice =~ ^[0-9]+$ && $choice -ge 1 && $choice -le ${#ordered[@]} ]] || die '无效的小雅容器编号' + IFS=$'\t' read -r XIAOYA_CONTAINER image state <<<"${ordered[$((choice - 1))]}" + printf '已选择小雅容器:%s(镜像:%s,状态:%s)\n' "$XIAOYA_CONTAINER" "$image" "$state" >>$TTY_OUT + [[ $state == running ]] || printf '警告:小雅容器当前已停止;仍允许安装控制器,状态页会提示。\n' >>$TTY_OUT + fi +} +validate_xiaoya_data_dir() { + local inspect_json + [[ -d $XIAOYA_DIR && $XIAOYA_DIR = /* && $XIAOYA_DIR != / ]] || die '小雅数据目录必须是已存在的绝对目录' + inspect_json=$(docker inspect "$XIAOYA_CONTAINER" 2>/dev/null) || die "无法 inspect 小雅容器:$XIAOYA_CONTAINER" + XIAOYA_DIR=$(XIAOYA_DIR_INPUT="$XIAOYA_DIR" python3 -c ' +import json, os, pathlib, sys + +value = os.environ["XIAOYA_DIR_INPUT"] +path = pathlib.Path(value) +if not path.is_absolute() or not path.is_dir(): + raise SystemExit("小雅数据目录不存在或不是目录") +current = pathlib.Path(path.root) +for part in path.parts[1:]: + current /= part + if current.is_symlink(): + raise SystemExit("小雅数据目录及其父目录不能包含符号链接") +canonical = str(path.resolve(strict=True)) +try: + payload = json.load(sys.stdin) + item = payload[0] + mounts = item.get("Mounts") or [] +except (ValueError, IndexError, TypeError, AttributeError): + raise SystemExit("Docker inspect 返回无效数据") + +binds = [] +for mount in mounts: + if not isinstance(mount, dict) or mount.get("Type") != "bind": + continue + destination = mount.get("Destination") + source = mount.get("Source") + if destination not in ("/data", "/www/data") or not isinstance(source, str) or not source.startswith("/"): + continue + source_path = pathlib.Path(source) + if not source_path.is_dir(): + continue + source_current = pathlib.Path(source_path.root) + for part in source_path.parts[1:]: + source_current /= part + if source_current.is_symlink(): + raise SystemExit("Docker 挂载源不能包含符号链接") + binds.append((destination, str(source_path.resolve(strict=True)))) + +if not binds: + raise SystemExit("所选容器没有 /data 或 /www/data 的主机目录挂载") +unique = {} +for destination, source in binds: + unique.setdefault(destination, set()).add(source) +if "/data" in unique: + if len(unique["/data"]) != 1: + raise SystemExit("所选容器的 /data 挂载源不唯一") + selected = next(iter(unique["/data"])) +elif len(unique.get("/www/data", set())) == 1: + selected = next(iter(unique["/www/data"])) +else: + raise SystemExit("所选容器的 /www/data 挂载源不唯一") +if canonical != selected: + raise SystemExit("小雅数据目录与所选容器的 Docker 挂载源不一致") +print(canonical) +' <<<"$inspect_json") || die "小雅数据目录必须与容器 /data 或 /www/data 的 Docker 主机挂载源一致" + printf '已校验小雅数据目录与容器挂载一致:%s\n' "$XIAOYA_DIR" >>$TTY_OUT +} +if (( CONTROLLER_ONLY )); then + validate_controller_settings + [[ $CONTROLLER_BIND_ADDRESS == :: ]] && CONTROLLER_BIND_ADDRESS='[::]' + if [[ $CONTROLLER_BIND_ADDRESS == *:* && $CONTROLLER_BIND_ADDRESS != \[*\] ]]; then + CONTROLLER_BIND_ADDRESS="[$CONTROLLER_BIND_ADDRESS]" + fi + discover_xiaoya_container + validate_container_name + [[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} == 1 ]] && exit 0 + validate_xiaoya_data_dir +fi +if (( ! CONTROLLER_ONLY )) && [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi +if (( ! CONTROLLER_ONLY )) && [[ $PG_HOST != postgres && -z $PG_PASSWORD_FILE ]]; then die 'an external database requires --postgres-password-file'; fi if ! [[ $API_PORT =~ ^[0-9]+$ && $WEBDAV_PORT =~ ^[0-9]+$ && $TVBOX_PORT =~ ^[0-9]+$ ]]; then die 'ports must be numeric'; fi -if ! [[ $PG_PORT =~ ^[0-9]+$ && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9_]+$ && $PG_HOST =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi +if (( ! CONTROLLER_ONLY )) && ! [[ $PG_PORT =~ ^[0-9]+$ && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9._-]+$ && $PG_HOST =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi progress '准备安装目录与数据库凭据' -mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{data/postgres,data,state,components,releases,secrets,config} +mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{state,secrets,config} +if (( ! CONTROLLER_ONLY )); then + mkdir -p "$INSTALL_DIR"/{data/postgres,data,components,releases} + write_controller_token +fi +if (( CONTROLLER_ONLY )); then + umask 077 + chmod 700 "$INSTALL_DIR/secrets" +fi +if (( REMOTE_MODE )); then + umask 077 + printf '%s' "$REMOTE_TOKEN" >"$INSTALL_DIR/secrets/xiaoya-controller-token" + chmod 600 "$INSTALL_DIR/secrets/xiaoya-controller-token" +fi +discover_host_addresses() { + XYMEDIA_HOST_OVERRIDE="${XYMEDIA_HOST_ADDRESSES:-}" python3 - <<'PY' +import ipaddress, os, re, socket, subprocess + +def valid(value): + try: + ip = ipaddress.ip_address(value.strip()) + except ValueError: + return None + if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast: + return None + return str(ip) + +raw = os.environ.get('XYMEDIA_HOST_OVERRIDE', '') +values = re.split(r'[\r\n,]+', raw) if raw.strip() else [] +if not values: + try: + route = subprocess.check_output(['ip', 'route', 'get', '1.1.1.1'], text=True, stderr=subprocess.DEVNULL) + match = re.search(r'\bsrc\s+(\S+)', route) + if match: + values.append(match.group(1)) + except (OSError, subprocess.SubprocessError): + pass + try: + addr = subprocess.check_output(['ip', '-o', 'addr', 'show', 'scope', 'global'], text=True, stderr=subprocess.DEVNULL) + values.extend(re.findall(r'\binet6?\s+([^/\s]+)', addr)) + except (OSError, subprocess.SubprocessError): + pass + if not values: + for family, target in ((socket.AF_INET, ('1.1.1.1', 80)), (socket.AF_INET6, ('2606:4700:4700::1111', 80, 0, 0))): + sock = socket.socket(family, socket.SOCK_DGRAM) + try: + sock.connect(target) + values.append(sock.getsockname()[0]) + except OSError: + pass + finally: + sock.close() +seen = set() +for value in values: + normalized = valid(value) + if normalized and normalized not in seen: + seen.add(normalized) + print(normalized) +PY +} +HOST_ADDRESSES_INITIAL="$(discover_host_addresses || true)" +if (( CONTROLLER_ONLY )) || [[ $MENU_PROFILES == *xiaoya-control* ]]; then + if [[ ! -e "$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES" ]]; then + umask 077 + printf '%s\n' "$HOST_ADDRESSES_INITIAL" >"$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES" + chmod 600 "$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES" + fi +fi write_probe="$INSTALL_DIR/xymediavault-write-probe-$$" if ! touch "$write_probe" || ! rm -f "$write_probe"; then die "安装目录不可写:$INSTALL_DIR;请使用 --install-dir 指定本机可写目录,例如 /opt/xymediavault" fi -INSTALL_LOG="${TMPDIR:-/tmp}/xymediavault-install-$$.log" -: >"$INSTALL_LOG" || die "无法创建临时安装日志:$INSTALL_LOG" -chmod 600 "$INSTALL_LOG" chmod 700 "$INSTALL_DIR/secrets" -if [[ -n $PG_PASSWORD_FILE ]]; then +if (( ! CONTROLLER_ONLY )) && [[ -n $PG_PASSWORD_FILE ]]; then [[ -r $PG_PASSWORD_FILE ]] || die 'password file is not readable' if [[ "$PG_PASSWORD_FILE" != "$INSTALL_DIR/secrets/postgres-password" ]]; then cp "$PG_PASSWORD_FILE" "$INSTALL_DIR/secrets/xymedia-postgres-password" PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password" fi -else +elif (( ! CONTROLLER_ONLY )); then PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password" if [[ ! -s $PG_PASSWORD_FILE ]]; then umask 077 if command -v openssl >/dev/null; then openssl rand -hex 32 >"$PG_PASSWORD_FILE"; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$PG_PASSWORD_FILE"; fi fi fi -chmod 600 "$PG_PASSWORD_FILE" -rm -f "$INSTALL_DIR/secrets/postgres-password" +if (( ! CONTROLLER_ONLY )); then chmod 600 "$PG_PASSWORD_FILE"; fi +if (( ! CONTROLLER_ONLY )); then +if (( ! CONTROLLER_ONLY )); then + rm -f "$INSTALL_DIR/secrets/postgres-password" +fi +fi TEMPLATE_DIR="$INSTALL_DIR/bootstrap" mkdir -p "$TEMPLATE_DIR" template_file() { local name=$1 downloaded="$TEMPLATE_DIR/$1" - curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$RELEASE_BASE/$name" -o "$downloaded" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$RELEASE_BASE/$name?installer=$INSTALL_NONCE" -o "$downloaded" printf '%s\n' "$downloaded" } -CONFIG_TEMPLATE=$(template_file config.yaml) -COMPOSE_TEMPLATE=$(template_file compose.yaml) -DOCKERFILE_TEMPLATE=$(template_file Dockerfile.bootstrap) +if (( CONTROLLER_ONLY )); then + COMPOSE_TEMPLATE=$(template_file compose-controller.yaml) +else + CONFIG_TEMPLATE=$(template_file config.yaml) + COMPOSE_TEMPLATE=$(template_file compose.yaml) + DOCKERFILE_TEMPLATE=$(template_file Dockerfile.bootstrap) + COMPOSE_FUSE_TEMPLATE=$(template_file compose.fuse.yaml) + REMOUNT_FUSE_TEMPLATE=$(template_file remount-fuse.sh) +fi umask 077 -printf 'XYMEDIA_INSTALL_DIR=%s\nXYMEDIA_POSTGRES_HOST=%s\nXYMEDIA_POSTGRES_PORT=%s\nXYMEDIA_POSTGRES_DATABASE=%s\nXYMEDIA_POSTGRES_USER=%s\nXYMEDIA_API_PORT=%s\nXYMEDIA_WEBDAV_PORT=%s\nXYMEDIA_TVBOX_PORT=%s\n' "$INSTALL_DIR" "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" "$API_PORT" "$WEBDAV_PORT" "$TVBOX_PORT" >"$INSTALL_DIR/.env" +HOST_ADDRESSES_ENV="$(printf '%s' "$HOST_ADDRESSES_INITIAL" | tr '\r\n' ',,' | sed 's/,,*/,/g; s/^,//; s/,$//')" +if [[ "$HOST_ADDRESSES_ENV" == *$'\r'* || "$HOST_ADDRESSES_ENV" == *$'\n'* ]]; then die 'invalid host address value'; fi +[[ ! -L "$INSTALL_DIR/.env" ]] || die '.env must not be a symlink' +env_tmp=$(mktemp "$INSTALL_DIR/.env.tmp.XXXXXX") || die '无法创建安全的 .env 临时文件' +chmod 600 "$env_tmp" +if [[ -f "$INSTALL_DIR/.env" ]]; then cp "$INSTALL_DIR/.env" "$env_tmp"; fi +FUSE_ENABLED=$(awk -F= '$1 == "XYMEDIA_FUSE_ENABLED" {print $2; exit}' "$env_tmp" 2>/dev/null || true) +FUSE_HOST_PATH=$(awk -F= '$1 == "XYMEDIA_FUSE_HOST_PATH" {print $2; exit}' "$env_tmp" 2>/dev/null || true) +[[ $FUSE_ENABLED == true || $FUSE_ENABLED == false ]] || FUSE_ENABLED=false +python3 - "$env_tmp" "$INSTALL_DIR" "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" "$API_PORT" "$WEBDAV_PORT" "$TVBOX_PORT" "$CONTROLLER_URL" "${XIAOYA_DIR:-$INSTALL_DIR/xiaoya}" "$XIAOYA_CONTAINER" "$HOST_ADDRESSES_ENV" "$CONTROLLER_BIND_ADDRESS" "$CONTROLLER_PORT" "$FUSE_ENABLED" "$FUSE_HOST_PATH" <<'PY' +import pathlib, sys +p=pathlib.Path(sys.argv[1]) +keys=('XYMEDIA_INSTALL_DIR','XYMEDIA_POSTGRES_HOST','XYMEDIA_POSTGRES_PORT','XYMEDIA_POSTGRES_DATABASE','XYMEDIA_POSTGRES_USER','XYMEDIA_API_PORT','XYMEDIA_WEBDAV_PORT','XYMEDIA_TVBOX_PORT','XYMEDIA_XIAOYA_CONTROLLER_URL','XYMEDIA_XIAOYA_DATA_DIR','XYMEDIA_XIAOYA_CONTAINER','XYMEDIA_HOST_ADDRESSES','XYMEDIA_CONTROLLER_BIND_ADDRESS','XYMEDIA_CONTROLLER_PORT','XYMEDIA_FUSE_ENABLED','XYMEDIA_FUSE_HOST_PATH') +values=dict(zip(keys, sys.argv[2:])) +seen=set(); output=[] +for line in p.read_text().splitlines(): + key=line.split('=',1)[0] if '=' in line else '' + if key in values: + output.append(key+'='+values[key]); seen.add(key) + else: + output.append(line) +output.extend(key+'='+value for key,value in values.items() if key not in seen) +p.write_text('\n'.join(output)+'\n') +PY +[[ ! -L "$INSTALL_DIR/.env" ]] || die '.env became a symlink during update' +mv -f "$env_tmp" "$INSTALL_DIR/.env" progress '下载公开版本目录(读取 App、TMM、Title 当前版本)' -curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$CATALOG_URL" -o "$INSTALL_DIR/catalog-v1.json" +catalog_sep='?' +case "$CATALOG_URL" in *\?*) catalog_sep='&';; esac +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "${CATALOG_URL}${catalog_sep}installer=$INSTALL_NONCE" -o "$INSTALL_DIR/catalog-v1.json" python3 - "$INSTALL_DIR/catalog-v1.json" <<'PY' || die 'invalid public catalog' import json, sys catalog = json.load(open(sys.argv[1])) assert catalog.get("schema_version") == 1 -for component in ("app", "tmm", "title"): +for component in ("app", "tmm", "title", "controller"): assert isinstance(catalog.get("components", {}).get(component, {}).get("artifacts"), dict) PY case "$(uname -m)" in x86_64|amd64) PLATFORM=linux-amd64;; aarch64|arm64) PLATFORM=linux-arm64;; *) die "unsupported host architecture: $(uname -m)";; esac @@ -149,12 +563,33 @@ with tarfile.open(archive, 'r:*') as tf: PY rm -f "$tarball" } +if (( CONTROLLER_ONLY )); then + IFS=$'\t' read -r controller_version controller_url < <(artifact controller "$PLATFORM") || die '公开目录没有当前架构的 controller 制品' + progress "下载小雅控制器 $controller_version ($PLATFORM)" + download_extract controller "$controller_version" "$controller_url" "$INSTALL_DIR/controller-stage" + edge=$(find "$INSTALL_DIR/controller-stage" -type f -name xymedia-edge -perm -u+x -print -quit) + [[ -n $edge ]] || die 'controller archive lacks executable xymedia-edge' + mkdir -p "$INSTALL_DIR/controller" + cp "$edge" "$INSTALL_DIR/controller/xymedia-edge" + chmod 755 "$INSTALL_DIR/controller/xymedia-edge" + [[ -s "$INSTALL_DIR/secrets/xiaoya-controller-token" ]] || write_controller_token + cp "$COMPOSE_TEMPLATE" "$INSTALL_DIR/compose.yaml" + progress '启动小雅控制器' + run_logged "${COMPOSE[@]}" --profile xiaoya-control --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d xiaoya-control || die '小雅控制器启动失败' + printf '小雅控制器已安装,监听地址:%s:%s;请按防火墙配置访问,控制器密钥不会显示。\n' "$CONTROLLER_BIND_ADDRESS" "$CONTROLLER_PORT" + exit 0 +fi IFS=$'\t' read -r APP_VERSION APP_URL < <(artifact app "$PLATFORM") APP_STAGE="$INSTALL_DIR/state/app-$APP_VERSION" progress "下载应用包 $APP_VERSION ($PLATFORM)" download_extract app "$APP_VERSION" "$APP_URL" "$APP_STAGE" APP_ROOT="$APP_STAGE/xymediavault-$APP_VERSION-$PLATFORM" [[ -x $APP_ROOT/bin/xymediavault && -x $APP_ROOT/bin/xymedia-supervisor && -d $APP_ROOT/web/dist && -f $APP_ROOT/release.json ]] || die "app package $APP_VERSION lacks the required xymedia-supervisor; use a newer public app package" +if [[ -x $APP_ROOT/bin/xymedia-edge ]]; then + mkdir -p "$INSTALL_DIR/controller" + cp "$APP_ROOT/bin/xymedia-edge" "$INSTALL_DIR/controller/xymedia-edge" + chmod 755 "$INSTALL_DIR/controller/xymedia-edge" +fi mkdir -p "$INSTALL_DIR/releases/releases" [[ ! -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]] && mv "$APP_ROOT" "$INSTALL_DIR/releases/releases/$APP_VERSION" ln -sfn "releases/$APP_VERSION" "$INSTALL_DIR/releases/current" @@ -180,7 +615,7 @@ if (( ! SKIP_COMPONENTS )); then cp "$archive" "$INSTALL_DIR/components/$component.tar.zst" done fi -PG_HOST="$PG_HOST" PG_PORT="$PG_PORT" PG_DB="$PG_DB" PG_USER="$PG_USER" python3 - "$CONFIG_TEMPLATE" "$INSTALL_DIR/config.yaml" <<'PY' +CONTROLLER_URL="$CONTROLLER_URL" PG_HOST="$PG_HOST" PG_PORT="$PG_PORT" PG_DB="$PG_DB" PG_USER="$PG_USER" python3 - "$CONFIG_TEMPLATE" "$INSTALL_DIR/config.yaml" <<'PY' import os, pathlib, sys template, output = map(pathlib.Path, sys.argv[1:]) values = { @@ -188,6 +623,7 @@ values = { "${XYMEDIA_POSTGRES_PORT}": os.environ["PG_PORT"], "${XYMEDIA_POSTGRES_DATABASE}": os.environ["PG_DB"], "${XYMEDIA_POSTGRES_USER}": os.environ["PG_USER"], + "${XYMEDIA_XIAOYA_CONTROLLER_URL}": os.environ["CONTROLLER_URL"], } content = template.read_text() for placeholder, value in values.items(): @@ -196,8 +632,28 @@ output.write_text(content) PY rm -f "$INSTALL_DIR/compose.yaml" "$INSTALL_DIR/Dockerfile.bootstrap" cp "$COMPOSE_TEMPLATE" "$DOCKERFILE_TEMPLATE" "$INSTALL_DIR/" +cp "$COMPOSE_FUSE_TEMPLATE" "$REMOUNT_FUSE_TEMPLATE" "$INSTALL_DIR/" +chmod 700 "$INSTALL_DIR/remount-fuse.sh" +FUSE_ENABLED=$(awk -F= '$1 == "XYMEDIA_FUSE_ENABLED" {print $2; exit}' "$INSTALL_DIR/.env") +COMPOSE_FUSE=() +if [[ $FUSE_ENABLED == true ]]; then COMPOSE_FUSE=(-f "$INSTALL_DIR/compose.fuse.yaml"); fi +FUSE_ENABLED="$FUSE_ENABLED" python3 - "$INSTALL_DIR/config.yaml" <<'PY' +import os, pathlib, re, sys +p = pathlib.Path(sys.argv[1]) +text = p.read_text() +enabled = os.environ['FUSE_ENABLED'] == 'true' +text = re.sub(r'(?m)^( auto_mount:)\s*.*$', r'\1 ' + ('true' if enabled else 'false'), text) +text = re.sub(r'(?m)^( mount_path:)\s*.*$', r'\1 /mnt/xymediavault', text) +tmp = p.with_name(p.name + '.tmp') +tmp.write_text(text) +tmp.replace(p) +PY +if [[ -n $MENU_PROFILES ]]; then + [[ ${XYMEDIA_INSTALL_CONFIRM:-} == INSTALL_XIAOYA ]] || { confirm=$(prompt_tty '输入 INSTALL_XIAOYA 继续: '); [[ $confirm == INSTALL_XIAOYA ]] || die '未确认小雅安装'; } + for profile in $MENU_PROFILES; do run_logged "${COMPOSE[@]}" --profile "$profile" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d || die "小雅 profile 启动失败:$profile"; done +fi progress '下载 bootstrap runtime' - if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" pull app; then + if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" pull app; then die 'bootstrap runtime 下载失败;请确认能访问 git.keeper.work Container Registry' fi progress '启动 PostgreSQL' @@ -231,16 +687,16 @@ fi progress '初始化数据库结构' if (( EXISTING_DB )); then export XYMEDIA_EXTERNAL_MIGRATION_CONFIRM=YES - if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --config /app/config.yaml; then + if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm --no-deps app-migrate migrate --config /app/config.yaml; then die '已有数据库迁移失败' fi else - if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --new --config /app/config.yaml; then + if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm --no-deps app-migrate migrate --new --config /app/config.yaml; then die '数据库初始化失败' fi fi progress '启动 XyMediaVault 服务' -if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d app; then +if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d app; then die 'XyMediaVault 启动失败' fi for elapsed in $(seq 0 118 2); do