Linux x86_64 offline release; configurable API and bundled PostgreSQL ports.

- packaging/linux: netx-<ver>-linux-x86_64.tar.gz with bundled Python 3.12,
  PostgreSQL 16 and web/dist (glibc >= 2.17). install.sh (install/update/
  reinstall), netxctl (start/stop/status/logs, set-port, set-bind,
  set-pg-port, reconfigure-db, check-update/update/auto-update, backup,
  uninstall), systemd units, nohup fallback without systemd. Privilege
  drop, detaching and tar go through the bundled Python, so bare images
  without su/runuser/setsid/tar work.
- Updates: same GitHub/Forgejo/manifest sources as Windows, sha256
  verified, pre-update pg_dump, automatic rollback when the new version
  does not come up.
- Non-loopback bind: generated bootstrap admin password and
  NETX_UME_VERIFY_TLS=true, as security_bootstrap requires.
- Build in manylinux2014 + check_bundle.sh (GLIBC <= 2.17, no missing
  libs); release-linux.yml builds on v* tags and smoke-tests offline on
  CentOS 7, Rocky 9, Ubuntu 18.04/24.04, Debian 12, openSUSE Leap 15 and
  openEuler 22.03 before attaching to the release.
- Windows: Setup DB page gets a bundled PG port field and
  /BundledPgPort=; set_pg_port.ps1 + tray "Built-in database port";
  reconfigure keeps the configured port; postgresql.conf is written
  without a BOM (PS 5.1 Set-Content -Encoding utf8 broke postgres).
- Forgejo/GitHub publish scripts include the Linux package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-14 10:09:07 +08:00
parent c141a37263
commit 846aa3e680
26 changed files with 3120 additions and 9 deletions

614
packaging/linux/install.sh Executable file
View file

