From 220ae61141b3ca219b6e4143c3281ce7425764ee Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 2 Sep 2026 05:26:55 +0000 Subject: [PATCH] release: sync public installer asset install.sh --- install.sh | 304 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 277 insertions(+), 27 deletions(-) diff --git a/install.sh b/install.sh index 6b34045..cbbe3dc 100644 --- a/install.sh +++ b/install.sh @@ -6,13 +6,17 @@ RELEASE_BASE=${XYMEDIA_RELEASE_BASE:-https://git.keeper.work/admin/xymediavault- INSTALL_NONCE=$(date +%s) SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd -P) INSTALL_DIR=${XYMEDIA_INSTALL_DIR:-$PWD} -PG_HOST=postgres -PG_PORT=5432 +PG_HOST=${XYMEDIA_POSTGRES_HOST:-postgres} +PG_PORT=${XYMEDIA_POSTGRES_PUBLIC_PORT:-5432} PG_DB=xymedia -PG_USER=xymedia_app +PG_USER=${XYMEDIA_POSTGRES_USER:-xymedia_app} PG_PASSWORD_FILE= +INSTALL_EXISTING=0 +XYMEDIA_POSTGRES_PUBLIC_PORT=${XYMEDIA_POSTGRES_PUBLIC_PORT:-} SKIP_COMPONENTS=0 EXISTING_DB=0 +LOCAL_DB=0 +PG_HOST_CLI=0 API_PORT=${XYMEDIA_API_PORT:-18080} WEBDAV_PORT=${XYMEDIA_WEBDAV_PORT:-18081} TVBOX_PORT=${XYMEDIA_TVBOX_PORT:-18082} @@ -39,6 +43,10 @@ CONTROLLER_BIND_EXPLICIT=${XYMEDIA_CONTROLLER_BIND_ADDRESS:+1} CONTROLLER_PORT_EXPLICIT=${XYMEDIA_CONTROLLER_PORT:+1} MAINTENANCE_ACTION= FUSE_PATH_INPUT= +CLEANUP_CHOICE= +XYMEDIA_INSTANCE_ID=${XYMEDIA_INSTANCE_ID:-} +cleanup_interrupted=0 +cleanup_journal= confirm_fuse_action() { local confirmation confirmation=$(prompt_tty '该操作将停止并重新启动应用,输入 1 继续,其他内容取消: ') @@ -92,19 +100,171 @@ print_fuse_status() { print_controller_info() { local env_file="$INSTALL_DIR/.env" token_file address port token [[ -f $env_file ]] || { printf '未找到安装配置,请先完成安装。\n' >>$TTY_OUT; return 1; } - address=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_BIND_ADDRESS" {print $2; exit}' "$env_file") + address=$(awk -F= '$1 == "XYMEDIA_HOST_ADDRESSES" {print $2; exit}' "$env_file" | cut -d, -f1) port=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_PORT" {print $2; exit}' "$env_file") token_file="$INSTALL_DIR/secrets/xiaoya-controller-token" token=$(cat "$token_file" 2>/dev/null || true) - [[ -n $address ]] || address='0.0.0.0' + [[ -n $address ]] || address=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_BIND_ADDRESS" {print $2; exit}' "$env_file") + [[ -n $address && $address != 0.0.0.0 && $address != :: ]] || address='127.0.0.1' [[ -n $port ]] || port=19090 [[ -n $token ]] || { printf '未找到控制器密钥,请先完成控制器安装。\n' >>$TTY_OUT; return 1; } printf '小雅控制器地址:http://%s:%s\n小雅控制器密钥:%s\n' "$address" "$port" "$token" >>$TTY_OUT } +print_database_info() { + local env_file="$INSTALL_DIR/.env" host port db user password_file password local_db + [[ -f $env_file ]] || { printf '未找到安装配置,请先完成安装。\n' >>$TTY_OUT; return 1; } + host=$(read_install_env XYMEDIA_POSTGRES_HOST) + port=$(read_install_env XYMEDIA_POSTGRES_PORT) + db=$(read_install_env XYMEDIA_POSTGRES_DATABASE) + user=$(read_install_env XYMEDIA_POSTGRES_USER) + if [[ $host == *:* && $host != \[*\] ]]; then host="[$host]"; fi + printf 'PostgreSQL 地址:%s:%s\nPostgreSQL 数据库:%s\nPostgreSQL 用户:%s\n' "$host" "$port" "$db" "$user" >>$TTY_OUT + password_file="$INSTALL_DIR/secrets/xymedia-postgres-password" + local_db=$(read_install_env XYMEDIA_POSTGRES_LOCAL_DB) + if [[ $local_db == 1 || $host == postgres || $LOCAL_DB == 1 ]] && [[ -s $password_file ]]; then + password=$(cat "$password_file") + printf 'PostgreSQL 密码:%s\n' "$password" >>$TTY_OUT + else + printf 'PostgreSQL 密码:外部数据库密码由用户自行保管,不在安装器显示。\n' >>$TTY_OUT + fi +} +validate_cleanup_root() { + [[ $INSTALL_DIR = /* && $INSTALL_DIR != / && -d $INSTALL_DIR && ! -L $INSTALL_DIR ]] || die '清理要求安装目录是已存在的非符号链接绝对目录' + INSTALL_PATH="$INSTALL_DIR" python3 - <<'PY' +import os, pathlib +p = pathlib.Path(os.environ['INSTALL_PATH']) +current = pathlib.Path(p.root) +for part in p.parts[1:]: + current /= part + info = current.lstat() + if info.st_uid != 0 or info.st_mode & 0o022 or current.is_symlink(): raise SystemExit(1) +if p.resolve(strict=True) == pathlib.Path('/'): + raise SystemExit(1) +PY + [[ -f "$INSTALL_DIR/.env" && ! -L "$INSTALL_DIR/.env" ]] || die '清理要求安装目录存在非符号链接 .env;无法确认实例所有权' + local recorded_dir recorded_id + recorded_dir=$(cleanup_read_env XYMEDIA_INSTALL_DIR) + recorded_id=$(cleanup_read_env XYMEDIA_INSTANCE_ID) + [[ $recorded_dir == "$INSTALL_DIR" ]] || die '安装配置中的实例目录与当前目录不一致,拒绝清理' + [[ $recorded_id =~ ^[a-z0-9_-]+$ ]] || die '安装配置缺少有效 XYMEDIA_INSTANCE_ID,拒绝清理旧实例' + XYMEDIA_INSTANCE_ID=$recorded_id +} +cleanup_read_env() { awk -F= -v wanted="$1" '$1 == wanted {sub(/^[^=]*=/, ""); print; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true; } +cleanup_canonical_child() { + [[ -n $1 && -d $1 && ! -L $1 ]] || return 1 + CLEANUP_PATH_INPUT="$1" CLEANUP_ROOT="$2" python3 - <<'PY' +import os, pathlib +p = pathlib.Path(os.environ['CLEANUP_PATH_INPUT']) +root = pathlib.Path(os.environ['CLEANUP_ROOT']).resolve(strict=True) +if not p.is_absolute() or any((p.root.joinpath(*p.parts[1:i+1])).is_symlink() for i in range(len(p.parts)-1)): raise SystemExit(1) +canonical = p.resolve(strict=True) +try: canonical.relative_to(root) +except ValueError: raise SystemExit(1) +if canonical == root: raise SystemExit(1) +print(canonical) +PY +} +cleanup_unmount_owned_fuse() { + local path mount_id source fstype + path=$(cleanup_read_env XYMEDIA_FUSE_HOST_PATH) + [[ -n $path ]] || return 0 + path=$(cleanup_canonical_child "$path" /) || die 'FUSE 路径无效,已停止清理' + command -v mountpoint >/dev/null 2>&1 && command -v findmnt >/dev/null 2>&1 || die '无法验证 FUSE 挂载身份,已停止清理' + mountpoint -q "$path" 2>/dev/null || return 0 + 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) + [[ -n $mount_id && $source == xymediavault && ( $fstype == fuse.xymediavault || $fstype == fuse ) ]] || die 'FUSE 路径不是可确认的 XyMediaVault 挂载,已停止清理' + if command -v fusermount3 >/dev/null 2>&1; then fusermount3 -u "$path" || die '受管 FUSE 卸载失败,未删除任何数据'; elif command -v fusermount >/dev/null 2>&1; then fusermount -u "$path" || die '受管 FUSE 卸载失败,未删除任何数据'; else umount "$path" || die '缺少安全卸载命令,未删除任何数据'; fi + mountpoint -q "$path" 2>/dev/null && die 'FUSE 卸载后仍处于挂载状态,未删除任何数据' || true +} +cleanup_inspect_ok() { + local expected=$1 inspected=$2 require_mount=$3 + INSPECTED_JSON="$inspected" EXPECTED_NAME="$expected" EXPECTED_ID="$XYMEDIA_INSTANCE_ID" EXPECTED_DIR="$INSTALL_DIR" REQUIRE_MOUNT="$require_mount" python3 - <<'PY' +import json, os, pathlib +try: + item = json.loads(os.environ['INSPECTED_JSON'])[0] + labels = (item.get('Config') or {}).get('Labels') or {} + if (item.get('Name') or '').lstrip('/') != os.environ['EXPECTED_NAME']: raise SystemExit(1) + if labels.get('com.xymediavault.managed') != 'true': raise SystemExit(1) + if labels.get('com.xymediavault.instance_id') != os.environ['EXPECTED_ID']: raise SystemExit(1) + if labels.get('com.xymediavault.install_dir') != os.environ['EXPECTED_DIR']: raise SystemExit(1) + if os.environ['REQUIRE_MOUNT'] == '1': + data = pathlib.Path(os.environ['EXPECTED_DATA']).resolve(strict=True) + matched = False + for mount in item.get('Mounts') or []: + if mount.get('Type') == 'bind' and mount.get('Destination') in ('/data', '/www/data'): + source = pathlib.Path(mount.get('Source', '')).resolve(strict=True) + if source == data: matched = True + if not matched: raise SystemExit(1) +except (ValueError, IndexError, TypeError, AttributeError, OSError): raise SystemExit(1) +PY +} +cleanup_container() { + local name=$1 require_mount=${2:-0} expected_data=${3:-} inspected + inspected=$(docker container inspect "$name" 2>/dev/null) || return 0 + [[ -n $inspected ]] || return 0 + EXPECTED_DATA="$expected_data" cleanup_inspect_ok "$name" "$inspected" "$require_mount" || die "容器 $name 缺少当前实例所有权标签或挂载校验失败,拒绝删除" + docker rm -f -- "$name" >>"$INSTALL_LOG" 2>&1 || die "删除容器失败:$name(详情见日志)" +} +cleanup_all() { + local include_xiaoya=$1 marker data_path canonical_data name managed confirm inspected + validate_cleanup_root + if [[ -e "$INSTALL_DIR/.cleanup.journal" ]]; then + [[ -f "$INSTALL_DIR/.cleanup.journal" && ! -L "$INSTALL_DIR/.cleanup.journal" ]] || die '清理日志不是安全的普通文件,拒绝继续' + [[ $(stat -c '%u' "$INSTALL_DIR/.cleanup.journal") -eq 0 && $(( $(stat -c '%a' "$INSTALL_DIR/.cleanup.journal") & 077 )) -eq 0 ]] || die '清理日志权限不安全,拒绝继续' + printf '检测到未完成的清理日志:%s\n' "$INSTALL_DIR/.cleanup.journal" >>$TTY_OUT + confirm=$(prompt_tty '继续未完成清理请输入数字 1,输入 0 取消: ') + [[ $confirm == 1 ]] || { printf '已取消恢复清理。\n' >>$TTY_OUT; return 0; } + fi + marker=$(cleanup_read_env XYMEDIA_XIAOYA_MANAGED); data_path=$(cleanup_read_env XYMEDIA_XIAOYA_DATA_DIR); canonical_data= + if [[ $include_xiaoya == 1 && $marker != 1 ]]; then + printf '当前安装未记录本机小雅由安装器创建,选项 2 不可用;未删除任何内容。\n' >>$TTY_OUT + return 0 + fi + if [[ $include_xiaoya == 1 && $marker == 1 ]]; then canonical_data=$(cleanup_canonical_child "$data_path" "$INSTALL_DIR") || die '小雅数据未被标记为安装目录内的安全路径,已停止清理'; fi + local -a containers=(xymedia-app xymedia-postgres xymedia-controller xymedia-aliyuntvtoken) + if [[ $include_xiaoya == 1 && $marker == 1 ]]; then containers+=(xymedia-xiaoya); fi + local -a migration_ids=() migration_names=() + while IFS=$'\t' read -r name id; do + [[ -n $id ]] || continue + inspected=$(docker container inspect "$id" 2>/dev/null) || continue + if [[ $name =~ ^xymedia-app-migrate-[0-9]+$ ]] && cleanup_inspect_ok "$name" "$inspected" 0; then migration_names+=("$name"); migration_ids+=("$id"); fi + done < <(docker ps -a --filter label=com.xymediavault.managed=true --format '{{.Names}}\t{{.ID}}') + containers+=("${migration_names[@]}") + for name in "${containers[@]}"; do + inspected=$(docker container inspect "$name" 2>/dev/null) || continue + cleanup_inspect_ok "$name" "$inspected" "$([[ $name == xymedia-xiaoya ]] && printf 1 || printf 0)" || die "容器 $name 缺少当前实例所有权标签,拒绝清理;未删除容器" + done + printf '\n将删除以下精确容器(不存在的会跳过):\n' >>$TTY_OUT; printf ' %s\n' "${containers[@]}" >>$TTY_OUT + printf '将删除安装器管理数据:%s/data %s/secrets %s/components %s/releases %s/controller %s/state\n' "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" >>$TTY_OUT + printf '以及受管配置、Compose、日志和状态文件;其他文件、外部小雅数据和未标记小雅容器保留。\n' >>$TTY_OUT + [[ -z $canonical_data ]] || printf '小雅数据(已确认在安装目录内):%s\n' "$canonical_data" >>$TTY_OUT + confirm=$(prompt_tty '确认删除以上内容?请输入数字 1,其他内容取消: '); [[ $confirm == 1 ]] || { printf '已取消清理。\n' >>$TTY_OUT; return 0; } + [[ ! -L "$INSTALL_DIR/.cleanup.journal" ]] || die '清理日志是符号链接,拒绝清理' + mkdir "$INSTALL_DIR/.install.lock" 2>/dev/null || die '已有安装或维护操作正在进行,拒绝清理' + cleanup_lock_dir="$INSTALL_DIR/.install.lock" + cleanup_journal="$INSTALL_DIR/.cleanup.journal" + (umask 077; printf 'stage=confirmed\ninstance_id=%s\n' "$XYMEDIA_INSTANCE_ID" >"$cleanup_journal") || die '无法创建清理日志,未删除任何内容' + cleanup_exit() { local status=$?; rmdir "${cleanup_lock_dir:-}" 2>/dev/null || true; if (( cleanup_interrupted )); then (umask 077; printf 'stage=interrupted\ninstance_id=%s\n' "$XYMEDIA_INSTANCE_ID" >"$cleanup_journal"); printf '清理被中断,已保留恢复日志:%s\n' "$cleanup_journal" >>$TTY_OUT; fi; persist_installer_log; cleanup_installer_tmp; exit "$status"; } + cleanup_trap() { cleanup_interrupted=1; exit 130; } + trap cleanup_exit EXIT + trap cleanup_trap INT TERM + cleanup_checkpoint() { if (( cleanup_interrupted )); then printf 'stage=%s\n' "$1" >"$cleanup_journal"; return 1; fi; printf 'stage=%s\n' "$1" >"$cleanup_journal"; return 0; } + cleanup_interrupted=0 + cleanup_unmount_owned_fuse + for managed in xymedia-app xymedia-postgres xymedia-controller xymedia-aliyuntvtoken xymedia-xiaoya; do [[ " ${containers[*]} " == *" $managed "* ]] || continue; cleanup_checkpoint "before-$managed" || exit 130; cleanup_container "$managed" "$([[ $managed == xymedia-xiaoya ]] && printf 1 || printf 0)" "$canonical_data"; done + local index; for index in "${!migration_ids[@]}"; do cleanup_checkpoint "before-migration-${migration_names[$index]}" || exit 130; inspected=$(docker container inspect "${migration_ids[$index]}" 2>/dev/null) || continue; cleanup_inspect_ok "${migration_names[$index]}" "$inspected" 0 || continue; docker rm -f -- "${migration_ids[$index]}" >>"$INSTALL_LOG" 2>&1 || die "删除迁移容器失败(详情见日志)"; done + if [[ -n $canonical_data ]]; then + [[ ! -L "$canonical_data" ]] || die '小雅数据路径是符号链接,已停止清理' + rm -rf -- "$canonical_data" + fi + for managed in data secrets components releases controller state bootstrap; do [[ ! -L "$INSTALL_DIR/$managed" ]] || die "受管路径是符号链接,已停止清理"; rm -rf -- "$INSTALL_DIR/$managed"; done + for managed in .env config.yaml compose.yaml compose.fuse.yaml Dockerfile.bootstrap remount-fuse.sh catalog-v1.json install.log; do [[ ! -L "$INSTALL_DIR/$managed" ]] || die "受管文件是符号链接,已停止清理"; rm -f -- "$INSTALL_DIR/$managed"; done + rm -f -- "$cleanup_journal"; cleanup_journal=; printf '清理完成;安装目录中的其他文件和外部小雅数据已保留。\n' >>$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-host) PG_HOST=${2:?missing value}; PG_HOST_CLI=1; 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;; @@ -155,6 +315,14 @@ PY fi [[ ! -e "$INSTALL_DIR/.env" || ! -L "$INSTALL_DIR/.env" ]] || die '.env must not be a symlink' } +ensure_instance_id() { + [[ -n "$XYMEDIA_INSTANCE_ID" ]] || XYMEDIA_INSTANCE_ID=$(awk -F= '$1 == "XYMEDIA_INSTANCE_ID" {print $2; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true) + if [[ -z "$XYMEDIA_INSTANCE_ID" ]]; then + XYMEDIA_INSTANCE_ID=$(od -An -tx1 -N8 /dev/urandom 2>/dev/null | tr -d ' \n') + [[ -n "$XYMEDIA_INSTANCE_ID" ]] || XYMEDIA_INSTANCE_ID="install-${INSTALL_NONCE}" + fi + [[ $XYMEDIA_INSTANCE_ID =~ ^[a-z0-9_-]+$ ]] || die 'XYMEDIA_INSTANCE_ID 格式无效' +} progress() { STEP=$((STEP + 1)) printf '\033[2K\r[%d/%d] %s\n' "$STEP" "$TOTAL_STEPS" "$1" @@ -204,21 +372,23 @@ menu() { read_tty choice || die '无法读取终端输入' case "$choice" in 1) return 0;; - 2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;将安装小雅和小雅控制器。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-controller:19090'; MENU_PROFILES='xiaoya xiaoya-control'; return 0;; + 2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;将安装小雅和小雅控制器。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-controller:19090'; [[ -f "$INSTALL_DIR/.env" ]] || XYMEDIA_XIAOYA_MANAGED=1; 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 + printf '\n维护菜单\n1) 查看状态\n2) 一键挂载本地媒体库\n3) 启动/重启已配置挂载\n4) 停用本地媒体库挂载\n5) 清理 XyMediaVault 容器及数据\n0) 返回\n选择:' >>$TTY_OUT read_tty maintenance_choice || die '无法读取终端输入' 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;; + 4) require_public_app_installation || continue; confirm_fuse_action; MAINTENANCE_ACTION=disable; return 0;; + 5) printf '清理选项:\n1) 只清理 XyMediaVault\n2) 清理 XyMediaVault 和本机安装的小雅\n0) 取消\n选择:' >>$TTY_OUT; read_tty cleanup_choice || die '无法读取终端输入'; case "$cleanup_choice" in 1) CLEANUP_CHOICE=1; return 0;; 2) CLEANUP_CHOICE=2; return 0;; 0) continue;; *) printf '无效选择\n' >>$TTY_OUT;; esac;; 0) continue 2;; *) printf '无效选择\n' >>$TTY_OUT;; esac done;; - 6) print_controller_info || true;; + 6) print_controller_info || true;; + 7) print_database_info || true;; 0) exit 0;; *) printf '无效选项\n' >>$TTY_OUT;; esac done @@ -251,6 +421,7 @@ validate_container_name() { progress '检查安装环境' [[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || die '安装目录必须是绝对路径且不能是根目录' guard_existing_install_tree +if [[ -f "$INSTALL_DIR/.env" ]]; then INSTALL_EXISTING=1; fi init_installer_log command -v docker >/dev/null || die '需要安装 Docker' 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'; fi @@ -258,6 +429,24 @@ docker info >/dev/null 2>&1 || die '无法连接 Docker,请确认 Docker 已 command -v curl >/dev/null || die '需要安装 curl' command -v zstd >/dev/null || die '需要安装 zstd,用于解压发布包' command -v python3 >/dev/null || die '需要安装 python3,用于安全解压发布包' +if [[ -n $CLEANUP_CHOICE ]]; then + (( EUID == 0 )) || die '清理要求 root 权限' + cleanup_all "$([[ $CLEANUP_CHOICE == 2 ]] && printf 1 || printf 0)" + exit 0 +fi +ensure_instance_id +if (( ! CONTROLLER_ONLY )) && (( INSTALL_EXISTING )); then + old_host=$(read_install_env XYMEDIA_POSTGRES_HOST) + old_port=$(read_install_env XYMEDIA_POSTGRES_PORT) + old_db=$(read_install_env XYMEDIA_POSTGRES_DATABASE) + old_user=$(read_install_env XYMEDIA_POSTGRES_USER) + [[ -n $old_host ]] && PG_HOST=$old_host + [[ -n $old_port ]] && PG_PORT=$old_port + [[ -n $old_db ]] && PG_DB=$old_db + [[ -n $old_user ]] && PG_USER=$old_user + old_local=$(read_install_env XYMEDIA_POSTGRES_LOCAL_DB) + if [[ $old_local == 1 || ( $old_host == postgres && $EXISTING_DB != 1 ) ]]; then LOCAL_DB=1; fi +fi if [[ -n $MAINTENANCE_ACTION ]]; then require_public_app_installation || die '维护操作仅支持已有完整 app 安装' case "$MAINTENANCE_ACTION" in @@ -392,10 +581,18 @@ if (( CONTROLLER_ONLY )); then [[ ${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 (( ! CONTROLLER_ONLY )) && (( PG_HOST_CLI )) && [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi +if (( ! CONTROLLER_ONLY )) && (( PG_HOST_CLI )) && [[ $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 (( ! 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 +if (( ! CONTROLLER_ONLY )) && ! [[ $PG_PORT =~ ^[0-9]+$ && $PG_PORT -ge 1024 && $PG_PORT -le 65535 && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi +if (( ! CONTROLLER_ONLY )) && ! python3 - "$PG_HOST" <<'PY' +import ipaddress, sys +try: + ipaddress.ip_address(sys.argv[1]) +except ValueError: + if not __import__('re').fullmatch(r'[A-Za-z0-9.-]+', sys.argv[1]): raise SystemExit(1) +PY +then die 'PostgreSQL 主机必须是 IPv4、IPv6 或合法主机名'; fi progress '准备安装目录与数据库凭据' mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{state,secrets,config} if (( ! CONTROLLER_ONLY )); then @@ -458,6 +655,44 @@ for value in values: PY } HOST_ADDRESSES_INITIAL="$(discover_host_addresses || true)" +select_random_postgres_port() { + local requested=${XYMEDIA_POSTGRES_PUBLIC_PORT:-} + if [[ -n $requested ]]; then + [[ $requested =~ ^[0-9]+$ && $requested -ge 1024 && $requested -le 65535 ]] || die 'XYMEDIA_POSTGRES_PUBLIC_PORT 必须是 1024 到 65535 之间的数字' + printf '%s\n' "$requested" + return 0 + fi + python3 - <<'PY' +import random, socket +for _ in iter(int, 1): + port = random.randint(20000, 40000) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind(('0.0.0.0', port)) + print(port) + break + except OSError: + pass + finally: + sock.close() +PY +} +random_postgres_user() { + python3 - <<'PY' +import secrets, string +print('x' + ''.join(secrets.choice(string.ascii_lowercase + string.digits + '_') for _ in range(15))) +PY +} +if (( ! CONTROLLER_ONLY )) && (( ! INSTALL_EXISTING )) && (( ! PG_HOST_CLI )); then + if [[ -z ${XYMEDIA_POSTGRES_HOST:-} ]]; then + [[ -n $HOST_ADDRESSES_INITIAL ]] || die '未检测到可用宿主机 IP,请设置 XYMEDIA_POSTGRES_HOST 后重试' + PG_HOST=$(printf '%s\n' "$HOST_ADDRESSES_INITIAL" | head -n 1) + fi + PG_PORT=$(select_random_postgres_port) || die '无法选择 PostgreSQL 对外端口,请稍后重试' + XYMEDIA_POSTGRES_PUBLIC_PORT=$PG_PORT + PG_USER=$(random_postgres_user) || die '无法生成 PostgreSQL 用户名' + LOCAL_DB=1 +fi if (( CONTROLLER_ONLY )) || [[ $MENU_PROFILES == *xiaoya-control* ]]; then if [[ ! -e "$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES" ]]; then umask 077 @@ -470,9 +705,9 @@ if ! touch "$write_probe" || ! rm -f "$write_probe"; then die "安装目录不可写:$INSTALL_DIR;请使用 --install-dir 指定本机可写目录,例如 /opt/xymediavault" fi chmod 700 "$INSTALL_DIR/secrets" -if (( ! CONTROLLER_ONLY )) && [[ -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 + if [[ "$PG_PASSWORD_FILE" != "$INSTALL_DIR/secrets/xymedia-postgres-password" ]]; then cp "$PG_PASSWORD_FILE" "$INSTALL_DIR/secrets/xymedia-postgres-password" PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password" fi @@ -480,15 +715,14 @@ 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 + if (( INSTALL_EXISTING )) && [[ -s "$INSTALL_DIR/secrets/postgres-password" ]]; then cp "$INSTALL_DIR/secrets/postgres-password" "$PG_PASSWORD_FILE" + elif 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 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 +if (( LOCAL_DB )) && [[ -z ${XYMEDIA_POSTGRES_PUBLIC_PORT:-} ]]; then XYMEDIA_POSTGRES_PUBLIC_PORT=$PG_PORT; fi +# Keep the legacy secret for existing administrators and old installations. TEMPLATE_DIR="$INSTALL_DIR/bootstrap" mkdir -p "$TEMPLATE_DIR" template_file() { @@ -511,14 +745,16 @@ if [[ "$HOST_ADDRESSES_ENV" == *$'\r'* || "$HOST_ADDRESSES_ENV" == *$'\n'* ]]; t [[ ! -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 + if [[ -f "$INSTALL_DIR/.env" ]]; then cp "$INSTALL_DIR/.env" "$env_tmp"; fi +XIAOYA_MANAGED_ENV=$(awk -F= '$1 == "XYMEDIA_XIAOYA_MANAGED" {print $2; exit}' "$env_tmp" 2>/dev/null || true) +[[ ${XYMEDIA_XIAOYA_MANAGED:-0} == 1 ]] || XYMEDIA_XIAOYA_MANAGED=${XIAOYA_MANAGED_ENV:-0} 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' +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" "${XYMEDIA_XIAOYA_MANAGED:-0}" "$XYMEDIA_INSTANCE_ID" "$LOCAL_DB" "$XYMEDIA_POSTGRES_PUBLIC_PORT" <<'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') +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','XYMEDIA_XIAOYA_MANAGED','XYMEDIA_INSTANCE_ID','XYMEDIA_POSTGRES_LOCAL_DB','XYMEDIA_POSTGRES_PUBLIC_PORT') values=dict(zip(keys, sys.argv[2:])) seen=set(); output=[] for line in p.read_text().splitlines(): @@ -663,6 +899,15 @@ output.write_text(content) PY rm -f "$INSTALL_DIR/compose.yaml" "$INSTALL_DIR/Dockerfile.bootstrap" cp "$COMPOSE_TEMPLATE" "$DOCKERFILE_TEMPLATE" "$INSTALL_DIR/" +if (( PG_HOST_CLI )) || [[ -n ${XYMEDIA_POSTGRES_HOST:-} ]]; then + python3 - "$INSTALL_DIR/compose.yaml" <<'PY' +import pathlib, re, sys +p = pathlib.Path(sys.argv[1]) +text = p.read_text() +text = re.sub(r'\n extra_hosts:\n - "host\.docker\.internal:host-gateway"', '', text) +p.write_text(text) +PY +fi 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") @@ -693,12 +938,12 @@ progress '下载 bootstrap runtime' die 'bootstrap runtime 下载失败;请确认能访问 git.keeper.work Container Registry' fi progress '启动 PostgreSQL' -if [[ $PG_HOST == postgres ]]; then +if (( LOCAL_DB )); then if ! run_logged "${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d postgres; then die 'PostgreSQL 启动失败' fi fi -if [[ $PG_HOST == postgres ]]; then +if (( LOCAL_DB )); then wait_for_postgres() { local started_at elapsed container_id health local previous_int previous_term @@ -773,7 +1018,7 @@ if [[ $migration_rc -ne 0 ]]; then fi die "数据库初始化失败(退出码:$migration_rc)" fi -printf '数据库初始化完成\n' + printf '数据库初始化完成\n' printf '数据库初始化完成\n' >>"$INSTALL_LOG" progress '启动 XyMediaVault 服务' set +e @@ -818,5 +1063,10 @@ cp "$INSTALL_LOG" "$INSTALL_DIR/install.log" 2>/dev/null || true if [[ $MENU_PROFILES == 'xiaoya xiaoya-control' ]]; then print_controller_info fi + if (( LOCAL_DB )); then + print_database_info + else + printf 'PostgreSQL 地址:%s:%s\nPostgreSQL 数据库:%s\nPostgreSQL 用户:%s\n' "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" + fi printf '安装日志:%s/install.log\n' "$INSTALL_DIR" exit 0