#!/usr/bin/env bash set -euo pipefail # Lifecycle contract: run this installer once on a fresh Dash host. It is not # a reinstall, repair, rollback, or version-update entrypoint. After the first # successful installation, every version change uses the dash update subcommand # directly or through the compatibility wrapper update_dash_linux.sh. Installation and # update workflows are never run concurrently. APP="dash" readonly POSTGRES_VERSION="16.15" readonly POSTGRES_MAJOR="${POSTGRES_VERSION%%.*}" readonly TIMESCALEDB_VERSION="2.29.1" readonly DEFAULT_NODE_OFFLINE_THRESHOLD="17s" INSTALL_DIR="/opt/Ithiltir-dash" RELEASES_DIR="${INSTALL_DIR}/releases" CURRENT_LINK="${INSTALL_DIR}/current" LAYOUT_MARKER="${INSTALL_DIR}/.release-layout-v1" BIN_DIR="${INSTALL_DIR}/bin" BIN_PATH="${BIN_DIR}/dash" CONFIG_DIR="${INSTALL_DIR}/configs" CONFIG_EXAMPLE="${CONFIG_DIR}/config.example.yaml" CONFIG_LOCAL="${CONFIG_DIR}/config.local.yaml" NOTIFY_CONFIG_KEY="${CONFIG_DIR}/notify-config.key" SERVICE_FILE="/etc/systemd/system/${APP}.service" MANUAL_RUN_FILE="${INSTALL_DIR}/run_dash.sh" REDIS_INSTALL_METHOD="${REDIS_INSTALL_METHOD:-package}" REDIS_TAKEOVER_CONFIRMED="0" SERVICE_MANAGER_MODE="${SERVICE_MANAGER_MODE:-auto}" SERVICE_MANAGER="" INSTALL_RELEASE="" INSTALL_RECOVERY_ROOT="" INSTALL_PREVIOUS_LAYOUT="" INSTALL_PREVIOUS_TARGET="" INSTALL_PREVIOUS_MARKER="0" INSTALL_PREVIOUS_CONFIG="0" INSTALL_SYSTEMD_WAS_ACTIVE="0" INSTALL_ROLLBACK_READY="0" INSTALL_MIGRATION_STARTED="0" OS_ID="" OS_VERSION_ID="" APT_REPO_ID="" APT_REPO_CODENAME="" OS_FAMILY="" PKG_MANAGER="" PKG_MANAGER_LABEL="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_LANG="${INSTALL_LANG:-}" usage() { cat <&2 echo " 1) English" >&2 echo " 2) 中文" >&2 if [[ "$default" == "zh" ]]; then read -r -p "Enter number / 请输入序号 [2] " ans || true ans="${ans:-2}" else read -r -p "Enter number / 请输入序号 [1] " ans || true ans="${ans:-1}" fi case "$ans" in 1) INSTALL_LANG="en"; return 0 ;; 2) INSTALL_LANG="zh"; return 0 ;; *) echo "Please enter 1 or 2 / 请输入 1 或 2" >&2 ;; esac done } txt() { if [[ "${INSTALL_LANG:-zh}" == "en" ]]; then printf '%s' "$2" else printf '%s' "$1" fi } say() { echo "$(txt "$1" "$2")"; } say_err() { echo "$(txt "$1" "$2")" >&2; } need_cmd() { command -v "$1" >/dev/null 2>&1; } as_root() { if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then "$@" return fi if need_cmd sudo; then sudo "$@" return fi say_err "需要 root 权限且未安装 sudo,请以 root 运行" "Root privileges are required and sudo is not installed. Please run as root." exit 1 } die() { echo "ERROR: $*" >&2 exit 1 } print_config_summary() { local dash_ip="$1" listen_port="$2" public_url="$3" db_user="$4" db_pass="$5" db_name="$6" retention_days="$7" redis_addr="$8" redis_password="$9" offline_threshold="${10}" language="${11}" trusted_proxies="${12}" local retention_label="default (45 days)" if [[ "$retention_days" != "default" ]]; then retention_label="${retention_days} days" fi say "即将写入配置:${CONFIG_LOCAL}" "About to write config: ${CONFIG_LOCAL}" echo " app.dash_ip: ${dash_ip}" echo " app.listen: :${listen_port}" echo " app.public_url: ${public_url}" echo " app.language: ${language}" echo " database.user: ${db_user}" say " database.password: (已隐藏,长度 ${#db_pass})" " database.password: (hidden, length ${#db_pass})" echo " database.name: ${db_name}" echo " database.metrics_raw_retention_days: 8" echo " database.retention_days: ${retention_label}" echo " app.node_offline_threshold: ${offline_threshold}" echo " redis.addr: ${redis_addr}" if [[ -n "$redis_password" ]]; then say " redis.password: (已配置并隐藏)" " redis.password: (configured and hidden)" else say " redis.password: (空)" " redis.password: (empty)" fi echo " http.trusted_proxies: ${trusted_proxies}" } as_postgres() { if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then if need_cmd runuser; then runuser -u postgres -- "$@" return fi if need_cmd su; then su -s /bin/bash postgres -c "$(printf '%q ' "$@")" return fi die "$(txt "缺少 runuser/su,无法以 postgres 用户执行命令" "Missing runuser/su; cannot run commands as postgres")" fi if need_cmd sudo; then sudo -u postgres "$@" return fi die "$(txt "非 root 且未安装 sudo,无法以 postgres 用户执行命令" "Not running as root and sudo is not installed; cannot run commands as postgres")" } prompt_yes_no() { local prompt="$1" default="${2:-Y}" ans while true; do local hint="[Y/n]" case "${default,,}" in y | yes) hint="[Y/n]" ;; n | no) hint="[y/N]" ;; esac read -r -p "${prompt} ${hint} " ans || true ans="${ans:-$default}" case "${ans,,}" in y | yes) return 0 ;; n | no) return 1 ;; *) say "请输入 y 或 n" "Please enter y or n" ;; esac done } systemd_available() { need_cmd systemctl && [[ -d /run/systemd/system ]] } detect_service_manager() { if systemd_available; then printf '%s\n' systemd return 0 fi printf '%s\n' none } select_service_manager() { local detected detected="$(detect_service_manager)" case "$SERVICE_MANAGER_MODE" in auto) [[ "$detected" != "none" ]] || die "$(txt "未检测到受支持的服务管理器;如需手动安装,请使用 --service-manager=none" "No supported service manager was detected; use --service-manager=none for a manual installation")" SERVICE_MANAGER="$detected" ;; systemd) [[ "$detected" == "$SERVICE_MANAGER_MODE" ]] || die "$(txt "请求的服务管理器不可用:${SERVICE_MANAGER_MODE}" "Requested service manager is unavailable: ${SERVICE_MANAGER_MODE}")" SERVICE_MANAGER="$SERVICE_MANAGER_MODE" ;; none) SERVICE_MANAGER="none" ;; *) die "$(txt "无效的服务管理器:${SERVICE_MANAGER_MODE}(支持 auto/systemd/none)" "Invalid service manager: ${SERVICE_MANAGER_MODE} (supported: auto/systemd/none)")" ;; esac } ensure_process_control() { need_cmd pgrep || die "$(txt "需要 pgrep 检查并停止现有 Dash 进程" "pgrep is required to detect and stop an existing Dash process")" } stop_manual_processes() { local -a pids=() mapfile -t pids < <(pgrep -f -- "$BIN_PATH" 2>/dev/null || true) ((${#pids[@]} > 0)) || return 0 say "停止现有手动 Dash 进程" "Stopping existing manually started Dash process" as_root kill -TERM "${pids[@]}" >/dev/null 2>&1 || true for _ in {1..50}; do mapfile -t pids < <(pgrep -f -- "$BIN_PATH" 2>/dev/null || true) ((${#pids[@]} == 0)) && return 0 sleep 0.1 done as_root kill -KILL "${pids[@]}" >/dev/null 2>&1 || true sleep 0.1 if pgrep -f -- "$BIN_PATH" >/dev/null 2>&1; then die "$(txt "无法停止现有 Dash 进程" "Failed to stop the existing Dash process")" fi } enable_time_sync() { say "启用系统时间同步(NTP,失败不影响安装)" "Enabling system time sync (NTP; non-fatal)" if need_cmd timedatectl && as_root timedatectl set-ntp true >/dev/null 2>&1; then say "系统时间同步已启用。" "System time sync is enabled." return 0 fi local unit case "$SERVICE_MANAGER" in systemd) for unit in systemd-timesyncd.service chronyd.service ntpd.service ntp.service; do if as_root systemctl enable --now "$unit" >/dev/null 2>&1; then say "系统时间同步服务已启动:${unit}" "System time sync service started: ${unit}" return 0 fi done ;; none) ;; esac say_err "警告:未能自动启用系统时间同步,请手动检查 NTP/chrony/systemd-timesyncd。" "WARNING: Could not enable system time sync automatically; please check NTP/chrony/systemd-timesyncd manually." return 0 } prompt_string() { local prompt="$1" default="${2:-}" out if [[ -n "$default" ]]; then read -r -p "${prompt} [${default}] " out || true echo "${out:-$default}" else read -r -p "${prompt} " out || true echo "$out" fi } prompt_language() { local default="${1:-$([[ "${INSTALL_LANG:-zh}" == "en" ]] && echo 1 || echo 2)}" ans while true; do say_err "请选择默认语言(app.language):" "Select default language (app.language):" say_err " 1) English(en)" " 1) English (en)" say_err " 2) 中文(zh)" " 2) 中文 (zh)" read -r -p "$(txt "请输入序号 [${default}] " "Enter number [${default}] ")" ans || true ans="${ans:-$default}" case "$ans" in 1) echo "en" return 0 ;; 2) echo "zh" return 0 ;; *) say_err "请输入 1 或 2" "Please enter 1 or 2" ;; esac done } prompt_retention_days() { local default="${1:-1}" ans while true; do say_err "请选择网卡 raw 和服务检查保留时长(database.retention_days):" "Select raw NIC metric and service-check retention (database.retention_days):" say_err "如需掌握流量历史或 95 计费历史,建议选择 90 days 或更高。" "Choose 90 days or higher if you need traffic history or 95th percentile billing history." say_err " 1) default(45 days)" " 1) default (45 days)" echo " 2) 90 days" >&2 echo " 3) 180 days" >&2 echo " 4) 365 days" >&2 read -r -p "$(txt "请输入序号 [${default}] " "Enter number [${default}] ")" ans || true ans="${ans:-$default}" case "$ans" in 1) echo "default" return 0 ;; 2) echo "90" return 0 ;; 3) echo "180" return 0 ;; 4) echo "365" return 0 ;; *) say_err "请输入 1、2、3 或 4" "Please enter 1, 2, 3, or 4" ;; esac done } prompt_secret_confirm() { local prompt="$1" a b while true; do IFS= read -r -s -p "${prompt}: " a printf '\n' >&2 IFS= read -r -s -p "$(txt "请再次输入确认: " "Confirm again: ")" b printf '\n' >&2 [[ -n "$a" ]] || { say_err "不能为空,请重试" "Cannot be empty, please retry" continue } [[ "$a" == "$b" ]] || { say_err "两次输入不一致,请重试" "Passwords do not match, please retry" continue } echo "$a" return 0 done } prompt_secret_optional() { local prompt="$1" a b while true; do IFS= read -r -s -p "${prompt}: " a printf '\n' >&2 if [[ -z "$a" ]]; then printf '\n' return 0 fi if [[ "$a" =~ [[:cntrl:]] ]]; then say_err "Redis 密码不能包含控制字符,请重试" "Redis password cannot contain control characters, please retry" continue fi IFS= read -r -s -p "$(txt "请再次输入确认: " "Confirm again: ")" b printf '\n' >&2 [[ "$a" == "$b" ]] || { say_err "两次输入不一致,请重试" "Passwords do not match, please retry" continue } printf '%s\n' "$a" return 0 done } version_ge() { local a="$1" b="$2" local IFS=. local -a av bv read -r -a av <<<"$a" read -r -a bv <<<"$b" for i in 0 1 2; do local ai="${av[i]:-0}" bi="${bv[i]:-0}" if ((10#${ai} > 10#${bi})); then return 0; fi if ((10#${ai} < 10#${bi})); then return 1; fi done return 0 } package_version_matches() { local version="${1#*:}" want="$2" case "$version" in "$want" | "$want"-* | "$want"+* | "$want"~*) return 0 ;; *) return 1 ;; esac } apt_package_version() { local package="$1" want="$2" version while IFS='|' read -r _ version _; do version="$(trim_spaces "$version")" if package_version_matches "$version" "$want"; then printf '%s\n' "$version" return 0 fi done < <(apt-cache madison "$package" 2>/dev/null) return 1 } arch_package_version() { local package="$1" LC_ALL=C pacman -Si "$package" 2>/dev/null | awk -F: '$1 ~ /^Version[[:space:]]*$/ {sub(/^[[:space:]]*/, "", $2); print $2; exit}' } require_arch_package_version() { local package="$1" want="$2" version version="$(arch_package_version "$package" || true)" if [[ -z "$version" ]]; then die "$(txt "仓库中没有软件包 ${package}" "Package ${package} is unavailable in the configured repository")" fi package_version_matches "$version" "$want" || die "$(txt "仓库中的 ${package}=${version} 不符合新装锁定版本 ${want};请预装兼容依赖后重试" "Repository package ${package}=${version} does not match the fresh-install pin ${want}; preinstall compatible dependencies and retry")" } find_listening_pids_on_port() { local port="$1" if need_cmd ss; then ss -H -ltnp "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | sort -u return 0 fi if need_cmd lsof; then lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t 2>/dev/null | sort -u return 0 fi if need_cmd fuser; then fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | sed -n 's/^\([0-9][0-9]*\)$/\1/p' | sort -u return 0 fi return 0 } pid_comm() { local pid="$1" if [[ -r "/proc/${pid}/comm" ]]; then tr -d '\n' <"/proc/${pid}/comm" return 0 fi if need_cmd ps; then ps -p "$pid" -o comm= 2>/dev/null | head -n1 | tr -d '[:space:]' return 0 fi return 1 } pid_looks_like_redis() { local pid="$1" comm="" comm="$(pid_comm "$pid" 2>/dev/null || true)" case "$comm" in redis | redis-server | redis-sentinel) return 0 ;; esac if [[ -r "/proc/${pid}/cmdline" ]]; then tr '\0' ' ' <"/proc/${pid}/cmdline" | grep -Eq '(^|[ /])(redis|redis-server|redis-sentinel)( |$)' return $? fi return 1 } port_has_redis_listener() { local port="$1" local pids="${2:-}" local pid if [[ -z "$pids" ]]; then pids="$(find_listening_pids_on_port "$port")" fi for pid in $pids; do if pid_looks_like_redis "$pid"; then return 0 fi done return 1 } confirm_redis_takeover() { if [[ "$REDIS_TAKEOVER_CONFIRMED" == "1" ]]; then return 0 fi local pids pids="$(find_listening_pids_on_port 6379)" if [[ ! -e /etc/redis/redis.conf && ! -e /etc/systemd/system/redis-server.service && ! -e /etc/init.d/redis && ! -e /etc/init.d/redis-server && -z "$pids" ]]; then REDIS_TAKEOVER_CONFIRMED="1" return 0 fi if ! prompt_yes_no "$(txt "安装器将备份并覆盖现有 Redis 配置/服务,并停止 6379 端口上的旧进程。是否继续?" "The installer will back up and replace the existing Redis configuration/service and stop the old process on port 6379. Continue?")" "Y"; then return 1 fi REDIS_TAKEOVER_CONFIRMED="1" } kill_listeners_on_port() { local port="$1" local pids pids="$(find_listening_pids_on_port "$port")" if [[ -z "$pids" ]]; then return 0 fi if port_has_redis_listener "$port" "$pids"; then say "检测到端口 ${port} 已由 Redis 监听,先停止旧 Redis 进程:${pids}" "Port ${port} is already owned by Redis; stopping old Redis listeners: ${pids}" else say "检测到端口 ${port} 已被占用,尝试结束占用进程:${pids}" "Port ${port} is in use; terminating listeners: ${pids}" fi case "$SERVICE_MANAGER" in systemd) as_root systemctl stop redis-server.service >/dev/null 2>&1 || true as_root systemctl stop redis.service >/dev/null 2>&1 || true ;; none) ;; esac for pid in $pids; do as_root kill -TERM "$pid" >/dev/null 2>&1 || true done sleep 1 pids="$(find_listening_pids_on_port "$port")" if [[ -z "$pids" ]]; then return 0 fi for pid in $pids; do as_root kill -KILL "$pid" >/dev/null 2>&1 || true done sleep 1 pids="$(find_listening_pids_on_port "$port")" if [[ -n "$pids" ]]; then die "$(txt "无法释放端口 ${port}(仍占用的 PID:${pids})" "Failed to free port ${port} (still listening PIDs: ${pids})")" fi } sql_escape_literal() { local s="$1" s="${s//\'/\'\'}" printf "%s" "$s" } systemd_escape_env_value() { local s="$1" s="${s//%/%%}" s="${s//\\/\\\\}" s="${s//\"/\\\"}" printf "%s" "$s" } shell_quote_arg() { local s="$1" s="${s//\'/\'\\\'\'}" printf "'%s'" "$s" } sed_escape_repl() { local s="$1" s="${s//\\/\\\\}" s="${s//&/\\&}" s="${s//|/\\|}" printf "%s" "$s" } write_redis_conf() { [[ "$REDIS_TAKEOVER_CONFIRMED" == "1" ]] || die "$(txt "内部错误:覆盖 Redis 配置前未确认" "Internal error: Redis takeover was not confirmed")" as_root install -d -m 0755 /etc/redis /var/lib/redis /var/log/redis as_root chown -R redis:redis /var/lib/redis /var/log/redis >/dev/null 2>&1 || true if [[ -f /etc/redis/redis.conf ]]; then local bak="/etc/redis/redis.conf.bak.$(date +%Y%m%d%H%M%S)" as_root cp -f /etc/redis/redis.conf "$bak" fi local supervised="no" [[ "$SERVICE_MANAGER" != "systemd" ]] || supervised="systemd" as_root bash -c "cat > /etc/redis/redis.conf <= 11)) || die "$(txt "仅支持 Debian 11+,当前 VERSION_ID=${OS_VERSION_ID}" "Only Debian 11+ is supported (current VERSION_ID=${OS_VERSION_ID})")" OS_FAMILY="debian" APT_REPO_ID="debian" APT_REPO_CODENAME="${DEBIAN_CODENAME:-${VERSION_CODENAME:-}}" PKG_MANAGER="apt-get" PKG_MANAGER_LABEL="apt-get" ;; ubuntu) local major="${OS_VERSION_ID%%.*}" [[ "$major" =~ ^[0-9]+$ ]] || die "$(txt "无法解析 Ubuntu VERSION_ID=${OS_VERSION_ID:-}" "Cannot parse Ubuntu VERSION_ID=${OS_VERSION_ID:-}")" ((major >= 22)) || die "$(txt "仅支持 Ubuntu 22+,当前 VERSION_ID=${OS_VERSION_ID}" "Only Ubuntu 22+ is supported (current VERSION_ID=${OS_VERSION_ID})")" OS_FAMILY="debian" APT_REPO_ID="ubuntu" APT_REPO_CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" PKG_MANAGER="apt-get" PKG_MANAGER_LABEL="apt-get" ;; rhel | rocky | almalinux | ol | centos) local major="${OS_VERSION_ID%%.*}" [[ "$major" =~ ^[0-9]+$ ]] || die "$(txt "无法解析 ${OS_ID} VERSION_ID=${OS_VERSION_ID:-}" "Cannot parse ${OS_ID} VERSION_ID=${OS_VERSION_ID:-}")" ((major >= 8)) || die "$(txt "仅支持 RHEL/Rocky/Alma/Oracle/CentOS 8+,当前 VERSION_ID=${OS_VERSION_ID}" "Only RHEL/Rocky/Alma/Oracle/CentOS 8+ is supported (current VERSION_ID=${OS_VERSION_ID})")" OS_FAMILY="rhel" if need_cmd dnf; then PKG_MANAGER="dnf" elif need_cmd yum; then PKG_MANAGER="yum" else die "$(txt "未检测到 dnf/yum" "Neither dnf nor yum was found")" fi PKG_MANAGER_LABEL="${PKG_MANAGER}" ;; fedora) local major="${OS_VERSION_ID%%.*}" [[ "$major" =~ ^[0-9]+$ ]] || die "$(txt "无法解析 Fedora VERSION_ID=${OS_VERSION_ID:-}" "Cannot parse Fedora VERSION_ID=${OS_VERSION_ID:-}")" ((major >= 33)) || die "$(txt "仅支持 Fedora 33+,当前 VERSION_ID=${OS_VERSION_ID}" "Only Fedora 33+ is supported (current VERSION_ID=${OS_VERSION_ID})")" OS_FAMILY="fedora" if need_cmd dnf; then PKG_MANAGER="dnf" elif need_cmd yum; then PKG_MANAGER="yum" else die "$(txt "未检测到 dnf/yum" "Neither dnf nor yum was found")" fi PKG_MANAGER_LABEL="${PKG_MANAGER}" ;; arch | manjaro) OS_FAMILY="arch" PKG_MANAGER="pacman" PKG_MANAGER_LABEL="pacman" ;; alpine) OS_FAMILY="manual" PKG_MANAGER="" PKG_MANAGER_LABEL="manual" say_err "警告:Alpine 上的 Dash 仅支持显式手动模式;PostgreSQL ${POSTGRES_MAJOR}+、匹配其主版本的 TimescaleDB 和 Redis 必须预先安装并运行;全新环境目标为 PostgreSQL ${POSTGRES_VERSION} / TimescaleDB ${TIMESCALEDB_VERSION}。" "WARNING: Dash supports only explicit manual mode on Alpine; PostgreSQL ${POSTGRES_MAJOR}+, TimescaleDB for that PostgreSQL major, and Redis must already be installed and running; the fresh-environment target is PostgreSQL ${POSTGRES_VERSION} / TimescaleDB ${TIMESCALEDB_VERSION}." return 0 ;; *) case " ${ID_LIKE:-} " in *" ubuntu "*) OS_FAMILY="debian" APT_REPO_ID="ubuntu" APT_REPO_CODENAME="${UBUNTU_CODENAME:-}" [[ -n "$APT_REPO_CODENAME" ]] || die "$(txt "无法确定 ${OS_ID} 对应的 Ubuntu 基础版本代号(UBUNTU_CODENAME)" "Cannot determine the Ubuntu base codename for ${OS_ID} (UBUNTU_CODENAME)")" PKG_MANAGER="apt-get" PKG_MANAGER_LABEL="apt-get" ;; *" debian "*) OS_FAMILY="debian" APT_REPO_ID="debian" APT_REPO_CODENAME="${DEBIAN_CODENAME:-}" [[ -n "$APT_REPO_CODENAME" ]] || die "$(txt "无法确定 ${OS_ID} 对应的 Debian 基础版本代号(DEBIAN_CODENAME)" "Cannot determine the Debian base codename for ${OS_ID} (DEBIAN_CODENAME)")" PKG_MANAGER="apt-get" PKG_MANAGER_LABEL="apt-get" ;; *" rhel "* | *" fedora "*) OS_FAMILY="rhel" if need_cmd dnf; then PKG_MANAGER="dnf" elif need_cmd yum; then PKG_MANAGER="yum" else die "$(txt "未检测到 dnf/yum" "Neither dnf nor yum was found")" fi PKG_MANAGER_LABEL="${PKG_MANAGER}" ;; *" arch "*) OS_FAMILY="arch" PKG_MANAGER="pacman" PKG_MANAGER_LABEL="pacman" ;; *) if [[ "$SERVICE_MANAGER" == "none" ]]; then OS_FAMILY="manual" PKG_MANAGER="" PKG_MANAGER_LABEL="manual" return 0 fi die "$(txt "仅支持 Debian/Ubuntu、RHEL/Rocky/Alma/Oracle/Fedora、Arch/Manjaro;当前系统 ID=${OS_ID:-unknown}" "Only Debian/Ubuntu, RHEL/Rocky/Alma/Oracle/Fedora, and Arch/Manjaro are supported (current ID=${OS_ID:-unknown})")" ;; esac ;; esac } pkg_update() { case "${PKG_MANAGER}" in apt-get) as_root apt-get update -y ;; dnf) as_root dnf makecache -y ;; yum) as_root yum makecache -y ;; pacman) as_root pacman -Sy --noconfirm ;; *) die "$(txt "不支持的包管理器:${PKG_MANAGER:-unknown}" "Unsupported package manager: ${PKG_MANAGER:-unknown}")" ;; esac } pkg_install() { case "${PKG_MANAGER}" in apt-get) as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;; dnf) as_root dnf install -y "$@" ;; yum) as_root yum install -y "$@" ;; pacman) as_root pacman -S --noconfirm --needed "$@" ;; *) die "$(txt "不支持的包管理器:${PKG_MANAGER:-unknown}" "Unsupported package manager: ${PKG_MANAGER:-unknown}")" ;; esac } pkg_install_first_available() { local pkg for pkg in "$@"; do if pkg_install "$pkg" >/dev/null 2>&1; then printf '%s\n' "$pkg" return 0 fi done return 1 } ensure_pkg_prereqs() { pkg_update case "${OS_FAMILY}" in debian) pkg_install ca-certificates curl gnupg ;; rhel | fedora) pkg_install ca-certificates curl gnupg2 ;; arch) pkg_install curl gnupg ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac } ensure_postgres_binaries_on_path() { local current_major="" if need_cmd psql; then current_major="$(psql --version 2>/dev/null | sed -nE 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/p')" fi local dir major best_dir="" best_major=0 if [[ "$current_major" =~ ^[0-9]+$ ]] && ((current_major >= POSTGRES_MAJOR)); then best_major="$current_major" fi local -a dirs=() shopt -s nullglob dirs=(/usr/pgsql-*/bin /usr/lib/postgresql/*/bin) shopt -u nullglob for dir in "${dirs[@]}"; do [[ -x "${dir}/psql" ]] || continue major="$("${dir}/psql" --version 2>/dev/null | sed -nE 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/p')" if [[ "$major" =~ ^[0-9]+$ ]] && ((major >= POSTGRES_MAJOR && major > best_major)); then best_dir="$dir" best_major="$major" fi done if [[ -n "$best_dir" ]]; then export PATH="${best_dir}:${PATH}" fi } systemd_enable_now_first() { local unit for unit in "$@"; do if as_root systemctl enable --now "$unit" >/dev/null 2>&1; then return 0 fi done return 1 } systemd_restart_first() { local unit for unit in "$@"; do if as_root systemctl restart "$unit" >/dev/null 2>&1; then return 0 fi done return 1 } enable_postgres_service() { local pg_major pg_major="$(postgres_major_version)" [[ "$pg_major" =~ ^[0-9]+$ ]] || pg_major="$POSTGRES_MAJOR" case "$SERVICE_MANAGER" in systemd) case "${OS_FAMILY}" in debian | arch) as_root systemctl enable --now postgresql.service ;; rhel | fedora) as_root systemctl enable --now "postgresql-${pg_major}.service" ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac ;; none) return 0 ;; esac } restart_postgres_service() { local pg_major pg_major="$(postgres_major_version)" [[ "$pg_major" =~ ^[0-9]+$ ]] || die "$(txt "无法确定 PostgreSQL 主版本" "Cannot determine PostgreSQL major version")" case "$SERVICE_MANAGER" in systemd) case "${OS_FAMILY}" in debian | arch) as_root systemctl restart postgresql.service ;; rhel | fedora) as_root systemctl restart "postgresql-${pg_major}.service" ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac ;; none) return 0 ;; esac } enable_restart_redis_service() { case "$SERVICE_MANAGER" in systemd) systemd_enable_now_first redis-server.service redis6.service redis.service >/dev/null 2>&1 || true systemd_restart_first redis-server.service redis6.service redis.service >/dev/null 2>&1 || true ;; none) return 0 ;; esac } write_systemd_redis_service() { [[ "$REDIS_TAKEOVER_CONFIRMED" == "1" ]] || die "$(txt "内部错误:覆盖 Redis 服务前未确认" "Internal error: Redis takeover was not confirmed")" local unit="/etc/systemd/system/redis-server.service" as_root install -d -m 0755 /etc/systemd/system if [[ -e "$unit" || -L "$unit" ]]; then local bak="${unit}.bak.$(date +%Y%m%d%H%M%S)" as_root cp -a "$unit" "$bak" >/dev/null 2>&1 || true as_root rm -f "$unit" fi as_root bash -c "cat > ${unit} <<'EOF' [Unit] Description=Redis In-Memory Data Store After=network-online.target Wants=network-online.target [Service] Type=notify User=redis Group=redis RuntimeDirectory=redis RuntimeDirectoryMode=0755 ExecStart=/usr/local/bin/redis-server /etc/redis/redis.conf ExecStop=/usr/local/bin/redis-cli shutdown Restart=always RestartSec=2 LimitNOFILE=65535 [Install] WantedBy=multi-user.target EOF" } write_redis_service() { [[ "$REDIS_TAKEOVER_CONFIRMED" == "1" ]] || die "$(txt "内部错误:覆盖 Redis 服务前未确认" "Internal error: Redis takeover was not confirmed")" case "$SERVICE_MANAGER" in systemd) write_systemd_redis_service ;; none) return 0 ;; esac } install_repo_key() { local url="$1" fingerprints="$2" destination="$3" format="${4:-armored}" local tmp actual expected tmp="$(mktemp -d)" if ! curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL -o "${tmp}/key" "$url"; then rm -rf "$tmp" die "$(txt "下载仓库签名密钥失败:${url}" "Failed to download repository signing key: ${url}")" fi if ! actual="$(gpg --batch --with-colons --show-keys "${tmp}/key" 2>/dev/null | awk -F: '$1 == "pub" {primary=1; next} primary && $1 == "fpr" {print $10; primary=0}' | sort -u)"; then rm -rf "$tmp" die "$(txt "无法读取仓库签名密钥:${url}" "Failed to inspect repository signing key: ${url}")" fi expected="$(printf '%s\n' "$fingerprints" | tr ',' '\n' | sort -u)" if [[ -z "$actual" || "$actual" != "$expected" ]]; then rm -rf "$tmp" die "$(txt "仓库签名密钥指纹不匹配:${url}" "Repository signing-key fingerprint mismatch: ${url}")" fi local source="${tmp}/key" if [[ "$format" == "gpg" ]]; then if ! gpg --batch --yes --dearmor --output "${tmp}/key.gpg" "${tmp}/key"; then rm -rf "$tmp" die "$(txt "转换仓库签名密钥失败" "Failed to convert repository signing key")" fi source="${tmp}/key.gpg" fi as_root install -D -m 0644 "$source" "$destination" rm -rf "$tmp" } setup_postgresql_repo() { local arch arch="$(uname -m)" case "${OS_FAMILY}" in debian) if [[ -z "${APT_REPO_CODENAME}" && "$OS_ID" == "$APT_REPO_ID" ]] && need_cmd lsb_release; then APT_REPO_CODENAME="$(lsb_release -cs 2>/dev/null || true)" fi [[ -n "${APT_REPO_CODENAME}" ]] || die "$(txt "无法确定 Debian/Ubuntu 基础版本代号" "Cannot determine the Debian/Ubuntu base codename")" local keyring="/usr/share/keyrings/postgresql-archive-keyring.gpg" install_repo_key \ https://www.postgresql.org/media/keys/ACCC4CF8.asc \ B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 \ "$keyring" \ gpg as_root bash -c "cat > /etc/apt/sources.list.d/pgdg.list </dev/null 2>&1 || true else as_root yum -qy module disable postgresql >/dev/null 2>&1 || true fi ;; fedora) local major="${OS_VERSION_ID%%.*}" pkg_install "https://download.postgresql.org/pub/repos/yum/reporpms/F-${major}-${arch}/pgdg-fedora-repo-latest.noarch.rpm" if [[ "${PKG_MANAGER}" == "dnf" ]]; then as_root dnf -qy module disable postgresql >/dev/null 2>&1 || true else as_root yum -qy module disable postgresql >/dev/null 2>&1 || true fi ;; arch) return 0 ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac } postgres_cluster_initialized() { local pg_version_file for pg_version_file in \ "/var/lib/postgresql/${POSTGRES_MAJOR}/main/PG_VERSION" \ /var/lib/postgresql/data/PG_VERSION \ "/var/lib/pgsql/${POSTGRES_MAJOR}/data/PG_VERSION" \ /var/lib/pgsql/data/PG_VERSION \ /var/lib/postgres/data/PG_VERSION; do if [[ -f "$pg_version_file" ]]; then return 0 fi done return 1 } init_postgres_cluster_if_needed() { postgres_cluster_initialized && return 0 ensure_postgres_binaries_on_path case "${OS_FAMILY}" in debian) need_cmd pg_createcluster || die "$(txt "缺少 pg_createcluster,无法初始化 PostgreSQL ${POSTGRES_MAJOR}" "pg_createcluster is required to initialize PostgreSQL ${POSTGRES_MAJOR}")" as_root pg_createcluster "$POSTGRES_MAJOR" main ;; rhel | fedora) local setup="/usr/pgsql-${POSTGRES_MAJOR}/bin/postgresql-${POSTGRES_MAJOR}-setup" [[ -x "$setup" ]] || die "$(txt "缺少 postgresql-${POSTGRES_MAJOR}-setup" "postgresql-${POSTGRES_MAJOR}-setup is missing")" as_root "$setup" initdb ;; arch) need_cmd initdb || die "$(txt "缺少 initdb" "initdb is missing")" as_root install -d -m 0700 -o postgres -g postgres /var/lib/postgres/data as_postgres initdb -D /var/lib/postgres/data ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac postgres_cluster_initialized || die "$(txt "PostgreSQL 数据目录初始化失败" "PostgreSQL data directory was not initialized")" } install_postgresql16() { ensure_pkg_prereqs setup_postgresql_repo pkg_update case "${OS_FAMILY}" in debian) local server="postgresql-${POSTGRES_MAJOR}" client="postgresql-client-${POSTGRES_MAJOR}" local server_version client_version server_version="$(apt_package_version "$server" "$POSTGRES_VERSION" || true)" client_version="$(apt_package_version "$client" "$POSTGRES_VERSION" || true)" [[ -n "$server_version" && -n "$client_version" ]] || die "$(txt "PGDG 仓库没有新装锁定的 PostgreSQL ${POSTGRES_VERSION}" "The PGDG repository does not provide the pinned fresh-install PostgreSQL ${POSTGRES_VERSION}")" pkg_install "${server}=${server_version}" "${client}=${client_version}" ;; rhel | fedora) pkg_install \ "postgresql${POSTGRES_MAJOR}-server-${POSTGRES_VERSION}-*" \ "postgresql${POSTGRES_MAJOR}-${POSTGRES_VERSION}-*" ;; arch) require_arch_package_version postgresql "$POSTGRES_VERSION" pkg_install postgresql ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac ensure_postgres_binaries_on_path init_postgres_cluster_if_needed enable_postgres_service local installed_version installed_version="$(postgres_server_version || true)" [[ "$installed_version" == "$POSTGRES_VERSION" ]] || die "$(txt "新装 PostgreSQL 版本校验失败:当前 ${installed_version:-unknown},要求 ${POSTGRES_VERSION}" "Fresh PostgreSQL version check failed: got ${installed_version:-unknown}, require ${POSTGRES_VERSION}")" } postgres_server_version() { ensure_postgres_binaries_on_path need_cmd psql || return 1 local server_num server_num="$(as_postgres psql -d postgres -tAc 'SHOW server_version_num' 2>/dev/null | tr -d '[:space:]')" [[ "$server_num" =~ ^[0-9]+$ ]] && ((server_num >= 10000)) || return 1 printf '%s.%s\n' "$((server_num / 10000))" "$((server_num % 10000))" } postgres_major_version() { ensure_postgres_binaries_on_path if ! need_cmd psql; then echo "" return 0 fi local server_num v server_num="$(as_postgres psql -d postgres -tAc 'SHOW server_version_num' 2>/dev/null | tr -d '[:space:]' || true)" if [[ "$server_num" =~ ^[0-9]+$ ]] && ((server_num >= 10000)); then echo "$((server_num / 10000))" return 0 fi v="$(psql --version 2>/dev/null | awk '{print $3}' || true)" echo "${v%%.*}" } ensure_postgresql16_and_password() { local installed_by_script="0" local major major="$(postgres_major_version)" if [[ -z "$major" ]] || ! [[ "$major" =~ ^[0-9]+$ ]]; then if prompt_yes_no "$(txt "未检测到 PostgreSQL,是否安装新装锁定版本 PostgreSQL ${POSTGRES_VERSION}?" "PostgreSQL not detected. Install the pinned fresh-install version ${POSTGRES_VERSION}?")"; then install_postgresql16 installed_by_script="1" else die "$(txt "未安装 PostgreSQL,无法继续" "PostgreSQL is required")" fi elif ((major < POSTGRES_MAJOR)); then if prompt_yes_no "$(txt "检测到 PostgreSQL ${major},需要 ${POSTGRES_MAJOR}+。是否安装 PostgreSQL ${POSTGRES_VERSION}?" "Detected PostgreSQL ${major}. Need ${POSTGRES_MAJOR}+. Install PostgreSQL ${POSTGRES_VERSION}?")"; then install_postgresql16 installed_by_script="1" else die "$(txt "PostgreSQL 版本不足,无法继续" "PostgreSQL version too old")" fi fi if [[ "$installed_by_script" == "1" ]]; then local pw pw_sql pw="$(prompt_secret_confirm "$(txt "请设置 PostgreSQL 管理员(postgres)密码" "Set PostgreSQL admin (postgres) password")")" pw_sql="$(sql_escape_literal "$pw")" as_postgres psql -v ON_ERROR_STOP=1 -c "ALTER USER postgres WITH PASSWORD '${pw_sql}';" >/dev/null fi } timescaledb_installed() { ensure_postgres_binaries_on_path local pg_major pg_major="$(postgres_major_version)" [[ "$pg_major" =~ ^[0-9]+$ ]] || return 1 case "${OS_FAMILY}" in debian) [[ -f "/usr/share/postgresql/${pg_major}/extension/timescaledb.control" ]] ;; rhel | fedora) [[ -f "/usr/pgsql-${pg_major}/share/extension/timescaledb.control" ]] ;; arch) need_cmd pg_config || return 1 local sharedir sharedir="$(pg_config --sharedir)" || return 1 [[ -f "${sharedir}/extension/timescaledb.control" ]] ;; manual) need_cmd pg_config || return 1 local sharedir sharedir="$(pg_config --sharedir)" || return 1 [[ -f "${sharedir}/extension/timescaledb.control" ]] ;; *) return 1 ;; esac } install_timescaledb_for_postgres() { ensure_pkg_prereqs local pg_major pg_major="$(postgres_major_version)" [[ "$pg_major" =~ ^[0-9]+$ ]] && ((pg_major >= POSTGRES_MAJOR)) || die "$(txt "无法确定受支持的 PostgreSQL 主版本" "Cannot determine a supported PostgreSQL major version")" case "${OS_FAMILY}" in debian) [[ -n "${APT_REPO_ID}" && -n "${APT_REPO_CODENAME}" ]] || die "$(txt "无法确定 Debian/Ubuntu 基础仓库身份" "Cannot determine the Debian/Ubuntu base repository identity")" local keyring="/etc/apt/keyrings/timescale_timescaledb-archive-keyring.gpg" install_repo_key \ https://packagecloud.io/timescale/timescaledb/gpgkey \ 1005FB68604CE9B8F6879CF759F18EDF47F24417,0641009A8366FDE4444A7B62E7391C94080429FF \ "$keyring" \ gpg as_root bash -c "cat > /etc/apt/sources.list.d/timescale_timescaledb.list < /etc/yum.repos.d/timescale_timescaledb.repo </dev/null | tr -d '[:space:]' || true)" [[ "$installed_version" == "$TIMESCALEDB_VERSION" ]] || die "$(txt "新装 TimescaleDB 版本校验失败:当前 ${installed_version:-unknown},要求 ${TIMESCALEDB_VERSION}" "Fresh TimescaleDB version check failed: got ${installed_version:-unknown}, require ${TIMESCALEDB_VERSION}")" } redis_version() { local best="" bin v local -a bins=() if need_cmd redis-server; then bins+=("$(command -v redis-server)") fi bins+=(/usr/local/bin/redis-server /usr/bin/redis-server /bin/redis-server) for bin in "${bins[@]}"; do [[ -x "$bin" ]] || continue v="$("$bin" --version 2>/dev/null | sed -n 's/.*v=\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n1 || true)" [[ -n "$v" ]] || continue if [[ -z "$best" ]] || version_ge "$v" "$best"; then best="$v" fi done echo "$best" } install_redis_via_package_manager() { ensure_pkg_prereqs case "${OS_FAMILY}" in debian) pkg_install redis-server || return 1 ;; rhel | fedora) pkg_install_first_available redis6 redis >/dev/null || return 1 ;; arch) pkg_install redis || return 1 ;; *) return 1 ;; esac if ! id -u redis >/dev/null 2>&1; then as_root useradd --system --no-create-home --shell /usr/sbin/nologin redis fi } install_redis_build_deps() { case "${OS_FAMILY}" in debian) if [[ "$SERVICE_MANAGER" == "systemd" ]]; then pkg_install build-essential pkg-config tcl libsystemd-dev else pkg_install build-essential pkg-config tcl fi ;; rhel | fedora) if [[ "$SERVICE_MANAGER" == "systemd" ]]; then pkg_install gcc make pkgconf-pkg-config tcl systemd-devel else pkg_install gcc make pkgconf-pkg-config tcl fi ;; arch) pkg_install base-devel pkgconf tcl ;; *) die "$(txt "未知系统族:${OS_FAMILY:-unknown}" "Unknown OS family: ${OS_FAMILY:-unknown}")" ;; esac } install_redis_from_source() { local ver="${1:-8.2.5}" if ! confirm_redis_takeover; then return 1 fi ensure_pkg_prereqs install_redis_build_deps ( local tmp tgz tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT tgz="${tmp}/redis-${ver}.tar.gz" curl -fsSL -o "$tgz" "https://download.redis.io/releases/redis-${ver}.tar.gz" tar -C "$tmp" -xzf "$tgz" pushd "${tmp}/redis-${ver}" >/dev/null if [[ "$SERVICE_MANAGER" == "systemd" ]]; then make USE_SYSTEMD=yes -j"$(nproc)" else make -j"$(nproc)" fi as_root make install hash -r || true popd >/dev/null if ! id -u redis >/dev/null 2>&1; then as_root useradd --system --no-create-home --shell /usr/sbin/nologin redis fi write_redis_conf write_redis_service kill_listeners_on_port 6379 case "$SERVICE_MANAGER" in systemd) as_root systemctl daemon-reload as_root systemctl enable --now redis-server.service ;; none) ;; esac ) } ensure_redis_82plus() { local want="8.2.3" local v local tried_pkg=0 case "${REDIS_INSTALL_METHOD}" in package | apt) v="$(redis_version)" if [[ -z "$v" ]]; then if prompt_yes_no "$(txt "未检测到 Redis,是否先尝试使用系统包管理器安装兼容的 Redis?" "Redis not detected. Try installing a compatible Redis via the system package manager?")"; then tried_pkg=1 if ! install_redis_via_package_manager; then say "系统包管理器未提供可直接使用的 redis-server。" "No usable redis-server package was found in the system repositories." fi v="$(redis_version)" else say "已跳过系统包管理器安装 Redis。" "Skipped Redis installation via the system package manager." fi fi if [[ -n "$v" ]] && version_ge "$v" "$want"; then if confirm_redis_takeover; then write_redis_conf enable_restart_redis_service else say "保留现有 Redis ${v} 的配置和服务,不执行覆盖。" "Keeping the existing Redis ${v} configuration and service; no replacement was performed." fi return 0 fi if [[ -n "$v" ]]; then say "检测到 Redis ${v},但需要 >=8.2.3。" "Detected Redis ${v}, but >=8.2.3 is required." elif [[ "$tried_pkg" -eq 1 ]]; then say "系统包管理器安装后仍未检测到可用的 redis-server。" "A usable redis-server binary is still not available after the package-manager attempt." else say "未检测到 Redis。" "Redis was not detected." fi if ! prompt_yes_no "$(txt "是否源码安装/升级 Redis(默认 8.2.5)?" "Install or upgrade Redis from source instead? (default 8.2.5)")"; then if [[ -n "$v" ]]; then die "$(txt "Redis 版本不足,无法继续" "Redis version is insufficient")" fi die "$(txt "Redis 未安装,无法继续" "Redis is required")" fi local target_ver_pkg target_ver_pkg="$(prompt_string "$(txt "请输入要源码安装的 Redis 版本" "Redis version to install from source")" "8.2.5")" install_redis_from_source "$target_ver_pkg" || die "$(txt "已取消覆盖现有 Redis,无法完成源码安装/升级" "Redis takeover was declined; the source install/upgrade cannot continue")" v="$(redis_version)" [[ -n "$v" ]] || die "$(txt "Redis 安装失败:未检测到 redis-server" "Redis install failed: redis-server not found")" version_ge "$v" "$want" || die "$(txt "Redis 版本仍不足(当前 ${v},需要 >=8.2.3)" "Redis version still too old (current ${v}, need >=8.2.3)")" ;; source) v="$(redis_version)" if [[ -n "$v" ]] && version_ge "$v" "$want"; then return 0 fi if [[ -z "$v" ]]; then if ! prompt_yes_no "$(txt "未检测到 Redis,是否源码安装 Redis(默认 8.2.5)?" "Redis not detected. Install Redis (default 8.2.5) from source?")"; then die "$(txt "Redis 未安装,无法继续" "Redis is required")" fi else if ! prompt_yes_no "$(txt "检测到 Redis ${v},需要 >=8.2.3。是否源码安装/升级?" "Detected Redis ${v}. Need >=8.2.3. Install/upgrade from source?")"; then die "$(txt "Redis 版本不足,无法继续" "Redis version too old")" fi fi local target_ver target_ver="$(prompt_string "$(txt "请输入要源码安装的 Redis 版本" "Redis version to install (source build)")" "8.2.5")" install_redis_from_source "$target_ver" || die "$(txt "已取消覆盖现有 Redis,无法完成源码安装/升级" "Redis takeover was declined; the source install/upgrade cannot continue")" v="$(redis_version)" [[ -n "$v" ]] || die "$(txt "Redis 安装失败:未检测到 redis-server" "Redis install failed: redis-server not found")" version_ge "$v" "$want" || die "$(txt "Redis 版本仍不足(当前 ${v},需要 >=8.2.3)" "Redis version still too old (current ${v}, need >=8.2.3)")" ;; *) die "$(txt "未知 REDIS_INSTALL_METHOD=${REDIS_INSTALL_METHOD}(支持:package/source;apt 仍可作为兼容别名)" "Unknown REDIS_INSTALL_METHOD=${REDIS_INSTALL_METHOD} (supported: package/source; apt remains a compatibility alias)")" ;; esac } check_preinstalled_dependencies() { local major major="$(postgres_major_version)" [[ "$major" =~ ^[0-9]+$ ]] && ((major >= POSTGRES_MAJOR)) || die "$(txt "手动依赖模式要求已安装 PostgreSQL ${POSTGRES_MAJOR}+" "Manual dependency mode requires PostgreSQL ${POSTGRES_MAJOR}+")" timescaledb_installed || die "$(txt "手动依赖模式要求已安装与当前 PostgreSQL 匹配的 TimescaleDB" "Manual dependency mode requires TimescaleDB for the installed PostgreSQL")" as_postgres psql -d postgres -tAc 'SELECT 1' >/dev/null || die "$(txt "无法连接本机 PostgreSQL;请先启动服务" "Cannot connect to local PostgreSQL; start it before continuing")" } check_redis_endpoint() { local addr="$1" password="${2:-}" local checker="${SCRIPT_DIR}/bin/dash" [[ -x "$checker" ]] || die "$(txt "安装包缺少可执行的 ${checker}" "The package is missing executable ${checker}")" if [[ -z "$password" ]]; then "$checker" check-redis --addr "$addr" return fi ( local password_file umask 077 password_file="$(mktemp -t dash-redis-password-XXXXXX)" trap 'rm -f "$password_file"' EXIT chmod 0600 "$password_file" printf '%s' "$password" >"$password_file" "$checker" check-redis --addr "$addr" --password-file "$password_file" ) } validate_ident() { local name="$1" what="$2" [[ "$name" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || die "$(txt "${what} 仅允许字母/数字/下划线,且不能以数字开头:${name}" "${what} must match [a-zA-Z_][a-zA-Z0-9_]* (got: ${name})")" } create_db_and_user() { local db_user="$1" db_pass="$2" db_name="$3" validate_ident "$db_user" "database.user" validate_ident "$db_name" "database.name" local pass_sql pass_sql="$(sql_escape_literal "$db_pass")" local role_exists db_exists role_exists="$(as_postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${db_user}'" | tr -d '[:space:]')" if [[ "$role_exists" != "1" ]]; then as_postgres psql -v ON_ERROR_STOP=1 -c "CREATE USER ${db_user} WITH PASSWORD '${pass_sql}';" >/dev/null else as_postgres psql -v ON_ERROR_STOP=1 -c "ALTER USER ${db_user} WITH PASSWORD '${pass_sql}';" >/dev/null fi db_exists="$(as_postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${db_name}'" | tr -d '[:space:]')" if [[ "$db_exists" != "1" ]]; then as_postgres psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${db_name} OWNER ${db_user};" >/dev/null fi as_postgres psql -d "${db_name}" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >/dev/null } grant_db_privileges() { local db_user="$1" db_name="$2" validate_ident "$db_user" "database.user" validate_ident "$db_name" "database.name" as_postgres psql -d "${db_name}" -v ON_ERROR_STOP=1 </dev/null GRANT CONNECT ON DATABASE ${db_name} TO ${db_user}; GRANT USAGE ON SCHEMA public TO ${db_user}; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${db_user}; GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO ${db_user}; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${db_user}; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${db_user}; SQL } render_config_local() { local dash_ip="$1" listen_port="$2" public_url="$3" db_user="$4" db_pass="$5" db_name="$6" local retention_days="${7:-default}" local redis_addr="$8" local redis_password="$9" local offline_threshold="${10:-${DEFAULT_NODE_OFFLINE_THRESHOLD}}" local language="${11:-${INSTALL_LANG:-zh}}" local trusted_proxies_yaml="${12:-[]}" as_root install -d -m 0755 "$CONFIG_DIR" [[ -f "$CONFIG_EXAMPLE" ]] || die "$(txt "缺少模板 ${CONFIG_EXAMPLE}(请在安装目录放置 config.example.yaml)" "Missing template ${CONFIG_EXAMPLE}")" local listen=":${listen_port}" listen="$(one_line "$listen")" public_url="$(one_line "$public_url")" dash_ip="$(one_line "$dash_ip")" db_user="$(one_line "$db_user")" db_pass="$(one_line "$db_pass")" db_name="$(one_line "$db_name")" retention_days="$(one_line "$retention_days")" redis_addr="$(one_line "$redis_addr")" redis_password="$(one_line "$redis_password")" offline_threshold="$(one_line "$offline_threshold")" offline_threshold="${offline_threshold:-${DEFAULT_NODE_OFFLINE_THRESHOLD}}" language="$(one_line "$language")" case "${language}" in zh | en) ;; *) die "$(txt "app.language 只能是 zh 或 en:${language}" "app.language must be zh or en: ${language}")" ;; esac case "${retention_days}" in default | 90 | 180 | 365) ;; *) die "$(txt "database.retention_days 只能是 default/90/180/365:${retention_days}" "database.retention_days must be one of default/90/180/365: ${retention_days}")" ;; esac trusted_proxies_yaml="$(one_line "$trusted_proxies_yaml")" trusted_proxies_yaml="${trusted_proxies_yaml:-[]}" local retention_line="# retention_days: 45" if [[ "${retention_days}" != "default" ]]; then retention_line="retention_days: ${retention_days}" fi local dash_ip_esc listen_esc public_url_esc db_user_esc db_pass_esc db_name_esc retention_line_esc redis_addr_esc redis_password_esc offline_th_esc language_esc dash_ip_esc="$(yaml_dq_escape "$dash_ip")" listen_esc="$(yaml_dq_escape "$listen")" public_url_esc="$(yaml_dq_escape "$public_url")" db_user_esc="$(yaml_dq_escape "$db_user")" db_pass_esc="$(yaml_dq_escape "$db_pass")" db_name_esc="$(yaml_dq_escape "$db_name")" retention_line_esc="$(yaml_dq_escape "$retention_line")" redis_addr_esc="$(yaml_dq_escape "$redis_addr")" redis_password_esc="$(yaml_dq_escape "$redis_password")" offline_th_esc="$(yaml_dq_escape "$offline_threshold")" language_esc="$(yaml_dq_escape "$language")" local jwt_signing_key jwt_signing_key_esc jwt_signing_key="$(set +o pipefail; tr -dc 'A-Za-z0-9' /dev/null 2>&1 || true" RETURN local placeholder local -a required_placeholders=( __APP_DASH_IP__ __APP_LISTEN__ __APP_PUBLIC_URL__ __APP_LANGUAGE__ __APP_NODE_OFFLINE_THRESHOLD__ __HTTP_TRUSTED_PROXIES__ __DB_USER__ __DB_PASS__ __DB_NAME__ __DB_RETENTION_DAYS_LINE__ __REDIS_ADDR__ __REDIS_PASSWORD__ __JWT_SIGNING_KEY__ ) for placeholder in "${required_placeholders[@]}"; do if ! grep -qF "$placeholder" "$CONFIG_EXAMPLE"; then die "$(txt "写入配置失败:模板缺少 ${placeholder}(请更新 ${CONFIG_EXAMPLE})" "Failed to write config: template missing ${placeholder} (please update ${CONFIG_EXAMPLE})")" fi done local r_dash_ip r_listen r_public_url r_language r_offline_th r_http_trusted_proxies r_db_user r_db_pass r_db_name r_db_retention_line r_redis_addr r_redis_password r_jwt_signing_key r_dash_ip="$(sed_escape_repl "$dash_ip_esc")" r_listen="$(sed_escape_repl "$listen_esc")" r_public_url="$(sed_escape_repl "$public_url_esc")" r_language="$(sed_escape_repl "$language_esc")" r_offline_th="$(sed_escape_repl "$offline_th_esc")" r_http_trusted_proxies="$(sed_escape_repl "$trusted_proxies_yaml")" r_db_user="$(sed_escape_repl "$db_user_esc")" r_db_pass="$(sed_escape_repl "$db_pass_esc")" r_db_name="$(sed_escape_repl "$db_name_esc")" r_db_retention_line="$(sed_escape_repl "$retention_line_esc")" r_redis_addr="$(sed_escape_repl "$redis_addr_esc")" r_redis_password="$(sed_escape_repl "$redis_password_esc")" r_jwt_signing_key="$(sed_escape_repl "$jwt_signing_key_esc")" sed \ -e "s|__APP_DASH_IP__|${r_dash_ip}|g" \ -e "s|__APP_LISTEN__|${r_listen}|g" \ -e "s|__APP_PUBLIC_URL__|${r_public_url}|g" \ -e "s|__APP_LANGUAGE__|${r_language}|g" \ -e "s|__APP_NODE_OFFLINE_THRESHOLD__|${r_offline_th}|g" \ -e "s|__HTTP_TRUSTED_PROXIES__|${r_http_trusted_proxies}|g" \ -e "s|__DB_USER__|${r_db_user}|g" \ -e "s|__DB_PASS__|${r_db_pass}|g" \ -e "s|__DB_NAME__|${r_db_name}|g" \ -e "s|__DB_RETENTION_DAYS_LINE__|${r_db_retention_line}|g" \ -e "s|__REDIS_ADDR__|${r_redis_addr}|g" \ -e "s|__REDIS_PASSWORD__|${r_redis_password}|g" \ -e "s|__JWT_SIGNING_KEY__|${r_jwt_signing_key}|g" \ "$CONFIG_EXAMPLE" >"$tmp" for placeholder in "${required_placeholders[@]}"; do if grep -qF "$placeholder" "$tmp"; then die "$(txt "写入配置失败:模板占位符 ${placeholder} 未被替换(请确认 config.example.yaml 版本与安装脚本一致)" "Failed to write config: placeholder ${placeholder} was not replaced (template/script mismatch)")" fi done as_root install -o root -g root -m 0600 "$tmp" "$CONFIG_LOCAL" say "已写入配置:${CONFIG_LOCAL}" "Wrote config: ${CONFIG_LOCAL}" } is_ip_literal() { local host="$1" if [[ "$host" =~ ^\[[0-9a-fA-F:]+\]$ ]]; then return 0 fi [[ "$host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] } yaml_dq_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" printf "%s" "$s" } one_line() { local s="$1" s="${s//$'\r'/}" s="${s//$'\n'/}" printf "%s" "$s" } trim_spaces() { local s="$1" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf "%s" "$s" } admin_password_valid() { local LC_ALL=C local s="$1" [[ -n "$s" ]] || return 1 [[ "$s" =~ ^[!-~]+$ ]] || return 1 (( ${#s} >= 8 )) } run_db_migrations() { local config_path="$1" if [[ -x "$BIN_PATH" ]]; then as_root env DASH_HOME="$INSTALL_DIR" "$BIN_PATH" migrate -config "$config_path" return 0 fi die "$(txt "无法执行 migrate:未找到可执行文件 ${BIN_PATH}" "Cannot run migrate: executable not found: ${BIN_PATH}")" } normalize_public_url() { local in="$1" in="${in#"${in%%[![:space:]]*}"}" in="${in%"${in##*[![:space:]]}"}" if [[ -z "$in" ]]; then echo "" return 0 fi if [[ "$in" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then echo "$in" return 0 fi local hostport="${in%%/*}" local ip_probe="$hostport" if [[ "$hostport" == \[* ]]; then ip_probe="${hostport%%]*}" ip_probe="${ip_probe}]" elif [[ "$hostport" == *:*:* && "$hostport" =~ ^[0-9a-fA-F:]+$ ]]; then in="[${hostport}]${in:${#hostport}}" ip_probe="[${hostport}]" else ip_probe="${hostport%%:*}" fi local scheme="http" if ! is_ip_literal "$ip_probe"; then scheme="https" fi local out="${scheme}://${in}" if [[ "$in" != */* ]]; then out="${out}/" fi echo "$out" } warn_if_domain_public_url() { local public_url="$1" local hostport="${public_url#*://}" hostport="${hostport%%/*}" local host="$hostport" if [[ "$hostport" == \[* ]]; then host="${hostport%%]*}" host="${host}]" else host="${hostport%%:*}" fi [[ -n "$host" ]] || return 0 if ! is_ip_literal "$host"; then say "提示:public_url 看起来是域名(${host}),请确保已配置前置 Web 服务器(如 Nginx/Caddy)做反代与 HTTPS。" "NOTE: public_url looks like a domain (${host}). You likely need a reverse proxy (Nginx/Caddy) for HTTPS and forwarding." fi } require_root_public_url() { local public_url="$1" if [[ ! "$public_url" =~ ^([hH][tT][tT][pP]|[hH][tT][tT][pP][sS]):// ]]; then die "$(txt "public_url 只支持 HTTP 或 HTTPS" "public_url supports only HTTP or HTTPS")" fi local rest="${public_url#*://}" [[ -n "$rest" ]] || die "$(txt "public_url 缺少主机名" "public_url is missing a host")" [[ "$rest" != *"@"* ]] || die "$(txt "public_url 不支持用户信息" "public_url must not include user information")" [[ "$rest" != *"?"* ]] || die "$(txt "public_url 不支持查询参数" "public_url must not include a query")" [[ "$rest" != *"#"* ]] || die "$(txt "public_url 不支持片段" "public_url must not include a fragment")" local hostport="${rest%%/*}" local path_part="${rest:${#hostport}}" [[ -n "$hostport" ]] || die "$(txt "public_url 缺少主机名" "public_url is missing a host")" local host="$hostport" if [[ "$hostport" == \[* ]]; then [[ "$hostport" == *"]"* ]] || die "$(txt "public_url 的 IPv6 主机格式无效" "public_url has an invalid IPv6 host")" host="${hostport#\[}" host="${host%%]*}" else host="${hostport%%:*}" fi [[ -n "$host" ]] || die "$(txt "public_url 缺少主机名" "public_url is missing a host")" [[ -z "$path_part" || "$path_part" == "/" ]] || die "$(txt "public_url 不支持路径前缀(${path_part})。请改为根路径 URL,例如 http://127.0.0.1:8080/ 或 https://dash.example.com/" "public_url does not support path prefixes (${path_part}). Use a root URL such as http://127.0.0.1:8080/ or https://dash.example.com/")" } stage_app_files_from_cwd() { # Full installation always consumes the files beside this script. A release # here is only the local immutable directory selected by current. need_cmd mv || die "$(txt "缺少命令:mv" "Missing command: mv")" mv --version 2>/dev/null | grep -q 'GNU coreutils' || die "$(txt "安装器需要 GNU coreutils 的 mv 以保证原子切换" "The installer requires GNU coreutils mv for atomic cutover")" [[ -f "${SCRIPT_DIR}/bin/dash" ]] || die "$(txt "未找到可执行文件 ${SCRIPT_DIR}/bin/dash" "Missing executable: ${SCRIPT_DIR}/bin/dash")" [[ -d "${SCRIPT_DIR}/dist" && -d "${SCRIPT_DIR}/deploy" && -d "${SCRIPT_DIR}/configs" ]] || die "$(txt "安装包缺少 dist、deploy 或 configs 目录" "Package is missing the dist, deploy, or configs directory")" local stage stage="$(as_root mktemp -d "${INSTALL_DIR}.stage.XXXXXX")" as_root chown "$(id -u):$(id -g)" "$stage" if ! as_root cp -a \ "${SCRIPT_DIR}/bin" \ "${SCRIPT_DIR}/configs" \ "${SCRIPT_DIR}/dist" \ "${SCRIPT_DIR}/deploy" \ "${SCRIPT_DIR}/install_dash_linux.sh" \ "${SCRIPT_DIR}/update_dash_linux.sh" \ "${SCRIPT_DIR}/release.env" \ "$stage/"; then as_root rm -rf "$stage" die "$(txt "暂存安装包失败" "Failed to stage package files")" fi [[ -f "${stage}/bin/dash" ]] || { as_root rm -rf "$stage" die "$(txt "暂存后未找到 Dash 二进制" "Dash binary is missing from the staged package")" } as_root chmod 0755 "${stage}/bin/dash" if ! as_root "${stage}/bin/dash" --version >/dev/null 2>&1; then as_root rm -rf "$stage" die "$(txt "Dash 二进制无法在当前系统运行;Alpine 需要静态或 musl 兼容产物" "The Dash binary cannot run on this system; Alpine requires a static or musl-compatible artifact")" fi printf '%s\n' "$stage" } prepare_staged_release() { local stage="$1" version release version="$(as_root "${stage}/bin/dash" --version)" || return version="${version//$'\r'/}" version="${version//$'\n'/}" [[ "$version" =~ ^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$ ]] || return 1 need_cmd mv || return 1 mv --version 2>/dev/null | grep -q 'GNU coreutils' || return 1 release="${RELEASES_DIR}/${version}-$(date -u +%Y%m%dT%H%M%SZ)-$$" as_root install -d -m 0755 "$INSTALL_DIR" "$RELEASES_DIR" "${INSTALL_DIR}/configs" || return [[ ! -e "$release" ]] || return 1 as_root mv "$stage" "$release" || return if ! as_root chown -R root:root "$release" || ! as_root chmod 0755 "${release}/bin/dash" || ! as_root find "$release" -maxdepth 1 -type f -name '*.sh' -exec chmod 0755 {} +; then as_root rm -rf "$release" return 1 fi printf '%s\n' "$release" } switch_current_target() { local target="$1" link [[ ! -e "$CURRENT_LINK" || -L "$CURRENT_LINK" ]] || return 1 link="${INSTALL_DIR}/.current.$$.$RANDOM" as_root rm -f "$link" || return as_root ln -s "$target" "$link" || return as_root mv -Tf "$link" "$CURRENT_LINK" || { as_root rm -f "$link" return 1 } } switch_current_release() { local release="$1" [[ -d "$release" ]] || return 1 switch_current_target "releases/$(basename "$release")" } install_compat_alias() { local path="$1" target="$2" link link="${path}.new.$$.$RANDOM" as_root rm -f "$link" || return as_root ln -s "$target" "$link" || return if [[ -d "$path" && ! -L "$path" ]]; then if ! as_root rm -rf "$path"; then as_root rm -f "$link" return 1 fi fi as_root mv -Tf "$link" "$path" || { as_root rm -f "$link" return 1 } } install_compat_aliases() { as_root install -d -m 0755 "${INSTALL_DIR}/configs" || return # Compatibility bridge for the legacy flat layout. Remove these aliases # after the next breaking-release migration window. install_compat_alias "${INSTALL_DIR}/bin" "current/bin" || return install_compat_alias "${INSTALL_DIR}/dist" "current/dist" || return install_compat_alias "${INSTALL_DIR}/deploy" "current/deploy" || return install_compat_alias "${INSTALL_DIR}/install_dash_linux.sh" "current/install_dash_linux.sh" || return install_compat_alias "${INSTALL_DIR}/update_dash_linux.sh" "current/update_dash_linux.sh" || return install_compat_alias "${INSTALL_DIR}/release.env" "current/release.env" || return install_compat_alias "${INSTALL_DIR}/configs/config.example.yaml" "../current/configs/config.example.yaml" || return } backup_legacy_install() { local backup_dir="$1" rel as_root install -d -m 0700 "$backup_dir" || return for rel in bin configs dist deploy install_dash_linux.sh update_dash_linux.sh release.env; do [[ -e "${INSTALL_DIR}/${rel}" || -L "${INSTALL_DIR}/${rel}" ]] || continue as_root cp -a "${INSTALL_DIR}/${rel}" "$backup_dir/" || return done } restore_legacy_install() { local backup_dir="$1" [[ -d "$backup_dir" ]] || return 1 as_root install -d -m 0755 "$INSTALL_DIR" || return as_root rm -rf \ "${INSTALL_DIR}/bin" \ "${INSTALL_DIR}/configs" \ "${INSTALL_DIR}/dist" \ "${INSTALL_DIR}/deploy" \ "$CURRENT_LINK" || return as_root rm -f \ "${INSTALL_DIR}/install_dash_linux.sh" \ "${INSTALL_DIR}/update_dash_linux.sh" \ "${INSTALL_DIR}/release.env" \ "$LAYOUT_MARKER" || return as_root cp -a "${backup_dir}/." "$INSTALL_DIR/" || return tighten_sensitive_file_permissions || return } legacy_install_present() { local rel for rel in bin/dash dist deploy install_dash_linux.sh update_dash_linux.sh release.env configs/config.local.yaml configs/config.yaml configs/config.example.yaml; do [[ -e "${INSTALL_DIR}/${rel}" || -L "${INSTALL_DIR}/${rel}" ]] && return 0 done return 1 } prepare_install_recovery() { [[ ! -e "$CURRENT_LINK" || -L "$CURRENT_LINK" ]] || return 1 INSTALL_RECOVERY_ROOT="$(as_root mktemp -d "${INSTALL_DIR}.recovery.XXXXXX")" || return as_root chown "$(id -u):$(id -g)" "$INSTALL_RECOVERY_ROOT" || return INSTALL_PREVIOUS_LAYOUT="fresh" INSTALL_PREVIOUS_TARGET="" INSTALL_PREVIOUS_MARKER="0" INSTALL_PREVIOUS_CONFIG="0" INSTALL_SYSTEMD_WAS_ACTIVE="0" if [[ -L "$CURRENT_LINK" ]]; then INSTALL_PREVIOUS_LAYOUT="release" INSTALL_PREVIOUS_TARGET="$(readlink "$CURRENT_LINK")" [[ -f "$LAYOUT_MARKER" ]] && INSTALL_PREVIOUS_MARKER="1" if [[ -f "$CONFIG_LOCAL" ]]; then as_root cp -a "$CONFIG_LOCAL" "${INSTALL_RECOVERY_ROOT}/config.local.yaml" || return INSTALL_PREVIOUS_CONFIG="1" fi elif legacy_install_present; then INSTALL_PREVIOUS_LAYOUT="legacy" backup_legacy_install "${INSTALL_RECOVERY_ROOT}/legacy" || return else as_root install -d -m 0700 "${INSTALL_RECOVERY_ROOT}/legacy" || return fi if systemd_available && [[ -f "$SERVICE_FILE" ]] && as_root systemctl is-active --quiet "${APP}.service"; then INSTALL_SYSTEMD_WAS_ACTIVE="1" fi INSTALL_ROLLBACK_READY="1" } restore_release_install() { [[ -n "$INSTALL_PREVIOUS_TARGET" ]] || return 1 switch_current_target "$INSTALL_PREVIOUS_TARGET" || return install_compat_aliases || return if [[ "$INSTALL_PREVIOUS_MARKER" == "1" ]]; then as_root touch "$LAYOUT_MARKER" || return else as_root rm -f "$LAYOUT_MARKER" || return fi if [[ "$INSTALL_PREVIOUS_CONFIG" == "1" ]]; then as_root install -o root -g root -m 0600 "${INSTALL_RECOVERY_ROOT}/config.local.yaml" "$CONFIG_LOCAL" || return else as_root rm -f "$CONFIG_LOCAL" || return fi } rollback_install() { local restored="true" say_err "安装提交前失败,正在恢复原文件入口和服务。" "Installation failed before commit; restoring the previous file entrypoint and service." case "$INSTALL_PREVIOUS_LAYOUT" in release) restore_release_install || restored="false" ;; legacy|fresh) restore_legacy_install "${INSTALL_RECOVERY_ROOT}/legacy" || restored="false" ;; *) restored="false" ;; esac if [[ "$restored" == "true" && -n "$INSTALL_RELEASE" ]] && ! install_release_is_current; then as_root rm -rf "$INSTALL_RELEASE" || restored="false" [[ "$restored" != "true" ]] || INSTALL_RELEASE="" fi if [[ "$restored" == "true" && "$INSTALL_SYSTEMD_WAS_ACTIVE" == "1" ]]; then as_root systemctl start "${APP}.service" || restored="false" fi if [[ "$restored" == "true" ]]; then as_root rm -rf "$INSTALL_RECOVERY_ROOT" INSTALL_RECOVERY_ROOT="" return 0 fi return 1 } activate_release() { local release="$1" switch_current_release "$release" || return install_compat_aliases || return as_root touch "$LAYOUT_MARKER" || return } prune_inactive_releases() { [[ -L "$CURRENT_LINK" && -d "$RELEASES_DIR" ]] || return 0 local current entry entries current="$(readlink "$CURRENT_LINK")" current="${current##*/}" entries="$(mktemp)" || return if ! find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d -print0 >"$entries"; then rm -f "$entries" return 1 fi while IFS= read -r -d '' entry; do [[ "$(basename "$entry")" == "$current" ]] && continue if ! as_root rm -rf "$entry"; then rm -f "$entries" return 1 fi done <"$entries" rm -f "$entries" || return } prepare_staged_app_files() { local stage="$1" release="" [[ -d "$stage" && -x "${stage}/bin/dash" ]] || die "$(txt "暂存安装包无效" "Invalid staged package")" INSTALL_RELEASE="" if ! release="$(prepare_staged_release "$stage")"; then as_root rm -rf "$stage" die "$(txt "准备不可变 release 目录失败;线上文件和进程未改动" "Failed to prepare the immutable release directory; live files and processes were not changed")" fi INSTALL_RELEASE="$release" } install_staged_app_files() { [[ -n "$INSTALL_RELEASE" && -d "$INSTALL_RELEASE" ]] || die "$(txt "尚未准备可切换的 release" "No prepared release is available for cutover")" activate_release "$INSTALL_RELEASE" || die "$(txt "强制覆盖 Dash 文件入口失败" "Failed to replace the managed Dash file entrypoints")" [[ -x "$BIN_PATH" ]] || die "$(txt "安装后未找到可执行文件 ${BIN_PATH}" "Missing executable after install: ${BIN_PATH}")" } tighten_sensitive_file_permissions() { if [[ -f "$CONFIG_LOCAL" ]]; then as_root chown root:root "$CONFIG_LOCAL" as_root chmod 0600 "$CONFIG_LOCAL" fi if [[ -f "$NOTIFY_CONFIG_KEY" ]]; then as_root chown root:root "$NOTIFY_CONFIG_KEY" as_root chmod 0600 "$NOTIFY_CONFIG_KEY" fi if [[ -f "$SERVICE_FILE" ]]; then as_root chown root:root "$SERVICE_FILE" as_root chmod 0600 "$SERVICE_FILE" fi if [[ -f "$MANUAL_RUN_FILE" ]]; then as_root chown root:root "$MANUAL_RUN_FILE" as_root chmod 0700 "$MANUAL_RUN_FILE" fi } write_systemd_service() { local admin_password="$1" admin_password="$(one_line "$admin_password")" local pwd_escaped pwd_escaped="$(systemd_escape_env_value "$admin_password")" local pg_major pg_units="postgresql.service" pg_major="$(postgres_major_version)" if [[ "$pg_major" =~ ^[0-9]+$ ]]; then pg_units+=" postgresql-${pg_major}.service" fi local tmp tmp="$(mktemp)" cat >"$tmp" <"$tmp" </dev/null 2>&1 || true fi as_root rm -f "$SERVICE_FILE" ;; esac } main() { local stage="" select_service_manager ensure_process_control detect_os if [[ "$OS_FAMILY" != "manual" && "$SERVICE_MANAGER" != "none" ]]; then if [[ -z "${PKG_MANAGER}" ]] || ! need_cmd "${PKG_MANAGER}"; then die "$(txt "未检测到系统包管理器(当前需要 ${PKG_MANAGER_LABEL:-unknown})" "System package manager not found (expected ${PKG_MANAGER_LABEL:-unknown})")" fi fi if [[ "$SERVICE_MANAGER" != "none" ]]; then enable_time_sync fi say "1) 检测并准备数据库依赖(已有兼容安装保持不变;缺失时锁定 PostgreSQL ${POSTGRES_VERSION} / TimescaleDB ${TIMESCALEDB_VERSION};Redis 地址将在配置后校验;模式:${PKG_MANAGER_LABEL})" "1) Checking database dependencies (existing compatible installations stay unchanged; missing dependencies are pinned to PostgreSQL ${POSTGRES_VERSION} / TimescaleDB ${TIMESCALEDB_VERSION}; the configured Redis endpoint is validated next; mode: ${PKG_MANAGER_LABEL})" if [[ "$OS_FAMILY" == "manual" || "$SERVICE_MANAGER" == "none" ]]; then check_preinstalled_dependencies else ensure_postgresql16_and_password ensure_timescaledb_enabled ensure_redis_82plus fi say "2) 交互式生成配置 ${CONFIG_LOCAL}" "2) Interactive configuration: ${CONFIG_LOCAL}" local dash_ip listen_port public_url db_user db_pass db_name retention_days redis_addr redis_password offline_threshold language admin_pwd trusted_proxies_yaml while true; do while true; do dash_ip="$(prompt_string "$(txt "请输入 Dash 服务端 IP(回车查看本机IP:ip addr)" "Dash server IP (press Enter to show local IPs via: ip addr)")")" dash_ip="$(trim_spaces "$(one_line "$dash_ip")")" if [[ -n "$dash_ip" ]]; then break fi say "未输入 IP,显示本机网络信息(ip addr):" "No IP entered; showing local network info (ip addr):" if need_cmd ip; then ip addr || true else say_err "未找到 ip 命令(iproute2),无法显示本机 IP" "ip command not found (iproute2)." fi done listen_port="$(prompt_string "$(txt "请输入监听端口" "Listen port")" "8080")" listen_port="${listen_port#:}" [[ "$listen_port" =~ ^[0-9]+$ ]] || die "$(txt "监听端口必须是数字:${listen_port}" "Listen port must be numeric (got: ${listen_port})")" ((10#$listen_port >= 1 && 10#$listen_port <= 65535)) || die "$(txt "监听端口必须在 1 到 65535 之间:${listen_port}" "Listen port must be between 1 and 65535 (got: ${listen_port})")" public_url="$(prompt_string "$(txt "请输入 public_url(用于生成安装脚本/外网访问,必须是根路径 URL)" "public_url (external access URL, root URL only)")" "http://127.0.0.1:${listen_port}/")" public_url="$(normalize_public_url "$public_url")" [[ -n "$public_url" ]] || die "$(txt "public_url 不能为空" "public_url is required")" require_root_public_url "$public_url" trusted_proxies_yaml="[]" if prompt_yes_no "$(txt "是否通过本机反向代理(如同机 Nginx/Caddy/Traefik)对外暴露 Dash?IP 部署请选择否;启用后仅信任来自本机代理的转发头。" "Is Dash exposed through a local reverse proxy on the same host (for example Nginx/Caddy/Traefik)? Choose no for a direct IP deployment; enabling this trusts forwarded headers only from that local proxy.")" "N"; then trusted_proxies_yaml='["127.0.0.1/32", "::1/128"]' fi db_user="$(prompt_string "$(txt "请输入数据库账号(database.user)" "database.user")" "monitor")" db_pass="$(prompt_secret_confirm "$(txt "请输入数据库密码(database.password)" "database.password")")" db_name="$(prompt_string "$(txt "请输入数据库名(database.name)" "database.name")" "monitor")" retention_days="$(prompt_retention_days 1)" while true; do redis_addr="$(prompt_string "$(txt "请输入 Redis 地址(redis.addr)" "redis.addr")" "127.0.0.1:6379")" redis_addr="$(trim_spaces "$(one_line "$redis_addr")")" redis_password="$(prompt_secret_optional "$(txt "请输入 Redis 密码(redis.password;无密码直接回车)" "redis.password (press Enter when authentication is disabled)")")" if check_redis_endpoint "$redis_addr" "$redis_password"; then break fi say_err "无法使用该 Redis 地址,请确认服务可达、允许 PING/INFO server,且版本不低于 6.2.0。" "Cannot use this Redis endpoint. Ensure it is reachable, permits PING and INFO server, and runs Redis 6.2.0 or newer." done offline_threshold="$(prompt_string "$(txt "请输入离线判定阈值(app.node_offline_threshold,例如:17s/30s/1m)" "app.node_offline_threshold (e.g. 17s/30s/1m)")" "${DEFAULT_NODE_OFFLINE_THRESHOLD}")" language="$(prompt_language)" echo "" say "2.1) 配置摘要(写入前确认)" "2.1) Configuration summary (confirm before writing)" print_config_summary "$dash_ip" "$listen_port" "$public_url" "$db_user" "$db_pass" "$db_name" "$retention_days" "$redis_addr" "$redis_password" "$offline_threshold" "$language" "$trusted_proxies_yaml" echo "" if prompt_yes_no "$(txt "确认写入以上配置并继续?" "Write the configuration above and continue?")" "Y"; then break fi say "已取消本次配置,请重新填写。" "Configuration was not written; please enter it again." echo "" done say "3) 初始化数据库用户和数据库" "3) Initializing database user and database" create_db_and_user "$db_user" "$db_pass" "$db_name" say "4) 配置运行方式:${SERVICE_MANAGER}" "4) Configuring runtime mode: ${SERVICE_MANAGER}" while true; do admin_pwd="$(prompt_secret_confirm "$(txt "请设置 Dash 管理员登录密码(环境变量 monitor_dash_pwd)" "Dash admin password (env monitor_dash_pwd)")")" admin_pwd="$(trim_spaces "$admin_pwd")" if ! admin_password_valid "$admin_pwd"; then say_err "Dash 管理员密码至少需要 8 个字符,仅允许大小写英文、数字和常见符号;会自动忽略输入前后的空格;中间不能包含空格或其他空白字符。" "Dash admin password must contain at least 8 characters using only ASCII letters, digits, and common symbols; leading and trailing spaces are ignored; inner whitespace is not allowed." continue fi break done say "5) 安装 Dash 文件到 ${INSTALL_DIR}" "5) Installing Dash files into ${INSTALL_DIR}" stage="$(stage_app_files_from_cwd)" prepare_staged_app_files "$stage" prepare_install_recovery || die "$(txt "准备旧安装恢复现场失败;线上文件和进程未改动" "Failed to prepare recovery for the previous installation; live files and processes were not changed")" stop_installed_services install_staged_app_files render_config_local "$dash_ip" "$listen_port" "$public_url" "$db_user" "$db_pass" "$db_name" "$retention_days" "$redis_addr" "$redis_password" "$offline_threshold" "$language" "$trusted_proxies_yaml" INSTALL_MIGRATION_STARTED="1" run_db_migrations "$CONFIG_LOCAL" grant_db_privileges "$db_user" "$db_name" cleanup_other_service_managers service_install "$admin_pwd" if [[ "$SERVICE_MANAGER" != "none" ]]; then service_enable service_start fi warn_if_domain_public_url "$public_url" service_status } parse_args "$@" choose_install_lang trap finish_dash_install EXIT main