@ -0,0 +1,614 @@
#!/usr/bin/env bash
# NetX Linux installer / updater. Ships at the root of netx-<ver>-linux-x86_64.tar.gz.
#
# sudo ./install.sh # interactive install (or update when already installed)
# sudo ./install.sh --yes --db bundled --port 8890 --pg-port 15432
# sudo ./install.sh --mode update --yes # what `netxctl update` runs
#
# Needs only bash, coreutils and useradd (tar to unpack it): Python, PostgreSQL
# and the web UI are inside the package.
set -euo pipefail
PKG="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=lib.sh
. "${PKG}/packaging/linux/lib.sh"
NETX_PY="${PKG}/python/bin/python3"
NETX_ADMIN_PY="${PKG}/packaging/linux/netx_admin.py"
PREFIX="/opt/netx"
DATA="/var/lib/netx"
MODE=""
DB_MODE=""
API_PORT=""
BIND=""
PG_PORT=""
PG_PASSWORD=""
DB_URL=""
DB_HOST="" DB_PORT_EXT="" DB_USER="" DB_PASS="" DB_NAME=""
CRED_KEY_FILE=""
ADMIN_PASSWORD=""
ADMIN_PW_GENERATED=0
NO_SERVICE=0
AUTO_UPDATE=0
OPEN_FIREWALL=0
NO_BACKUP=0
YES=0
usage() {
cat <<EOF
$(t "用法" "Usage"): sudo ./install.sh [options]
--mode install|update|reinstall $(t "已安装时默认 update(保留数据与配置)" "default: update when already installed (keeps data/config)")
--prefix DIR $(t "程序目录" "program dir") (/opt/netx)
--data DIR $(t "数据目录" "data dir") (/var/lib/netx)
--port N $(t "Web/API 端口" "Web/API port") (8890)
--bind HOST $(t "监听地址" "listen address") (0.0.0.0)
--db bundled|external
--pg-port N $(t "内置 PostgreSQL 端口" "bundled PostgreSQL port") (15432)
--pg-password PW $(t "内置库密码(默认自动生成)" "bundled DB password (default: generated)")
--db-url URL postgresql+psycopg://USER:PASS@HOST:5432/DB
--db-host/--db-port/--db-user/--db-password/--db-name $(t "外置库参数(替代 --db-url)" "external DB parts")
--admin-password PW $(t "初始管理员 admin 的密码(默认随机生成,安装结束时显示)" "initial password for admin (default: generated, shown at the end)")
--credential-key-file F $(t "沿用其他安装的 NE 凭据加密密钥" "reuse NETX_CREDENTIAL_SECRET_KEY from another install")
--no-service $(t "不注册 systemd 服务" "do not install the systemd service")
--auto-update $(t "开启每日自动更新" "enable daily automatic updates")
--open-firewall $(t "自动放行防火墙端口(firewalld/ufw)" "open the port in firewalld/ufw")
--no-backup $(t "更新前不备份数据库" "skip the pre-update database dump")
-y, --yes $(t "非交互,使用默认值" "non-interactive, accept defaults")
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--prefix) PREFIX="${2:-}"; shift 2 ;;
--data) DATA="${2:-}"; shift 2 ;;
--port) API_PORT="${2:-}"; shift 2 ;;
--bind) BIND="${2:-}"; shift 2 ;;
--db) DB_MODE="${2:-}"; shift 2 ;;
--pg-port) PG_PORT="${2:-}"; shift 2 ;;
--pg-password) PG_PASSWORD="${2:-}"; shift 2 ;;
--db-url) DB_URL="${2:-}"; shift 2 ;;
--db-host) DB_HOST="${2:-}"; shift 2 ;;
--db-port) DB_PORT_EXT="${2:-}"; shift 2 ;;
--db-user) DB_USER="${2:-}"; shift 2 ;;
--db-password) DB_PASS="${2:-}"; shift 2 ;;
--db-name) DB_NAME="${2:-}"; shift 2 ;;
--credential-key-file) CRED_KEY_FILE="${2:-}"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="${2:-}"; shift 2 ;;
--no-service) NO_SERVICE=1; shift ;;
--auto-update) AUTO_UPDATE=1; shift ;;
--open-firewall) OPEN_FIREWALL=1; shift ;;
--no-backup) NO_BACKUP=1; shift ;;
-y|--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) err "unknown option: $1"; usage; exit 2 ;;
esac
done
INTERACTIVE=1
if [[ "${YES}" == "1" || ! -t 0 ]]; then INTERACTIVE=0; fi
PREFIX="${PREFIX%/}"
DATA="${DATA%/}"
ENV_FILE="${DATA}/.env"
NETX_LOG_DIR="${DATA}/logs"
PGDATA_DIR="${DATA}/pgdata"
ask() { # ask "prompt" default -> echo answer
local a
read -r -p "$1 [$2]: " a || true
echo "${a:-$2}"
}
# ------------------------------------------------------------------ preflight
preflight() {
is_root || die "$(t "请用 root 运行:sudo ./install.sh" "run as root: sudo ./install.sh")"
local arch
arch="$(uname -m)"
[[ "${arch}" == "x86_64" || "${arch}" == "amd64" ]] || die "$(t "本安装包仅支持 x86_64,当前" "this package is x86_64 only; this machine is") ${arch}"
local glibc
glibc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}')" || true
[[ -n "${glibc}" ]] || die "$(t "未检测到 glibc(不支持 Alpine/musl)" "glibc not found (Alpine/musl is not supported)")"
if [[ "$(printf '%s\n2.17\n' "${glibc}" | sort -V | head -n1)" != "2.17" ]]; then
die "$(t "glibc 版本过低" "glibc too old"): ${glibc} (>= 2.17)"
fi
"${NETX_PY}" -c "import sys" 2>/dev/null || die "$(t "内置 Python 无法运行,安装包可能损坏" "bundled Python does not run; package may be corrupt")"
[[ -f "${PKG}/version.json" ]] || die "version.json missing in ${PKG}"
local parent avail
parent="$(dirname "${PREFIX}")"
mkdir -p "${parent}"
avail="$(df -Pk "${parent}" | awk 'NR==2 {print $4}')"
if [[ -n "${avail}" && "${avail}" -lt 1048576 ]]; then
die "$(t "磁盘空间不足(至少 1GB)" "not enough disk space (need 1GB)"): ${parent}"
fi
}
ensure_user() {
if id "${NETX_USER}" >/dev/null 2>&1; then return 0; fi
info "$(t "创建系统用户" "creating system user") ${NETX_USER}"
local nologin=/sbin/nologin
[[ -x "${nologin}" ]] || nologin=/usr/sbin/nologin
[[ -x "${nologin}" ]] || nologin=/bin/false
if command -v useradd >/dev/null 2>&1; then
useradd -r -U -d "${DATA}" -M -s "${nologin}" "${NETX_USER}" 2>/dev/null ||
useradd -r -d "${DATA}" -M -s "${nologin}" "${NETX_USER}"
elif command -v adduser >/dev/null 2>&1; then
adduser -S -D -H -h "${DATA}" -s "${nologin}" "${NETX_USER}" 2>/dev/null ||
adduser --system --group --no-create-home --home "${DATA}" "${NETX_USER}"
else
die "$(t "缺少 useradd/adduser,无法创建用户" "useradd/adduser not found")"
fi
# Some useradd builds do not create a same-named group with -r.
getent group "${NETX_USER}" >/dev/null 2>&1 || groupadd -r "${NETX_USER}" 2>/dev/null || true
}
# ------------------------------------------------------------------ prompts
pick_port() { # pick_port <label> <value> <default> <host> <allow_current>
local label="$1" val="$2" def="$3" host="$4" allow="$5" p
while :; do
if [[ -z "${val}" ]]; then
if [[ "${INTERACTIVE}" == "1" ]]; then val="$(ask "${label}" "${def}")"; else val="${def}"; fi
fi
if ! p="$(netx_admin valid-port "${val}" 2>&1)"; then
[[ "${INTERACTIVE}" == "1" ]] || die "${p}"
err "${p}"; val=""; continue
fi
if [[ "${p}" == "${allow}" ]] || netx_admin port-free "${p}" --host "${host}"; then
echo "${p}"; return 0
fi
local msg
msg="$(t "端口 ${p} 已被占用" "port ${p} is in use")"
[[ "${INTERACTIVE}" == "1" ]] || die "port_in_use: ${p} — ${msg}"
err "${msg}"
val=""
done
}
configure() {
local old_port old_bind old_mode old_pg
old_port="$(env_get "${ENV_FILE}" NETX_PORT)"
old_bind="$(env_get "${ENV_FILE}" NETX_HOST)"
old_mode="$(env_get "${ENV_FILE}" NETX_DB_MODE)"
old_pg="$(env_get "${ENV_FILE}" NETX_BUNDLED_PG_PORT)"
echo
info "$(t "配置 NetX" "Configure NetX")"
if [[ -z "${BIND}" ]]; then
if [[ "${INTERACTIVE}" == "1" ]]; then
BIND="$(ask "$(t "监听地址(0.0.0.0=所有网卡,127.0.0.1=仅本机)" "Listen address (0.0.0.0 = all, 127.0.0.1 = local only)")" "${old_bind:-0.0.0.0}")"
else
BIND="${old_bind:-0.0.0.0}"
fi
fi
API_PORT="$(pick_port "$(t "Web/API 端口" "Web/API port")" "${API_PORT}" "${old_port:-8890}" "${BIND}" "${old_port}")"
if [[ -z "${DB_MODE}" ]]; then
if [[ -n "${DB_URL}${DB_HOST}" ]]; then
DB_MODE="external"
elif [[ "${INTERACTIVE}" == "1" ]]; then
echo "$(t "数据库:" "Database:")"
echo " 1) $(t "内置 PostgreSQL(离线可用,推荐)" "bundled PostgreSQL (offline, recommended)")"
echo " 2) $(t "外置 PostgreSQL(已有的库)" "external PostgreSQL (existing server)")"
local def=1
[[ "${old_mode}" == "external" ]] && def=2
[[ "$(ask "$(t "请选择" "Choose")" "${def}")" == "2" ]] && DB_MODE="external" || DB_MODE="bundled"
else
DB_MODE="${old_mode:-bundled}"
fi
fi
case "${DB_MODE}" in bundled|external) ;; *) die "invalid --db: ${DB_MODE}" ;; esac
if [[ "${DB_MODE}" == "bundled" ]]; then
PG_PORT="$(pick_port "$(t "内置 PostgreSQL 端口(仅本机 127.0.0.1)" "Bundled PostgreSQL port (127.0.0.1 only)")" "${PG_PORT}" "${old_pg:-15432}" 127.0.0.1 "${old_pg}")"
else
configure_external
fi
CRED_KEY=""
if [[ -n "${CRED_KEY_FILE}" ]]; then
CRED_KEY="$(tr -d ' \r\n' <"${CRED_KEY_FILE}")"
elif [[ -z "$(env_get "${ENV_FILE}" NETX_CREDENTIAL_SECRET_KEY)" && "${INTERACTIVE}" == "1" ]]; then
echo "$(t "可选:粘贴其他 NetX 安装的 NETX_CREDENTIAL_SECRET_KEY,以沿用已加密的网元密码;回车跳过(自动生成)" \
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from another install to reuse encrypted NE passwords; Enter = generate")"
read -r -p "NETX_CREDENTIAL_SECRET_KEY: " CRED_KEY || true
fi
}
configure_external() {
local existing
existing="$(env_get "${ENV_FILE}" NETX_DATABASE_URL)"
while :; do
if [[ -z "${DB_URL}" && -n "${DB_HOST}" ]]; then
DB_URL="$(netx_admin build-url -- "${DB_USER:-netx}" "${DB_PASS}" "${DB_HOST}" "${DB_PORT_EXT:-5432}" "${DB_NAME:-netx}")" || exit 1
fi
if [[ -z "${DB_URL}" ]]; then
if [[ "${INTERACTIVE}" != "1" ]]; then
[[ -n "${existing}" && "$(env_get "${ENV_FILE}" NETX_DB_MODE)" == "external" ]] && DB_URL="${existing}"
[[ -n "${DB_URL}" ]] || die "external_url_required: --db-url or --db-host/--db-user/--db-password/--db-name"
else
echo "$(t "外置 PostgreSQL(库和用户需事先建好)" "External PostgreSQL (database and role must exist)")"
local h po u pw n
h="$(ask "$(t "主机" "Host")" "127.0.0.1")"
po="$(ask "$(t "端口" "Port")" "5432")"
u="$(ask "$(t "用户" "User")" "netx")"
read -r -s -p "$(t "密码" "Password"): " pw || true
echo
n="$(ask "$(t "数据库" "Database")" "netx")"
DB_URL="$(netx_admin build-url -- "${u}" "${pw}" "${h}" "${po}" "${n}")" || { DB_URL=""; continue; }
fi
fi
info "$(t "测试外置数据库连接" "probing external PostgreSQL")"
if netx_admin probe-db "${DB_URL}"; then
ok "$(t "外置数据库连接成功" "external PostgreSQL OK")"
return 0
fi
[[ "${INTERACTIVE}" == "1" ]] || die "external_db_probe_failed"
DB_URL="" DB_HOST=""
done
}
# ------------------------------------------------------------------ program files
# Set by load_pkg_meta (after preflight proved the bundled Python runs).
VERSION=""
PKG_PG_MAJOR=""
REL=""
load_pkg_meta() {
VERSION="$(netx_admin json-get "${PKG}/version.json" version)" || die "bad version.json"
PKG_PG_MAJOR="$(netx_admin json-get "${PKG}/version.json" pg_major 2>/dev/null || echo 16)"
REL="${PREFIX}/releases/${VERSION}"
}
deploy_release() {
info "$(t "安装程序文件到" "installing program files to") ${REL}"
mkdir -p "${PREFIX}/releases"
if [[ "$(readlink -f "${PKG}")" != "$(readlink -f "${REL}" 2>/dev/null || echo "${REL}")" ]]; then
rm -rf "${REL}.new"
mkdir -p "${REL}.new"
cp -a "${PKG}/." "${REL}.new/"
rm -rf "${REL}"
mv "${REL}.new" "${REL}"
fi
chown -R root:root "${REL}"
chmod -R u+rwX,go+rX,go-w "${REL}"
info "$(t "预编译 Python 字节码" "precompiling Python bytecode")"
"${REL}/python/bin/python3" -m compileall -q -j 0 "${REL}/netx_api" "${REL}/alembic" \
"${REL}/python/lib/python3.12" >/dev/null 2>&1 || true
printf 'NETX_DATA=%s\n' "${DATA}" >"${PREFIX}/netx.conf"
}
switch_current() { # switch_current <version>
ln -sfn "releases/$1" "${PREFIX}/current.tmp"
mv -Tf "${PREFIX}/current.tmp" "${PREFIX}/current"
}
install_integration() {
# /usr/bin: sudo's secure_path on RHEL-family systems omits /usr/local/bin.
ln -sfn "${PREFIX}/current/packaging/linux/netxctl" /usr/bin/netxctl
if [[ -d /etc/logrotate.d ]]; then
sed -e "s#@DATA@#${DATA}#g" -e "s#@USER@#${NETX_USER}#g" \
"${REL}/packaging/linux/logrotate.netx" >/etc/logrotate.d/netx
chmod 0644 /etc/logrotate.d/netx
fi
if [[ "${NO_SERVICE}" != "1" ]] && have_systemd; then
sed -e "s#@PREFIX@#${PREFIX}#g" -e "s#@USER@#${NETX_USER}#g" \
"${REL}/packaging/linux/systemd/netx.service.in" >/etc/systemd/system/netx.service
if [[ -f /etc/systemd/system/netx-update.service ]]; then
sed -e "s#@PREFIX@#${PREFIX}#g" "${REL}/packaging/linux/systemd/netx-update.service.in" \
>/etc/systemd/system/netx-update.service
fi
systemctl daemon-reload
systemctl enable netx.service >/dev/null 2>&1
fi
if command -v restorecon >/dev/null 2>&1 && command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
restorecon -R "${PREFIX}" "${DATA}" >/dev/null 2>&1 || true
fi
}
prune_releases() { # keep current + $1
local keep1 keep2 d
keep1="$(basename "$(readlink -f "${PREFIX}/current")")"
keep2="${1:-}"
for d in "${PREFIX}"/releases/*; do
[[ -d "${d}" ]] || continue
case "$(basename "${d}")" in
"${keep1}"|"${keep2}") ;;
*) rm -rf "${d}" ;;
esac
done
}
# ------------------------------------------------------------------ data / db
write_env() {
local pairs=()
pairs+=("NETX_DB_MODE=${DB_MODE}" "NETX_HOST=${BIND}" "NETX_PORT=${API_PORT}")
pairs+=("NETX_UI_DIST_DIR=${PREFIX}/current/web/dist" "NETX_RUN_INLINE_SCHEDULERS=false")
local kv
while IFS= read -r kv; do pairs+=("${kv}"); done < <(data_env_pairs "${DATA}")
if [[ "${DB_MODE}" == "bundled" ]]; then
local url
url="$(netx_admin build-url -- netx "${PG_PASSWORD}" 127.0.0.1 "${PG_PORT}" netx)" || die "build_db_url_failed"
pairs+=("NETX_BUNDLED_PG_PORT=${PG_PORT}" "NETX_DATABASE_URL=${url}")
else
pairs+=("NETX_DATABASE_URL=${DB_URL}")
fi
# NetX refuses a non-loopback bind while the bootstrap admin password is the lab
# default (admin123) or UME TLS verification is off (security_bootstrap.py).
if [[ -n "${ADMIN_PASSWORD}" ]]; then
pairs+=("NETX_BOOTSTRAP_ADMIN_PASSWORD=${ADMIN_PASSWORD}")
elif [[ -z "$(env_get "${ENV_FILE}" NETX_BOOTSTRAP_ADMIN_PASSWORD)" ]]; then
ADMIN_PASSWORD="$(netx_admin gen-secret password)"
ADMIN_PW_GENERATED=1
pairs+=("NETX_BOOTSTRAP_ADMIN_PASSWORD=${ADMIN_PASSWORD}")
fi
if ! is_loopback "${BIND}" && [[ -z "$(env_get "${ENV_FILE}" NETX_UME_VERIFY_TLS)" ]]; then
pairs+=("NETX_UME_VERIFY_TLS=true")
fi
if [[ -n "${CRED_KEY}" ]]; then
pairs+=("NETX_CREDENTIAL_SECRET_KEY=${CRED_KEY}")
elif [[ -z "$(env_get "${ENV_FILE}" NETX_CREDENTIAL_SECRET_KEY)" ]]; then
pairs+=("NETX_CREDENTIAL_SECRET_KEY=$(netx_admin gen-secret fernet)")
fi
touch "${ENV_FILE}"
chmod 0600 "${ENV_FILE}"
netx_admin env-set "${ENV_FILE}" "${pairs[@]}"
chown "${NETX_USER}:${NETX_USER}" "${ENV_FILE}"
chmod 0600 "${ENV_FILE}"
}
setup_bundled_pg() {
PGBIN="${REL}/pgsql/bin"
local major
major="$(pg_data_major || true)"
if [[ -n "${major}" && "${major}" != "${PKG_PG_MAJOR}" ]]; then
die "pg_major_mismatch: ${PGDATA_DIR} is PostgreSQL ${major}, package ships ${PKG_PG_MAJOR}"
fi
if [[ -z "${PG_PASSWORD}" ]]; then
local old_url
old_url="$(env_get "${ENV_FILE}" NETX_DATABASE_URL)"
if [[ -n "${major}" ]]; then
# Existing cluster: keep its password (it was set at first install).
[[ -n "${old_url}" ]] && PG_PASSWORD="$(netx_admin url-part "${old_url}" password 2>/dev/null || true)"
[[ -n "${PG_PASSWORD}" ]] || die "$(t "已有内置数据库但找不到原密码,请用 --pg-password 指定" "existing bundled cluster but its password is unknown; pass --pg-password")"
else
PG_PASSWORD="$(netx_admin gen-secret password)"
fi
fi
# Record the password before initdb so re-running a failed install can reuse the cluster.
local early_url
early_url="$(netx_admin build-url -- netx "${PG_PASSWORD}" 127.0.0.1 "${PG_PORT}" netx)" || die "build_db_url_failed"
touch "${ENV_FILE}"
netx_admin env-set "${ENV_FILE}" "NETX_DATABASE_URL=${early_url}"
chown "${NETX_USER}:${NETX_USER}" "${ENV_FILE}"
chmod 0600 "${ENV_FILE}"
if [[ -z "${major}" ]]; then
info "$(t "初始化内置 PostgreSQL" "initialising bundled PostgreSQL") (${PGDATA_DIR})"
rm -rf "${PGDATA_DIR:?}"/* "${PGDATA_DIR:?}"/.[!.]* 2>/dev/null || true
local pw="${DATA}/.pginit.pw"
(umask 077 && printf '%s' "${PG_PASSWORD}" >"${pw}")
chown "${NETX_USER}:${NETX_USER}" "${pw}"
as_netx env LC_ALL=C LANG=C "${PGBIN}/initdb" -D "${PGDATA_DIR}" -U postgres --auth=scram-sha-256 \
--pwfile="${pw}" -E UTF8 --locale=C >"${NETX_LOG_DIR}/initdb.log" 2>&1 ||
{ rm -f "${pw}"; tail -n 20 "${NETX_LOG_DIR}/initdb.log" >&2; die "initdb_failed"; }
rm -f "${pw}"
fi
netx_admin pg-conf "${PGDATA_DIR}/postgresql.conf" --set "port=${PG_PORT}" \
--set "listen_addresses='127.0.0.1'" --set "unix_socket_directories=''" \
--set "logging_collector=off"
chown "${NETX_USER}:${NETX_USER}" "${PGDATA_DIR}/postgresql.conf"
local started=0
if ! pg_is_running; then
pg_start || { tail -n 20 "${NETX_LOG_DIR}/pg.log" >&2; die "pg_start_failed"; }
started=1
fi
local sqlpw="${PG_PASSWORD//\'/\'\'}" out
psql_su() {
as_netx env PGPASSWORD="${PG_PASSWORD}" LC_ALL=C "${PGBIN}/psql" -h 127.0.0.1 -p "${PG_PORT}" -U postgres \
-d postgres -v ON_ERROR_STOP=1 -tAc "$1"
}
if ! out="$(psql_su "SELECT 1 FROM pg_roles WHERE rolname='netx'" 2>&1)"; then
[[ "${started}" == "1" ]] && pg_stop
die "bundled_pg_login_failed: ${out}"
fi
if [[ "${out}" == "1" ]]; then
psql_su "ALTER ROLE netx WITH LOGIN PASSWORD '${sqlpw}'" >/dev/null
else
psql_su "CREATE ROLE netx LOGIN PASSWORD '${sqlpw}'" >/dev/null
fi
if [[ "$(psql_su "SELECT 1 FROM pg_database WHERE datname='netx'")" != "1" ]]; then
psql_su "CREATE DATABASE netx OWNER netx" >/dev/null
fi
[[ "${started}" == "1" ]] && pg_stop
ok "$(t "内置 PostgreSQL 就绪" "bundled PostgreSQL ready"): 127.0.0.1:${PG_PORT}"
}
backup_before_update() { # $1 = new version
[[ "${NO_BACKUP}" == "1" ]] && return 0
[[ "$(env_get "${ENV_FILE}" NETX_DB_MODE)" == "bundled" ]] || return 0
[[ -n "$(pg_data_major || true)" ]] || return 0
# Installed (root-owned, world-readable) copy: the unpacked package may sit in a
# directory the netx user cannot enter (e.g. /root).
PGBIN="${REL}/pgsql/bin"
local url port pw out started=0
url="$(env_get "${ENV_FILE}" NETX_DATABASE_URL)"
port="$(netx_admin url-part "${url}" port)"
pw="$(netx_admin url-part "${url}" password)"
out="${DATA}/backups/pre-$1-$(date +%Y%m%d-%H%M%S).dump"
if ! pg_is_running; then
pg_start || die "pg_start_failed (backup)"
started=1
fi
info "$(t "更新前备份数据库" "backing up database before update") → ${out}"
if ! as_netx env PGPASSWORD="${pw}" LC_ALL=C "${PGBIN}/pg_dump" -Fc -h 127.0.0.1 -p "${port}" -U netx -d netx -f "${out}"; then
[[ "${started}" == "1" ]] && pg_stop
die "$(t "备份失败,已中止更新(可加 --no-backup 跳过)" "backup failed; update aborted (use --no-backup to skip)")"
fi
[[ "${started}" == "1" ]] && pg_stop
# Keep the 3 newest pre-update dumps.
local old
ls -1t "${DATA}"/backups/pre-*.dump 2>/dev/null | tail -n +4 | while IFS= read -r old; do rm -f "${old}"; done
}
health_url() {
echo "http://$(client_host "$(env_get "${ENV_FILE}" NETX_HOST)"):$(env_get "${ENV_FILE}" NETX_PORT)/health"
}
start_and_wait() {
svc_start "${PREFIX}" "${DATA}"
info "$(t "等待 NetX 就绪(首次启动会建表,可能需要 1-2 分钟)" "waiting for NetX (first start creates tables; may take 1-2 min)")"
netx_admin wait-health "$(health_url)" --timeout 180
}
open_firewall() {
local p="$1"
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
if [[ "${OPEN_FIREWALL}" == "1" ]]; then
firewall-cmd --permanent --add-port="${p}/tcp" >/dev/null && firewall-cmd --reload >/dev/null &&
ok "firewalld: ${p}/tcp $(t "已放行" "opened")"
else
echo " $(t "防火墙放行" "open firewall"): firewall-cmd --permanent --add-port=${p}/tcp && firewall-cmd --reload"
fi
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
if [[ "${OPEN_FIREWALL}" == "1" ]]; then
ufw allow "${p}/tcp" >/dev/null && ok "ufw: ${p}/tcp $(t "已放行" "opened")"
else
echo " $(t "防火墙放行" "open firewall"): ufw allow ${p}/tcp"
fi
fi
}
summary() {
local port bind host
port="$(env_get "${ENV_FILE}" NETX_PORT)"
bind="$(env_get "${ENV_FILE}" NETX_HOST)"
host="$(primary_ip)"
[[ "${bind}" == "127.0.0.1" ]] && host="127.0.0.1"
echo
ok "NetX ${VERSION} $(t "已就绪" "is up")"
echo " $(t "访问地址" "URL") : http://${host}:${port}/"
if [[ "${MODE}" == "install" && -n "${ADMIN_PASSWORD}" ]]; then
echo " $(t "初始账号" "first login"): admin / ${ADMIN_PASSWORD} ($(t "首次登录后必须修改密码" "you must change it at first login"))"
[[ "${ADMIN_PW_GENERATED}" == "1" ]] && echo " $(t "(随机生成,只显示这一次;也保存在" "(generated; shown once, also kept in") ${ENV_FILE} NETX_BOOTSTRAP_ADMIN_PASSWORD)"
fi
echo " $(t "程序目录" "program") : ${PREFIX}/current -> releases/${VERSION}"
echo " $(t "数据目录" "data") : ${DATA}"
echo " $(t "管理命令" "manage") : sudo netxctl status | stop | start | set-port | set-pg-port | update"
if svc_unit_installed; then
echo " systemd : netx.service ($(t "开机自启" "starts at boot"))"
else
echo " $(t "未注册 systemd 服务,重启后需执行 netxctl start" "no systemd service: run netxctl start after reboot")"
fi
[[ "${bind}" != "127.0.0.1" ]] && open_firewall "${port}"
return 0
}
# ------------------------------------------------------------------ flows
do_install() { # install or reinstall
ensure_user
mkdir -p "${DATA}"
ensure_data_dirs "${DATA}"
if [[ -x "${PREFIX}/current/packaging/linux/netxctl" ]]; then
svc_stop "${PREFIX}" "${DATA}"
fi
configure
deploy_release
if [[ "${DB_MODE}" == "bundled" ]]; then setup_bundled_pg; fi
write_env
ensure_data_dirs "${DATA}"
switch_current "${VERSION}"
install_integration
if [[ "${AUTO_UPDATE}" == "1" ]]; then "${PREFIX}/current/packaging/linux/netxctl" auto-update on || true; fi
if ! start_and_wait; then
err "$(t "NetX 启动失败,日志" "NetX failed to start; logs"): ${DATA}/logs/"
tail -n 30 "${DATA}/logs/api.log" >&2 2>/dev/null || true
exit 1
fi
prune_releases ""
summary
}
do_update() {
[[ -f "${ENV_FILE}" ]] || die "$(t "未找到已有安装(${ENV_FILE}),请用 --mode install" "no existing install (${ENV_FILE}); use --mode install")"
ensure_user
ensure_data_dirs "${DATA}"
local old_ver=""
if [[ -f "${PREFIX}/current/version.json" ]]; then
old_ver="$(netx_admin json-get "${PREFIX}/current/version.json" version || true)"
fi
if [[ "$(env_get "${ENV_FILE}" NETX_DB_MODE)" == "bundled" ]]; then
local major
major="$(pg_data_major || true)"
if [[ -n "${major}" && "${major}" != "${PKG_PG_MAJOR}" ]]; then
die "pg_major_mismatch: data=${major} package=${PKG_PG_MAJOR}"
fi
fi
info "$(t "更新" "Updating") NetX ${old_ver:-?} → ${VERSION}"
if [[ -n "${old_ver}" ]]; then svc_stop "${PREFIX}" "${DATA}"; fi
if [[ "${old_ver}" == "${VERSION}" ]]; then
# Reinstalling the same version: keep a copy so a failed start can roll back.
rm -rf "${PREFIX}/releases/${VERSION}.prev"
cp -a "${REL}" "${PREFIX}/releases/${VERSION}.prev" 2>/dev/null || true
fi
deploy_release
backup_before_update "${VERSION}"
# Re-assert keys newer releases rely on; never touches ports or DB settings.
local pairs=("NETX_UI_DIST_DIR=${PREFIX}/current/web/dist" "NETX_RUN_INLINE_SCHEDULERS=false") kv
while IFS= read -r kv; do pairs+=("${kv}"); done < <(data_env_pairs "${DATA}")
netx_admin env-set "${ENV_FILE}" "${pairs[@]}"
chown "${NETX_USER}:${NETX_USER}" "${ENV_FILE}"
switch_current "${VERSION}"
install_integration
if [[ "${AUTO_UPDATE}" == "1" ]]; then "${PREFIX}/current/packaging/linux/netxctl" auto-update on || true; fi
if start_and_wait; then
rm -rf "${PREFIX}/releases/${VERSION}.prev"
prune_releases "${old_ver}"
summary
return 0
fi
err "$(t "新版本启动失败" "new version failed to start")"
tail -n 30 "${DATA}/logs/api.log" >&2 2>/dev/null || true
svc_stop "${PREFIX}" "${DATA}"
local back=""
if [[ -n "${old_ver}" && "${old_ver}" != "${VERSION}" && -d "${PREFIX}/releases/${old_ver}" ]]; then
back="${old_ver}"
elif [[ -d "${PREFIX}/releases/${VERSION}.prev" ]]; then
rm -rf "${REL}"
mv "${PREFIX}/releases/${VERSION}.prev" "${REL}"
back="${VERSION}"
fi
if [[ -n "${back}" ]]; then
warn "$(t "回滚到" "rolling back to") ${back}"
switch_current "${back}"
REL="${PREFIX}/releases/${back}"
install_integration
svc_start "${PREFIX}" "${DATA}"
netx_admin wait-health "$(health_url)" --timeout 180 && ok "$(t "已回滚并恢复运行" "rolled back and running"): ${back}"
local dump
dump="$(ls -1t "${DATA}"/backups/pre-*.dump 2>/dev/null | head -n1 || true)"
[[ -n "${dump}" ]] && warn "$(t "如数据库已被新版本迁移,可用此备份恢复" "if the new version migrated the database, restore from"): ${dump}"
fi
exit 3
}
preflight
load_pkg_meta
if [[ -z "${MODE}" ]]; then
if [[ -n "$(env_get "${ENV_FILE}" NETX_DB_MODE)" ]]; then MODE="update"; else MODE="install"; fi
if [[ "${MODE}" == "update" && "${INTERACTIVE}" == "1" ]]; then
echo "$(t "检测到已有安装(${DATA}):" "Existing installation found (${DATA}):")"
echo " 1) $(t "更新(保留数据和配置,推荐)" "update (keep data and settings, recommended)")"
echo " 2) $(t "重新配置(重新选择端口/数据库,不删数据)" "reconfigure (choose ports/database again; data kept)")"
[[ "$(ask "$(t "请选择" "Choose")" 1)" == "2" ]] && MODE="reinstall"
fi
fi
case "${MODE}" in
install|reinstall) do_install ;;
update) do_update ;;
*) die "invalid --mode: ${MODE}" ;;
esac