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

82
packaging/linux/README.md Normal file
View file

@ -0,0 +1,82 @@
# NetX Linux 发行版 / Linux release
一个离线安装包 `netx-<版本>-linux-x86_64.tar.gz`:自带 Python 3.12、全部依赖、PostgreSQL 16 和网页,目标机器上**不需要预装任何软件**(只要 bash、tar、gzip、useradd 这些系统自带命令)。
## 支持的系统
x86_64、glibc ≥ 2.17:CentOS 7/8/Stream 9、RHEL 7–9、Rocky/Alma 8–9、Oracle Linux、Fedora、Ubuntu 18.04+、Debian 9+、openSUSE Leap / SLES 12+、openEuler 20.03+、麒麟 V10、统信 UOS 20、Anolis 等。
不支持:Alpine(musl)、32 位、ARM、CentOS 6。
## 安装
```bash
tar xzf netx-0.4.19-linux-x86_64.tar.gz
cd netx-0.4.19-linux-x86_64
sudo ./install.sh
```
交互式安装会依次询问:监听地址(默认 `0.0.0.0`)、**Web/API 端口**(默认 8890,被占用会提示重新输入)、数据库(内置 / 外置)、**内置 PostgreSQL 端口**(默认 15432,只监听 127.0.0.1)。
非交互示例:
```bash
sudo ./install.sh --yes --port 9890 --db bundled --pg-port 25432
sudo ./install.sh --yes --db external --db-url 'postgresql+psycopg://netx:密码@10.0.0.8:5432/netx'
```
装好后访问 `http://<服务器IP>:<端口>/`,初始账号 `admin`,密码在安装结束时显示(随机生成,也可用 `--admin-password` 指定;同时保存在 `/var/lib/netx/.env` 的 `NETX_BOOTSTRAP_ADMIN_PASSWORD`),首次登录必须改密码。
> 对外监听(非 127.0.0.1)时 NetX 自身要求:管理员初始密码不能是 admin123,并且开启 UME 的 TLS 校验。安装程序会自动设置 `NETX_UME_VERIFY_TLS=true`;如果 UME 用的是自签名证书,需要配置证书,或者改成只监听本机再通过反向代理访问。
| 路径 | 内容 |
|---|---|
| `/opt/netx/current` → `releases/<版本>` | 程序(只读,保留最近 2 个版本用于回滚) |
| `/var/lib/netx` | `.env`、`pgdata/`、`data/`、`logs/`、`backups/` |
| `/usr/bin/netxctl` | 管理命令 |
| `netx.service` | systemd 服务,开机自启;没有 systemd 时用 `netxctl start` |
服务以系统用户 `netx` 运行。
## 日常管理
```bash
sudo netxctl status
sudo netxctl stop | start | restart
sudo netxctl logs api -f # api / worker / biz0 / pg / netxctl
sudo netxctl set-port 9890 # 改 Web/API 端口(检查占用,失败自动回滚)
sudo netxctl set-bind 127.0.0.1
sudo netxctl set-pg-port 25432 # 改内置 PostgreSQL 端口
sudo netxctl reconfigure-db # 重新选择内置/外置数据库(不删数据)
sudo netxctl backup # pg_dump + .env + 密钥 → /var/lib/netx/backups/
sudo netxctl uninstall # 保留数据;--purge 连数据一起删除
```
## 更新
- 在线:`sudo netxctl check-update`、`sudo netxctl update`。更新源与 Windows 版一致(GitHub、Forgejo,可用 `.env` 里的 `NETX_UPDATE_*` 覆盖),下载后校验 sha256。
- 离线:`sudo netxctl update --file netx-0.4.20-linux-x86_64.tar.gz`,或者解压新包后执行 `sudo ./install.sh`(自动识别为更新)。
- 自动:`sudo netxctl auto-update on`(每天 03:30 左右;systemd timer,没有 systemd 时用 cron)。
更新会先停服务,把内置数据库备份到 `backups/pre-<版本>-*.dump`(保留 3 份),然后切换版本并启动。新版本没起来就自动切回旧版本。端口、数据库等配置保持不变。
## 构建(开发者)
需要 docker,构建在 `manylinux2014` 容器里进行(CentOS 7 / glibc 2.17),保证产物在老系统上也能运行:
```bash
packaging/linux/build_linux_release.sh # 产物在 packaging/release/
packaging/linux/build_linux_release.sh --skip-web-build # 复用现成的 web/dist
```
- 第一次会从源码编译 PostgreSQL,结果缓存在 `packaging/cache/linux/`。
- `check_bundle.sh` 检查所有 ELF:要求的 glibc 不超过 2.17,并且依赖的库都在包里。
- 冒烟测试(断网,在一个裸发行版容器里完整走一遍安装、改端口、更新和卸载):
```bash
docker run --rm --network none -v "$PWD/packaging/release:/pkg:ro" \
-v "$PWD/packaging/linux/smoke_test.sh:/smoke.sh:ro" centos:7 \
bash /smoke.sh /pkg/netx-0.4.19-linux-x86_64.tar.gz
```
CI:`.github/workflows/release-linux.yml` 在 `v*` tag 上构建,在 7 个发行版里跑冒烟测试,全部通过后把 tar.gz 和 .sha256 挂到同一个 Release。挂到 Forgejo 用 `packaging/publish_forgejo_release.ps1`,它会自动带上 `packaging/release/` 下的 Linux 包。

View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Runs inside manylinux2014 (CentOS 7, glibc 2.17). Called by build_linux_release.sh.
# Mounts: /src (repo, ro) /webdist (ro) /cache (downloads + built PG) /out
set -euo pipefail
. /src/packaging/linux/versions.env
: "${VERSION:?}"
ARCH=x86_64
NAME="netx-${VERSION}-linux-${ARCH}"
BUILD=/tmp/build
STAGE="${BUILD}/${NAME}"
JOBS="$(nproc)"
rm -rf /tmp/build
mkdir -p "${STAGE}"
# ------------------------------------------------------------ PostgreSQL (cached)
PG_TGZ="/cache/pgsql-${PG_VERSION}-linux-${ARCH}.tar.gz"
if [[ ! -f "${PG_TGZ}" ]]; then
echo "==> building PostgreSQL ${PG_VERSION}"
rm -rf /tmp/pgsrc /opt/pgsql
mkdir -p /tmp/pgsrc
tar xjf "/cache/$(basename "${PG_URL}")" -C /tmp/pgsrc
cd "/tmp/pgsrc/postgresql-${PG_VERSION}"
# No ICU/readline/OpenSSL: nothing for the target box to provide. SCRAM works
# without OpenSSL; the server only listens on 127.0.0.1.
./configure --prefix=/opt/pgsql --without-icu --without-readline --without-openssl \
--with-zlib --disable-rpath CFLAGS="-O2" >/tmp/pg-configure.log
make -j"${JOBS}" >/tmp/pg-make.log
make install >/tmp/pg-install.log
cd /opt/pgsql
keep=" postgres pg_ctl initdb psql pg_dump pg_dumpall pg_restore pg_isready pg_controldata pg_upgrade "
for f in bin/*; do
[[ "${keep}" == *" $(basename "${f}") "* ]] || rm -f "${f}"
done
rm -rf /opt/pgsql/include /opt/pgsql/lib/pgxs /opt/pgsql/lib/pkgconfig
rm -f /opt/pgsql/lib/*.a
cp -L "$(ldconfig -p | awk '/libz\.so\.1 .*x86-64/ {print $NF; exit}')" lib/libz.so.1
for f in bin/*; do patchelf --set-rpath '$ORIGIN/../lib' "${f}"; done
for f in lib/*.so*; do
[[ -L "${f}" ]] || patchelf --set-rpath '$ORIGIN' "${f}"
done
strip --strip-unneeded bin/* lib/*.so* 2>/dev/null || true
tar czf "${PG_TGZ}" -C /opt pgsql
fi
tar xzf "${PG_TGZ}" -C "${STAGE}"
# ------------------------------------------------------------ Python + deps
echo "==> Python ${PY_VERSION} + requirements"
tar xzf "/cache/$(basename "${PY_URL}")" -C "${STAGE}" # -> python/
PY="${STAGE}/python/bin/python3"
export PIP_DISABLE_PIP_VERSION_CHECK=1
[[ -n "${PIP_INDEX_URL:-}" ]] || unset PIP_INDEX_URL
mkdir -p /cache/pip
# Running on glibc 2.17 makes pip pick manylinux2014-compatible wheels only.
"${PY}" -m pip install --cache-dir /cache/pip --no-warn-script-location --only-binary=:all: \
-r /src/requirements.txt
echo "==> slimming runtime"
PYLIB="${STAGE:?}/python/lib/python3.12"
for d in test idlelib tkinter turtledemo ensurepip turtle.py; do
rm -rf "${PYLIB:?}/${d}"
done
rm -f "${PYLIB:?}"/lib-dynload/_tkinter* "${PYLIB:?}"/lib-dynload/_test* "${PYLIB:?}"/lib-dynload/xxlimited*
for d in "${STAGE:?}"/python/lib/tcl* "${STAGE:?}"/python/lib/tk* "${STAGE:?}"/python/lib/itcl* \
"${STAGE:?}"/python/lib/thread* "${STAGE:?}"/python/lib/libtcl* "${STAGE:?}"/python/lib/libtk* \
"${STAGE:?}/python/share"; do
[[ -e "${d}" ]] && rm -rf "${d}"
done
find "${PYLIB}/site-packages" -depth -type d \( -name tests -o -name test -o -name testing \) \
-exec rm -rf {} + 2>/dev/null || true
find "${STAGE}/python" -name __pycache__ -type d -prune -exec rm -rf {} +
find "${STAGE}/python" -name '*.pyc' -delete
# ------------------------------------------------------------ app files
echo "==> app files"
cp -r /src/netx_api /src/alembic "${STAGE}/"
for f in pyproject.toml requirements.txt alembic.ini README.md LICENSE SECURITY.md; do
if [[ -f "/src/${f}" ]]; then cp "/src/${f}" "${STAGE}/"; fi
done
find "${STAGE}/netx_api" "${STAGE}/alembic" -name __pycache__ -type d -prune -exec rm -rf {} +
rm -rf "${STAGE:?}/netx_api/tests"
mkdir -p "${STAGE}/web"
cp -r /webdist "${STAGE}/web/dist"
mkdir -p "${STAGE}/packaging/linux"
for f in lib.sh netxctl netx_admin.py systemd logrotate.netx README.md; do
cp -r "/src/packaging/linux/${f}" "${STAGE}/packaging/linux/"
done
cp /src/packaging/linux/install.sh "${STAGE}/install.sh"
# Sources may come from a Windows checkout: force LF + exec bits.
sed -i 's/\r$//' "${STAGE}/install.sh" "${STAGE}"/packaging/linux/{lib.sh,netxctl,netx_admin.py,logrotate.netx} \
"${STAGE}"/packaging/linux/systemd/*
chmod 0755 "${STAGE}/install.sh" "${STAGE}/packaging/linux/netxctl"
cat >"${STAGE}/version.json" <<JSON
{"version": "${VERSION}", "arch": "${ARCH}", "pg_major": ${PG_MAJOR}, "pg_version": "${PG_VERSION}", "python": "${PY_VERSION}", "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
JSON
# ------------------------------------------------------------ checks
(cd "${STAGE}" && PYTHONPATH="${STAGE}" "${PY}" -c "import netx_api.main, psycopg, cryptography, netmiko, bcrypt, psutil; print('import smoke OK')")
find "${STAGE}" -name __pycache__ -type d -prune -exec rm -rf {} +
"${STAGE}/pgsql/bin/postgres" --version
bash /src/packaging/linux/check_bundle.sh "${STAGE}" "${MAX_GLIBC}"
# ------------------------------------------------------------ archive
echo "==> archive"
tar --owner=0 --group=0 --numeric-owner -czf "/out/${NAME}.tar.gz" -C "${BUILD}" "${NAME}"
(cd /out && sha256sum "${NAME}.tar.gz" >"${NAME}.tar.gz.sha256")
chown "${HOST_UID:-0}:${HOST_GID:-0}" "/out/${NAME}.tar.gz" "/out/${NAME}.tar.gz.sha256" "${PG_TGZ}" 2>/dev/null || true
chown -R "${HOST_UID:-0}:${HOST_GID:-0}" /cache/pip 2>/dev/null || true
du -sh "${STAGE}" "/out/${NAME}.tar.gz"

View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Build netx-<ver>-linux-x86_64.tar.gz (+ .sha256) into packaging/release/.
#
# packaging/linux/build_linux_release.sh [--version X.Y.Z] [--web-dist DIR] [--skip-web-build]
#
# Needs docker (the build runs inside manylinux2014 so the result works on glibc >= 2.17).
# Node 20+ is needed only when web/dist must be (re)built.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
# shellcheck source=versions.env
. "${HERE}/versions.env"
VERSION=""
WEB_DIST=""
SKIP_WEB_BUILD=0
OUT="${REPO}/packaging/release"
CACHE="${REPO}/packaging/cache/linux"
while [[ $# -gt 0 ]]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--web-dist) WEB_DIST="$2"; shift 2 ;;
--skip-web-build) SKIP_WEB_BUILD=1; shift ;;
--out) OUT="$2"; shift 2 ;;
--cache) CACHE="$2"; shift 2 ;;
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
if [[ -z "${VERSION}" ]]; then
VERSION="$(sed -n 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "${REPO}/pyproject.toml" | head -n1)"
fi
[[ -n "${VERSION}" ]] || { echo "cannot read version from pyproject.toml" >&2; exit 1; }
command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; }
if [[ -z "${WEB_DIST}" ]]; then
WEB_DIST="${REPO}/web/dist"
if [[ "${SKIP_WEB_BUILD}" != "1" ]]; then
echo "==> npm build (web/dist)"
(cd "${REPO}/web" && npm ci && npm run build:release)
fi
fi
[[ -f "${WEB_DIST}/index.html" ]] || { echo "web dist missing: ${WEB_DIST}/index.html" >&2; exit 1; }
WEB_DIST="$(cd "${WEB_DIST}" && pwd)"
mkdir -p "${OUT}" "${CACHE}"
fetch() { # fetch url sha256
local f
f="${CACHE}/$(basename "$1")"
if [[ ! -f "${f}" ]] || ! echo "$2 ${f}" | sha256sum -c --status; then
echo "==> download $1"
curl -fSL --retry 3 -o "${f}.part" "$1"
mv "${f}.part" "${f}"
fi
echo "$2 ${f}" | sha256sum -c --status || { echo "sha256 mismatch: ${f}" >&2; exit 1; }
}
fetch "${PG_URL}" "${PG_SHA256}"
fetch "${PY_URL}" "${PY_SHA256}"
echo "==> build in ${BUILD_IMAGE} (version ${VERSION})"
docker run --rm \
-v "${REPO}:/src:ro" \
-v "${WEB_DIST}:/webdist:ro" \
-v "${CACHE}:/cache" \
-v "${OUT}:/out" \
-e VERSION="${VERSION}" \
-e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
-e PIP_INDEX_URL="${PIP_INDEX_URL:-}" \
-e HTTP_PROXY="${HTTP_PROXY:-}" -e HTTPS_PROXY="${HTTPS_PROXY:-}" -e NO_PROXY="${NO_PROXY:-}" \
-e http_proxy="${http_proxy:-}" -e https_proxy="${https_proxy:-}" -e no_proxy="${no_proxy:-}" \
"${BUILD_IMAGE}" bash /src/packaging/linux/build_in_container.sh
echo "==> done:"
ls -la "${OUT}/netx-${VERSION}-linux-x86_64.tar.gz" "${OUT}/netx-${VERSION}-linux-x86_64.tar.gz.sha256"

37
packaging/linux/check_bundle.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Fail when any ELF in the stage needs glibc newer than MAX or a shared library
# that is neither in the bundle nor on the base-system allowlist.
# check_bundle.sh <stage-dir> [max-glibc=2.17]
set -euo pipefail
STAGE="$1"
MAX="${2:-2.17}"
# Present on every glibc distro (libcrypt.so.1 only backs the unused _crypt module).
ALLOW=" libc.so.6 libm.so.6 libdl.so.2 libpthread.so.0 librt.so.1 libutil.so.1 libcrypt.so.1 libnsl.so.1 libresolv.so.2 ld-linux-x86-64.so.2 libgcc_s.so.1 libstdc++.so.6 "
is_elf() { [[ "$(head -c4 "$1" 2>/dev/null | od -An -c | tr -d ' ')" == '177ELF' ]]; }
declare -A HAVE=()
ELFS=()
while IFS= read -r -d '' f; do
is_elf "${f}" && ELFS+=("${f}")
done < <(find "${STAGE}" -type f \( -name '*.so*' -o -perm -u+x \) -print0)
for f in "${ELFS[@]}"; do HAVE["$(basename "${f}")"]=1; done
while IFS= read -r -d '' l; do HAVE["$(basename "${l}")"]=1; done < <(find "${STAGE}" -type l -name '*.so*' -print0)
bad=0
for f in "${ELFS[@]}"; do
top="$(objdump -T "${f}" 2>/dev/null | grep -o 'GLIBC_[0-9][0-9.]*' | sed 's/GLIBC_//' | sort -V | tail -n1 || true)"
if [[ -n "${top}" && "$(printf '%s\n%s\n' "${top}" "${MAX}" | sort -V | tail -n1)" != "${MAX}" ]]; then
echo "[FAIL] ${f#"${STAGE}"/} needs GLIBC_${top} (> ${MAX})"
bad=1
fi
while IFS= read -r need; do
need="${need##*/}" # $ORIGIN/../lib/libfoo.so -> libfoo.so
[[ -z "${need}" || "${ALLOW}" == *" ${need} "* ]] && continue
[[ -n "${HAVE[${need}]:-}" ]] && continue
echo "[FAIL] ${f#"${STAGE}"/} needs ${need} (not bundled)"
bad=1
done < <(readelf -d "${f}" 2>/dev/null | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p')
done
echo "==> checked ${#ELFS[@]} ELF files (max GLIBC ${MAX})"
exit "${bad}"

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

171
packaging/linux/lib.sh Normal file
View file

@ -0,0 +1,171 @@
# Shared helpers for install.sh and netxctl. Sourced, never executed.
# Portable bash 4.2+ (CentOS 7): no `wait -n`, no `${var@Q}`, no mapfile -d.
NETX_ZH=0
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in zh*|ZH*) NETX_ZH=1 ;; esac
# t "中文" "English"
t() { if [[ "${NETX_ZH}" == "1" ]]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
info() { printf '==> %s\n' "$*"; }
ok() { printf '\033[32m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[33m[WARN] %s\033[0m\n' "$*" >&2; }
err() { printf '\033[31m[ERR] %s\033[0m\n' "$*" >&2; }
die() { err "$*"; exit 1; }
NETX_USER="${NETX_USER:-netx}"
have_systemd() { [[ -d /run/systemd/system ]] && command -v systemctl >/dev/null 2>&1; }
is_root() { [[ "$(id -u)" -eq 0 ]]; }
# Run a command as the netx service user (no-op when already that user).
as_netx() {
if [[ "$(id -un)" == "${NETX_USER}" ]]; then
"$@"
else
# Bundled Python drops privileges itself: minimal images may lack su/runuser.
# It also cd's to / (the service user usually cannot read root's cwd).
netx_admin run-as "${NETX_USER}" "$@"
fi
}
# netx_admin <subcommand> ... — stdlib helper on the bundled Python.
# Needs NETX_PY and NETX_ADMIN_PY set by the caller.
netx_admin() { "${NETX_PY}" -I "${NETX_ADMIN_PY}" "$@"; }
env_get() { netx_admin env-get "$1" "$2" 2>/dev/null || true; }
# Export every KEY=VALUE from a .env file without eval (values may hold any char).
load_env_file() {
local f="$1" kv
[[ -f "${f}" ]] || return 0
while IFS= read -r -d '' kv; do
export "${kv%%=*}=${kv#*=}"
done < <(netx_admin env-export "${f}")
}
# Runtime paths always live under the data root (same keys as Windows Get-NetxDataEnvMap).
data_env_pairs() {
local d="$1"
printf '%s\n' \
"NETX_AUTH_MCP_TOKEN_FILE=${d}/data/auth/mcp_token" \
"NETX_AUTH_SECRET_FILE=${d}/data/auth/jwt_secret" \
"NETX_CREDENTIAL_SECRET_FILE=${d}/data/auth/credential_secret" \
"NETX_SCHEDULER_HEARTBEAT_PATH=${d}/data/runtime/scheduler_heartbeat.json" \
"NETX_RUN_DIR=${d}/data/runtime" \
"NETX_BIZ_STATE_SPOOL_DIR=${d}/data/biz_state_spool" \
"NETX_NE_COLLECTION_DATA_DIR=${d}/data/ne_collections" \
"NETX_NE_EXEC_JOB_DIR=${d}/data/ne_exec_jobs" \
"NETX_WEBCRT_DATA_DIR=${d}/data/webcrt"
}
ensure_data_dirs() {
local d="$1" sub
for sub in data data/auth data/runtime data/biz_state_spool data/ne_collections \
data/ne_exec_jobs data/webcrt backups logs updates pgdata; do
mkdir -p "${d}/${sub}"
done
if is_root; then
chown -R "${NETX_USER}:${NETX_USER}" "${d}"
chmod 0750 "${d}"
chmod 0700 "${d}/pgdata"
[[ -f "${d}/.env" ]] && chmod 0600 "${d}/.env"
fi
return 0
}
# Client-side host for health checks when bound to a wildcard.
client_host() {
case "$1" in
""|0.0.0.0|"::"|"[::]") echo "127.0.0.1" ;;
*) echo "$1" ;;
esac
}
is_loopback() { case "$1" in 127.0.0.1|localhost|::1) return 0 ;; *) return 1 ;; esac; }
primary_ip() {
local ip=""
if command -v hostname >/dev/null 2>&1; then
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
fi
[[ -n "${ip}" ]] || ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
echo "${ip:-<server-ip>}"
}
# ---------------------------------------------------------------- PostgreSQL
# Callers set: PGBIN (…/pgsql/bin), PGDATA_DIR, NETX_LOG_DIR.
pg_ctl_netx() { as_netx env LC_ALL=C LANG=C "${PGBIN}/pg_ctl" "$@"; }
pg_is_running() { pg_ctl_netx -D "${PGDATA_DIR}" status >/dev/null 2>&1; }
pg_start() {
pg_ctl_netx -D "${PGDATA_DIR}" -l "${NETX_LOG_DIR}/pg.log" -w -t 90 start >/dev/null
}
pg_stop() { pg_ctl_netx -D "${PGDATA_DIR}" -m fast -w -t 60 stop >/dev/null 2>&1 || true; }
# Major version of an initialised cluster (empty when none).
pg_data_major() { [[ -f "${PGDATA_DIR}/PG_VERSION" ]] && tr -d ' \n' <"${PGDATA_DIR}/PG_VERSION"; }
# ---------------------------------------------------------------- service control
# svc_* PREFIX DATA
svc_unit_installed() { have_systemd && [[ -f /etc/systemd/system/netx.service ]]; }
svc_pidfile() { echo "$1/data/runtime/netx.pid"; }
svc_pid_alive() {
local pf pid
pf="$(svc_pidfile "$1")"
[[ -f "${pf}" ]] || return 1
pid="$(cat "${pf}" 2>/dev/null)"
[[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null
}
svc_running() {
local data="$2"
if svc_unit_installed; then
systemctl is-active --quiet netx.service
else
svc_pid_alive "${data}"
fi
}
svc_start() {
local prefix="$1" data="$2"
if svc_unit_installed; then
systemctl start netx.service
return
fi
if svc_pid_alive "${data}"; then
return 0
fi
local ctl="${prefix}/current/packaging/linux/netxctl" pf
pf="$(svc_pidfile "${data}")"
# Own session => the pid in the file also names the process group to stop.
# (Bundled Python instead of setsid/nohup: minimal images lack util-linux.)
# Installed copy: install.sh's own NETX_PY may live where netx cannot enter (/root).
as_netx "${prefix}/current/python/bin/python3" -I "${prefix}/current/packaging/linux/netx_admin.py" spawn "${data}/logs/netxctl.log" "${pf}" "${ctl}" run
}
svc_stop() {
local prefix="$1" data="$2"
if svc_unit_installed; then
systemctl stop netx.service || true
elif svc_pid_alive "${data}"; then
local pid i
pid="$(cat "$(svc_pidfile "${data}")")"
kill -TERM "${pid}" 2>/dev/null || true
for ((i = 0; i < 90; i++)); do
kill -0 "${pid}" 2>/dev/null || break
sleep 1
done
if kill -0 "${pid}" 2>/dev/null; then
warn "$(t "进程未在 90 秒内退出,强制结束" "netx did not exit in 90s; killing")"
kill -KILL -- "-${pid}" 2>/dev/null || kill -KILL "${pid}" 2>/dev/null || true
fi
fi
rm -f "$(svc_pidfile "${data}")"
# A crashed supervisor can leave the bundled cluster running.
if [[ -x "${prefix}/current/pgsql/bin/pg_ctl" && -f "${data}/pgdata/PG_VERSION" ]]; then
PGBIN="${prefix}/current/pgsql/bin" PGDATA_DIR="${data}/pgdata" pg_stop
fi
return 0
}

View file

@ -0,0 +1,10 @@
@DATA@/logs/*.log {
su @USER@ @USER@
daily
rotate 7
missingok
notifempty
compress
delaycompress
copytruncate
}

View file

@ -0,0 +1,675 @@
"""NetX Linux admin helper (stdlib only, run with the bundled Python).
Used by install.sh and netxctl for the bits that are painful in portable bash:
.env editing, port probes, URL building, health waits and update downloads.
Every subcommand prints machine-readable output on stdout and exits non-zero on
failure with a short `error_code: detail` line on stderr.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import re
import secrets
import shutil
import socket
import ssl
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
DEFAULT_GITHUB_REPO = "hansjone/netx"
DEFAULT_FORGEJO_URL = "https://git.avelo.top/api/v1/repos/hansjone/netx/releases/latest"
USER_AGENT = "netx-linux-updater"
def die(code: str, detail: str = "", rc: int = 1) -> None:
sys.stderr.write(f"{code}: {detail}\n" if detail else f"{code}\n")
sys.exit(rc)
# --------------------------------------------------------------------------- .env
def _parse_env_line(line: str) -> tuple[str, str] | None:
t = line.strip()
if not t or t.startswith("#") or "=" not in t:
return None
k, v = t.split("=", 1)
k = k.strip()
if k.startswith("export "):
k = k[len("export ") :].strip()
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1]
return k, v
def read_env(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.exists():
return out
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
kv = _parse_env_line(line)
if kv:
out[kv[0]] = kv[1]
return out
def write_env(path: Path, values: dict[str, str], unset: list[str] | None = None) -> None:
"""Update keys in place (keeping comments/order), append new ones."""
unset_set = set(unset or [])
lines: list[str] = []
seen: set[str] = set()
if path.exists():
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
kv = _parse_env_line(line)
if kv and kv[0] in unset_set:
continue
if kv and kv[0] in values:
if kv[0] in seen:
continue
lines.append(f"{kv[0]}={values[kv[0]]}")
seen.add(kv[0])
continue
lines.append(line)
for k, v in values.items():
if k not in seen:
lines.append(f"{k}={v}")
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
try:
st = path.stat()
if hasattr(os, "chown"):
os.chown(tmp, st.st_uid, st.st_gid)
except (FileNotFoundError, PermissionError):
pass
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# --------------------------------------------------------------------------- net
def port_free(port: int, host: str = "0.0.0.0") -> bool:
"""True when nothing listens on `port` (checks the wildcard and loopback)."""
for h in {host, "0.0.0.0", "127.0.0.1"}:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((h, port))
except OSError:
return False
finally:
s.close()
# Also catch a listener bound only to a specific address we did not test.
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return False
except OSError:
pass
return True
def valid_port(p: str) -> int:
try:
n = int(p)
except ValueError:
die("invalid_port", p)
if not 1024 <= n <= 65535:
die("invalid_port", f"{p} (must be 1024-65535)")
return n
def http_ok(url: str, timeout: float = 3.0) -> bool:
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(req, timeout=timeout) as r:
return 200 <= r.status < 300
except Exception:
return False
# --------------------------------------------------------------------------- db url
_URL_RE = re.compile(
r"^(?P<scheme>postgres(?:ql)?(?:\+psycopg)?)://(?P<user>[^:@/]+)(?::(?P<pw>[^@]*))?@(?P<host>\[[^\]]+\]|[^:/?]+)(?::(?P<port>\d+))?/(?P<db>[^?\s]+)(?P<rest>\?.*)?$"
)
def build_url(user: str, pw: str, host: str, port: int, db: str) -> str:
q = urllib.parse.quote
return f"postgresql+psycopg://{q(user, safe='')}:{q(pw, safe='')}@{host}:{port}/{q(db, safe='')}"
def url_with_port(url: str, port: int) -> str:
m = _URL_RE.match(url.strip())
if not m:
die("db_url_unparseable")
pw = f":{m['pw']}" if m["pw"] is not None else ""
return f"{m['scheme']}://{m['user']}{pw}@{m['host']}:{port}/{m['db']}{m['rest'] or ''}"
def url_parts(url: str) -> dict[str, str]:
m = _URL_RE.match(url.strip())
if not m:
die("db_url_unparseable")
u = urllib.parse.unquote
return {
"user": u(m["user"]),
"password": u(m["pw"] or ""),
"host": m["host"].strip("[]"),
"port": m["port"] or "5432",
"db": u(m["db"]),
}
def probe_db(url: str) -> None:
"""Connect with the bundled psycopg and run SELECT 1."""
p = url_parts(url)
try:
import psycopg # type: ignore
except Exception as exc: # pragma: no cover - only without bundled deps
die("psycopg_unavailable", str(exc))
try:
with psycopg.connect(
host=p["host"],
port=int(p["port"]),
user=p["user"],
password=p["password"],
dbname=p["db"],
connect_timeout=8,
) as conn:
conn.execute("SELECT 1")
except Exception as exc:
die("external_db_probe_failed", str(exc).strip().splitlines()[0] if str(exc).strip() else repr(exc))
# --------------------------------------------------------------------------- pg conf
def pg_conf_set(conf: Path, settings: dict[str, str]) -> None:
text = conf.read_text(encoding="utf-8", errors="replace") if conf.exists() else ""
for key, val in settings.items():
pat = re.compile(rf"(?m)^\s*#?\s*{re.escape(key)}\s*=.*$")
line = f"{key} = {val}"
# Replace the first (possibly commented) occurrence, drop later active duplicates.
found = False
def _sub(m: re.Match[str]) -> str:
nonlocal found
if not found:
found = True
return line
return "" if not m.group(0).lstrip().startswith("#") else m.group(0)
text = pat.sub(_sub, text)
if not found:
text = text.rstrip("\n") + f"\n{line}\n"
conf.write_text(text, encoding="utf-8")
def pg_conf_get_port(conf: Path) -> str:
for line in conf.read_text(encoding="utf-8", errors="replace").splitlines():
m = re.match(r"^\s*port\s*=\s*(\d+)", line)
if m:
return m.group(1)
return "5432"
# --------------------------------------------------------------------------- versions
def ver_tuple(v: str) -> tuple[int, ...]:
parts = re.findall(r"\d+", v.lstrip("vV"))
return tuple(int(x) for x in parts[:4]) or (0,)
def current_version(prefix: Path) -> str:
vj = prefix / "current" / "version.json"
try:
return str(json.loads(vj.read_text(encoding="utf-8")).get("version") or "0.0.0")
except Exception:
return "0.0.0"
# --------------------------------------------------------------------------- update
class Cfg:
def __init__(self, env: dict[str, str]):
def g(k: str, default: str = "") -> str:
return (env.get(k) or os.environ.get(k) or default).strip()
self.update_url = g("NETX_UPDATE_URL")
self.fallback_url = g("NETX_UPDATE_FALLBACK_URL")
self.forgejo_url = g("NETX_UPDATE_FORGEJO_URL", DEFAULT_FORGEJO_URL)
self.github_repo = g("NETX_UPDATE_GITHUB_REPO", DEFAULT_GITHUB_REPO)
self.sources = g("NETX_UPDATE_SOURCES")
self.token = g("NETX_UPDATE_TOKEN")
def source_list(self) -> list[str]:
if self.sources:
return [s.strip().lower() for s in self.sources.split(",") if s.strip()]
out = []
if self.update_url:
out.append("manifest")
out += ["github", "forgejo"]
if self.fallback_url:
out.append("manifest_fallback")
return out
def _is_github(url: str) -> bool:
host = (urllib.parse.urlparse(url).hostname or "").lower()
return host == "github.com" or host.endswith(".github.com") or host.endswith("githubusercontent.com")
def _open(url: str, cfg: Cfg, accept: str = "application/json", style: str = "", timeout: int = 30):
headers = {"User-Agent": USER_AGENT, "Accept": accept}
if cfg.token:
if style == "bearer":
headers["Authorization"] = f"Bearer {cfg.token}"
elif style == "token" or (style == "" and not _is_github(url)):
headers["Authorization"] = f"token {cfg.token}"
req = urllib.request.Request(url, headers=headers)
return urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context())
def _get_json(url: str, cfg: Cfg, style: str) -> dict:
with _open(url, cfg, style=style) as r:
return json.loads(r.read().decode("utf-8"))
def asset_name_re(arch: str) -> re.Pattern[str]:
return re.compile(rf"^netx-(?P<ver>[0-9][0-9A-Za-z.\-]*)-linux-{re.escape(arch)}\.tar\.gz$")
def _release_to_candidate(rel: dict, source: str, arch: str, api_url: str = "") -> dict:
tag = str(rel.get("tag_name") or "")
assets = rel.get("assets") or []
rx = asset_name_re(arch)
pkg = next((a for a in assets if rx.match(str(a.get("name") or ""))), None)
if not pkg:
raise RuntimeError(f"{source}_release_missing_linux_{arch}")
sha = next((a for a in assets if str(a.get("name") or "") == f"{pkg['name']}.sha256"), None)
def url_of(a: dict) -> str:
u = str(a.get("browser_download_url") or "")
if u:
return u
m = re.match(r"^(https?://[^/]+)/api/v1/repos/([^/]+)/([^/]+)/releases/latest", api_url)
if source == "forgejo" and m and tag:
return f"{m[1]}/{m[2]}/{m[3]}/releases/download/{tag}/{a['name']}"
return ""
return {
"source": source,
"latest": tag.lstrip("vV"),
"url": url_of(pkg),
"sha256_url": url_of(sha) if sha else "",
"sha256": "",
"size": int(pkg.get("size") or 0),
"notes_url": str(rel.get("html_url") or ""),
}
def _manifest_to_candidate(m: dict, source: str, arch: str) -> dict:
linux = (m.get("linux") or {}).get(arch) or {}
if not linux.get("url"):
raise RuntimeError(f"{source}_manifest_missing_linux_{arch}")
return {
"source": m.get("source") or source,
"latest": str(m.get("latest") or "").lstrip("vV"),
"url": linux["url"],
"sha256_url": linux.get("sha256_url") or "",
"sha256": (linux.get("sha256") or "").lower(),
"size": int(linux.get("size") or 0),
"notes_url": m.get("notes_url") or "",
}
def find_update(cfg: Cfg, arch: str, quiet: bool) -> tuple[dict | None, list[str]]:
cands: list[dict] = []
errors: list[str] = []
for src in cfg.source_list():
try:
if src == "github":
rel = _get_json(f"https://api.github.com/repos/{cfg.github_repo}/releases/latest", cfg, "bearer")
c = _release_to_candidate(rel, "github", arch)
elif src == "forgejo":
rel = _get_json(cfg.forgejo_url, cfg, "token")
c = _release_to_candidate(rel, "forgejo", arch, cfg.forgejo_url)
elif src in ("manifest", "manifest_fallback"):
url = cfg.update_url if src == "manifest" else cfg.fallback_url
if not url:
raise RuntimeError(f"{src}_url_empty")
c = _manifest_to_candidate(_get_json(url, cfg, "bearer"), src, arch)
else:
raise RuntimeError(f"unknown_source:{src}")
if not c["url"]:
raise RuntimeError(f"{src}_download_url_missing")
cands.append(c)
if not quiet:
print(f" OK {src} latest={c['latest']}", file=sys.stderr)
except Exception as exc:
errors.append(f"{src}: {exc}")
if not quiet:
print(f"[WARN] source '{src}' failed: {exc}", file=sys.stderr)
best = None
for c in cands: # highest version wins; ties keep the earlier source
if best is None or ver_tuple(c["latest"]) > ver_tuple(best["latest"]):
best = c
return best, errors
def download(url: str, dest: Path, cfg: Cfg) -> None:
style = "none" if _is_github(url) else ""
with _open(url, cfg, accept="application/octet-stream", style=style, timeout=60) as r, open(dest, "wb") as f:
shutil.copyfileobj(r, f, 1024 * 1024)
def sha256_file(p: Path) -> str:
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def safe_extract(tar_path: Path, dest: Path) -> Path:
"""Extract a release tarball, refusing absolute/parent paths and links outside."""
dest.mkdir(parents=True, exist_ok=True)
root = dest.resolve()
with tarfile.open(tar_path, "r:gz") as tf:
members = tf.getmembers()
tops = set()
for m in members:
target = (root / m.name).resolve()
if not str(target).startswith(str(root) + os.sep) and target != root:
die("unsafe_tar_member", m.name)
if m.issym() or m.islnk():
link = (target.parent / m.linkname).resolve() if m.issym() else (root / m.linkname).resolve()
if not str(link).startswith(str(root) + os.sep):
die("unsafe_tar_link", f"{m.name} -> {m.linkname}")
if m.isdev():
die("unsafe_tar_device", m.name)
tops.add(m.name.split("/", 1)[0])
if len(tops) != 1:
die("bad_package_layout", ",".join(sorted(tops))[:200])
try:
tf.extractall(root, filter="tar") # type: ignore[call-arg]
except TypeError: # Python without extraction filters
tf.extractall(root)
pkg = root / tops.pop()
if not (pkg / "install.sh").is_file() or not (pkg / "version.json").is_file():
die("bad_package_layout", "install.sh/version.json missing")
return pkg
# --------------------------------------------------------------------------- commands
def cmd_env_get(a: argparse.Namespace) -> None:
v = read_env(Path(a.file)).get(a.key)
if v is None:
sys.exit(1)
print(v)
def cmd_env_set(a: argparse.Namespace) -> None:
vals: dict[str, str] = {}
for kv in a.pairs:
if "=" not in kv:
die("bad_pair", kv)
k, v = kv.split("=", 1)
vals[k] = v
write_env(Path(a.file), vals, a.unset or [])
def cmd_env_export(a: argparse.Namespace) -> None:
"""NUL-separated KEY=VALUE pairs for `while read -d ''` in bash (no eval)."""
for k, v in read_env(Path(a.file)).items():
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", k):
sys.stdout.write(f"{k}={v}\0")
def cmd_port_free(a: argparse.Namespace) -> None:
sys.exit(0 if port_free(valid_port(a.port), a.host) else 1)
def cmd_valid_port(a: argparse.Namespace) -> None:
print(valid_port(a.port))
def cmd_gen_secret(a: argparse.Namespace) -> None:
if a.kind == "fernet":
print(base64.urlsafe_b64encode(os.urandom(32)).decode())
else:
# Alphanumeric only: safe in URLs, SQL literals and as a CLI argument (no leading "-").
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
print("".join(secrets.choice(alphabet) for _ in range(32)))
def cmd_build_url(a: argparse.Namespace) -> None:
print(build_url(a.user, a.password, a.host, valid_port(a.port), a.db))
def cmd_url_port(a: argparse.Namespace) -> None:
print(url_with_port(a.url, valid_port(a.port)))
def cmd_url_part(a: argparse.Namespace) -> None:
print(url_parts(a.url)[a.part])
def cmd_probe_db(a: argparse.Namespace) -> None:
probe_db(a.url)
def cmd_pg_conf(a: argparse.Namespace) -> None:
conf = Path(a.conf)
if a.get_port:
print(pg_conf_get_port(conf))
return
vals = {}
for kv in a.set or []:
k, v = kv.split("=", 1)
vals[k] = v
pg_conf_set(conf, vals)
def cmd_wait_health(a: argparse.Namespace) -> None:
deadline = time.time() + a.timeout
while time.time() < deadline:
if http_ok(a.url):
return
time.sleep(1.5)
sys.exit(1)
def cmd_health(a: argparse.Namespace) -> None:
sys.exit(0 if http_ok(a.url) else 1)
def cmd_ver_cmp(a: argparse.Namespace) -> None:
x, y = ver_tuple(a.a), ver_tuple(a.b)
print(-1 if x < y else (1 if x > y else 0))
def cmd_json_get(a: argparse.Namespace) -> None:
try:
v = json.loads(Path(a.file).read_text(encoding="utf-8")).get(a.key)
except Exception:
sys.exit(1)
if v is None:
sys.exit(1)
print(v)
def cmd_run_as(a: argparse.Namespace) -> None:
"""Drop root to `user` and exec the command (no su/runuser needed on minimal systems)."""
import pwd
pw = pwd.getpwnam(a.user)
os.initgroups(pw.pw_name, pw.pw_gid)
os.setgid(pw.pw_gid)
os.setuid(pw.pw_uid)
os.environ.update(HOME=pw.pw_dir, USER=pw.pw_name, LOGNAME=pw.pw_name)
os.chdir("/")
os.execvp(a.argv[0], a.argv)
def cmd_spawn(a: argparse.Namespace) -> None:
"""Start argv detached in its own session (like `setsid nohup`), pid -> pidfile."""
pid = os.fork()
if pid:
Path(a.pidfile).write_text(f"{pid}\n", encoding="ascii")
return
os.setsid()
os.chdir("/")
fd_in = os.open(os.devnull, os.O_RDONLY)
fd_out = os.open(a.log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o640)
os.dup2(fd_in, 0)
os.dup2(fd_out, 1)
os.dup2(fd_out, 2)
os.closerange(3, 1024)
os.execvp(a.argv[0], a.argv)
def cmd_tar_create(a: argparse.Namespace) -> None:
with tarfile.open(a.out, "w:gz") as tf:
for p in sorted(Path(a.dir).iterdir()):
tf.add(p, arcname=p.name)
os.chmod(a.out, 0o600)
def cmd_tar_list(a: argparse.Namespace) -> None:
with tarfile.open(a.file, "r:*") as tf:
for name in tf.getnames():
print(name)
def cmd_check_update(a: argparse.Namespace) -> None:
cfg = Cfg(read_env(Path(a.env)))
cur = current_version(Path(a.prefix))
best, errors = find_update(cfg, a.arch, a.quiet)
if best is None:
die("update_check_failed", "; ".join(errors), rc=2)
newer = ver_tuple(best["latest"]) > ver_tuple(cur)
out = {"current": cur, "update_available": newer, **best}
print(json.dumps(out, ensure_ascii=False))
def cmd_fetch_update(a: argparse.Namespace) -> None:
"""Download + verify + extract. Prints the extracted package directory."""
cfg = Cfg(read_env(Path(a.env)))
info = json.loads(a.info)
work = Path(a.workdir)
if work.exists():
shutil.rmtree(work)
work.mkdir(parents=True)
name = Path(urllib.parse.urlparse(info["url"]).path).name or "netx-update.tar.gz"
if not asset_name_re(a.arch).match(name):
die("unexpected_asset_name", name)
pkg = work / name
print(f"==> Downloading {info['url']}", file=sys.stderr)
download(info["url"], pkg, cfg)
expected = (info.get("sha256") or "").lower()
if not expected:
sha_url = info.get("sha256_url") or (info["url"] + ".sha256")
try:
sha_tmp = work / (name + ".sha256")
download(sha_url, sha_tmp, cfg)
expected = sha_tmp.read_text(encoding="ascii", errors="replace").strip().split()[0].lower()
except Exception as exc:
die("sha256_unavailable", f"{sha_url}: {exc}")
if not re.fullmatch(r"[0-9a-f]{64}", expected):
die("invalid_sha256_metadata", expected[:80])
actual = sha256_file(pkg)
if actual != expected:
die("sha256_mismatch", f"got {actual} expected {expected}")
print("==> sha256 OK", file=sys.stderr)
print(safe_extract(pkg, work / "pkg"))
def cmd_extract(a: argparse.Namespace) -> None:
"""Offline update: verify optional <file>.sha256 next to the tarball, then extract."""
tar_path = Path(a.file)
if not tar_path.is_file():
die("file_not_found", str(tar_path))
if not asset_name_re(a.arch).match(tar_path.name):
die("unexpected_asset_name", f"{tar_path.name} (expected netx-<ver>-linux-{a.arch}.tar.gz)")
side = tar_path.with_name(tar_path.name + ".sha256")
if side.is_file():
expected = side.read_text(encoding="ascii", errors="replace").strip().split()[0].lower()
if sha256_file(tar_path) != expected:
die("sha256_mismatch", str(side))
print("==> sha256 OK", file=sys.stderr)
work = Path(a.workdir)
if work.exists():
shutil.rmtree(work)
print(safe_extract(tar_path, work / "pkg"))
def main(argv: list[str] | None = None) -> None:
p = argparse.ArgumentParser(prog="netx_admin")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("env-get"); s.add_argument("file"); s.add_argument("key"); s.set_defaults(fn=cmd_env_get)
s = sub.add_parser("env-set"); s.add_argument("file"); s.add_argument("pairs", nargs="*")
s.add_argument("--unset", action="append"); s.set_defaults(fn=cmd_env_set)
s = sub.add_parser("env-export"); s.add_argument("file"); s.set_defaults(fn=cmd_env_export)
s = sub.add_parser("port-free"); s.add_argument("port"); s.add_argument("--host", default="0.0.0.0")
s.set_defaults(fn=cmd_port_free)
s = sub.add_parser("valid-port"); s.add_argument("port"); s.set_defaults(fn=cmd_valid_port)
s = sub.add_parser("gen-secret"); s.add_argument("kind", choices=["fernet", "password"]); s.set_defaults(fn=cmd_gen_secret)
s = sub.add_parser("build-url")
for k in ("user", "password", "host", "port", "db"):
s.add_argument(k)
s.set_defaults(fn=cmd_build_url)
s = sub.add_parser("url-port"); s.add_argument("url"); s.add_argument("port"); s.set_defaults(fn=cmd_url_port)
s = sub.add_parser("url-part"); s.add_argument("url")
s.add_argument("part", choices=["user", "password", "host", "port", "db"]); s.set_defaults(fn=cmd_url_part)
s = sub.add_parser("probe-db"); s.add_argument("url"); s.set_defaults(fn=cmd_probe_db)
s = sub.add_parser("pg-conf"); s.add_argument("conf"); s.add_argument("--set", action="append")
s.add_argument("--get-port", action="store_true"); s.set_defaults(fn=cmd_pg_conf)
s = sub.add_parser("wait-health"); s.add_argument("url"); s.add_argument("--timeout", type=float, default=90)
s.set_defaults(fn=cmd_wait_health)
s = sub.add_parser("health"); s.add_argument("url"); s.set_defaults(fn=cmd_health)
s = sub.add_parser("ver-cmp"); s.add_argument("a"); s.add_argument("b"); s.set_defaults(fn=cmd_ver_cmp)
s = sub.add_parser("json-get"); s.add_argument("file"); s.add_argument("key"); s.set_defaults(fn=cmd_json_get)
s = sub.add_parser("check-update"); s.add_argument("--env", required=True); s.add_argument("--prefix", required=True)
s.add_argument("--arch", default="x86_64"); s.add_argument("--quiet", action="store_true")
s.set_defaults(fn=cmd_check_update)
s = sub.add_parser("fetch-update"); s.add_argument("--env", required=True); s.add_argument("--info", required=True)
s.add_argument("--workdir", required=True); s.add_argument("--arch", default="x86_64")
s.set_defaults(fn=cmd_fetch_update)
s = sub.add_parser("extract"); s.add_argument("file"); s.add_argument("--workdir", required=True)
s.add_argument("--arch", default="x86_64"); s.set_defaults(fn=cmd_extract)
s = sub.add_parser("run-as"); s.add_argument("user"); s.add_argument("argv", nargs=argparse.REMAINDER)
s.set_defaults(fn=cmd_run_as)
s = sub.add_parser("spawn"); s.add_argument("log"); s.add_argument("pidfile")
s.add_argument("argv", nargs=argparse.REMAINDER); s.set_defaults(fn=cmd_spawn)
s = sub.add_parser("tar-create"); s.add_argument("out"); s.add_argument("dir"); s.set_defaults(fn=cmd_tar_create)
s = sub.add_parser("tar-list"); s.add_argument("file"); s.set_defaults(fn=cmd_tar_list)
a = p.parse_args(argv)
a.fn(a)
if __name__ == "__main__":
main()

522
packaging/linux/netxctl Executable file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env bash
# NetX Linux control command. Installed as /usr/bin/netxctl
# -> <prefix>/current/packaging/linux/netxctl
set -euo pipefail
SELF="$(readlink -f "${BASH_SOURCE[0]}")"
HERE="$(dirname "${SELF}")"
# shellcheck source=lib.sh
. "${HERE}/lib.sh"
REL_DIR="$(cd "${HERE}/../.." && pwd)"
if [[ "$(basename "$(dirname "${REL_DIR}")")" == "releases" ]]; then
PREFIX="$(dirname "$(dirname "${REL_DIR}")")"
else
PREFIX="${NETX_PREFIX:-/opt/netx}"
fi
CUR="${PREFIX}/current"
NETX_DATA="/var/lib/netx"
if [[ -f "${PREFIX}/netx.conf" ]]; then
_d="$(sed -n 's/^NETX_DATA=//p' "${PREFIX}/netx.conf" | tail -n1)"
[[ -n "${_d}" ]] && NETX_DATA="${_d}"
fi
NETX_DATA="${NETX_DATA_OVERRIDE:-${NETX_DATA}}"
ENV_FILE="${NETX_DATA}/.env"
NETX_LOG_DIR="${NETX_DATA}/logs"
NETX_PY="${CUR}/python/bin/python3"
NETX_ADMIN_PY="${CUR}/packaging/linux/netx_admin.py"
PGBIN="${CUR}/pgsql/bin"
PGDATA_DIR="${NETX_DATA}/pgdata"
ARCH="x86_64"
usage() {
cat <<EOF
$(t "用法" "Usage"): netxctl <command> [options]
start | stop | restart | status
logs [api|worker|biz0|pg|netxctl] [-f] [-n N]
set-port <port> $(t "修改 Web/API 端口" "change the Web/API port")
set-bind <host> $(t "修改监听地址(0.0.0.0 = 所有网卡)" "change the listen address (0.0.0.0 = all)")
set-pg-port <port> $(t "修改内置 PostgreSQL 端口" "change the bundled PostgreSQL port")
reconfigure-db $(t "重新配置数据库(内置/外置)" "reconfigure the database (bundled/external)")
check-update $(t "检查新版本" "check for a newer release")
update [--yes] [--file <netx-x.y.z-linux-x86_64.tar.gz>] [--force]
auto-update on|off $(t "每日自动更新" "daily automatic update")
backup [<file.tar.gz>] $(t "备份数据库与密钥" "back up database + secrets")
uninstall [--purge] [--yes]
version
run $(t "前台运行(systemd 使用)" "foreground supervisor (used by systemd)")
EOF
}
need_root() { is_root || die "$(t "需要 root 权限:sudo netxctl $*" "root required: sudo netxctl $*")"; }
need_install() {
[[ -x "${NETX_PY}" ]] || die "$(t "未找到 NetX 安装" "NetX installation not found"): ${CUR}"
if [[ ! -r "${ENV_FILE}" ]]; then
is_root || [[ "$(id -un)" == "${NETX_USER}" ]] || die "$(t "需要 root 权限:sudo netxctl" "root required: sudo netxctl")"
die "$(t "未找到配置文件" "config not found"): ${ENV_FILE}"
fi
}
cfg() { env_get "${ENV_FILE}" "$1"; }
db_mode() { local m; m="$(cfg NETX_DB_MODE | tr 'A-Z' 'a-z')"; [[ "${m}" == "bundled" ]] && echo bundled || echo external; }
api_port() { local p; p="$(cfg NETX_PORT)"; echo "${p:-8890}"; }
api_bind() { local h; h="$(cfg NETX_HOST)"; echo "${h:-0.0.0.0}"; }
pg_port() { local p; p="$(cfg NETX_BUNDLED_PG_PORT)"; echo "${p:-15432}"; }
health_url() { echo "http://$(client_host "$(api_bind)"):$(api_port)/health"; }
cur_version() { netx_admin json-get "${CUR}/version.json" version 2>/dev/null || echo "?"; }
wait_healthy() {
local timeout="${1:-120}"
if netx_admin wait-health "$(health_url)" --timeout "${timeout}"; then
return 0
fi
err "$(t "NetX 未在 ${timeout} 秒内就绪" "NetX not healthy after ${timeout}s"): $(health_url)"
tail -n 30 "${NETX_LOG_DIR}/api.log" 2>/dev/null >&2 || true
return 1
}
# ------------------------------------------------------------------ run (foreground)
CHILD_PIDS=()
CHILD_NAMES=()
STOPPING=0
PG_OWNED=0
spawn() {
local name="$1"
shift
"$@" >>"${NETX_LOG_DIR}/${name}.log" 2>&1 &
CHILD_PIDS+=("$!")
CHILD_NAMES+=("${name}")
echo "$(date '+%F %T') started ${name} pid=$!"
}
stop_children() {
local pid i alive
for pid in "${CHILD_PIDS[@]}"; do kill -TERM "${pid}" 2>/dev/null || true; done
for ((i = 0; i < 60; i++)); do
alive=0
for pid in "${CHILD_PIDS[@]}"; do kill -0 "${pid}" 2>/dev/null && alive=1; done
[[ "${alive}" == "0" ]] && break
sleep 0.5
done
for pid in "${CHILD_PIDS[@]}"; do kill -KILL "${pid}" 2>/dev/null || true; done
if [[ "${PG_OWNED}" == "1" ]]; then
echo "$(date '+%F %T') stopping bundled PostgreSQL"
pg_stop
fi
}
cmd_run() {
if is_root; then
exec "${NETX_PY}" -I "${NETX_ADMIN_PY}" run-as "${NETX_USER}" "${SELF}" run
fi
need_install
mkdir -p "${NETX_LOG_DIR}"
load_env_file "${ENV_FILE}"
local kv
while IFS= read -r kv; do export "${kv%%=*}=${kv#*=}"; done < <(data_env_pairs "${NETX_DATA}")
export NETX_UI_DIST_DIR="${CUR}/web/dist"
export NETX_RUN_INLINE_SCHEDULERS=false
export PYTHONPATH="${CUR}"
export PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
cd "${CUR}"
if [[ "$(db_mode)" == "bundled" ]]; then
local want have
want="$(netx_admin json-get "${CUR}/version.json" pg_major 2>/dev/null || echo "")"
have="$(pg_data_major || true)"
[[ -n "${have}" ]] || die "bundled_pg_not_initialised: ${PGDATA_DIR}"
if [[ -n "${want}" && "${have}" != "${want}" ]]; then
die "pg_major_mismatch: data=${have} bundled=${want}"
fi
if ! pg_is_running; then
if ! netx_admin port-free "$(pg_port)" --host 127.0.0.1; then
die "port_in_use: 127.0.0.1:$(pg_port) (bundled PostgreSQL) — $(t "可用 netxctl set-pg-port 换端口" "use netxctl set-pg-port")"
fi
echo "$(date '+%F %T') starting bundled PostgreSQL on 127.0.0.1:$(pg_port)"
pg_start || die "pg_start_failed (see ${NETX_LOG_DIR}/pg.log)"
PG_OWNED=1
fi
fi
trap 'STOPPING=1' TERM INT
spawn api "${NETX_PY}" -m netx_api.main
spawn worker "${NETX_PY}" -m netx_api.worker
local n="${NETX_BIZ_STATE_WORKER_REPLICAS:-2}" i
[[ "${n}" =~ ^[0-9]+$ ]] || n=2
((n >= 1)) || n=1
for ((i = 0; i < n; i++)); do
spawn "biz${i}" "${NETX_PY}" -m netx_api.biz_state_worker
done
local rc=0 sleeper
while [[ "${STOPPING}" == "0" ]]; do
for i in "${!CHILD_PIDS[@]}"; do
if ! kill -0 "${CHILD_PIDS[$i]}" 2>/dev/null; then
wait "${CHILD_PIDS[$i]}" 2>/dev/null && rc=$? || rc=$?
echo "$(date '+%F %T') ${CHILD_NAMES[$i]} exited (rc=${rc}); stopping the rest" >&2
rc=1
STOPPING=1
break
fi
done
[[ "${STOPPING}" == "0" ]] || break
sleep 2 &
sleeper=$!
wait "${sleeper}" 2>/dev/null || kill "${sleeper}" 2>/dev/null || true
done
echo "$(date '+%F %T') shutting down"
stop_children
exit "${rc}"
}
# ------------------------------------------------------------------ lifecycle
cmd_start() {
need_root start; need_install
if svc_running "${PREFIX}" "${NETX_DATA}" && netx_admin health "$(health_url)"; then
ok "$(t "NetX 已在运行" "NetX already running"): http://$(primary_ip):$(api_port)/"
return 0
fi
info "$(t "启动 NetX" "Starting NetX")"
svc_start "${PREFIX}" "${NETX_DATA}"
wait_healthy 120
ok "$(t "已启动" "Started"): http://$(primary_ip):$(api_port)/"
}
cmd_stop() {
need_root stop; need_install
info "$(t "停止 NetX" "Stopping NetX")"
svc_stop "${PREFIX}" "${NETX_DATA}"
ok "$(t "已停止" "Stopped")"
}
cmd_restart() { need_root restart; cmd_stop; cmd_start; }
cmd_status() {
need_install
local state="stopped" svc="nohup"
svc_unit_installed && svc="systemd"
if svc_running "${PREFIX}" "${NETX_DATA}"; then state="running"; fi
local health="down"
netx_admin health "$(health_url)" && health="ok"
echo "version : $(cur_version)"
echo "service : ${svc} (${state})"
echo "health : ${health} $(health_url)"
echo "listen : $(api_bind):$(api_port)"
echo "db mode : $(db_mode)$( [[ "$(db_mode)" == bundled ]] && echo " (127.0.0.1:$(pg_port))")"
echo "prefix : ${PREFIX}"
echo "data : ${NETX_DATA}"
if have_systemd && systemctl list-unit-files netx-update.timer >/dev/null 2>&1 && systemctl is-enabled --quiet netx-update.timer 2>/dev/null; then
echo "auto-update : on"
elif [[ -f /etc/cron.d/netx-update ]]; then
echo "auto-update : on (cron)"
else
echo "auto-update : off"
fi
[[ "${health}" == "ok" ]]
}
cmd_logs() {
local name="api" follow=0 lines=200
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--follow) follow=1; shift ;;
-n) lines="${2:-200}"; shift 2 ;;
*) name="$1"; shift ;;
esac
done
local f="${NETX_LOG_DIR}/${name}.log"
[[ -f "${f}" ]] || die "log_not_found: ${f}"
if [[ "${follow}" == "1" ]]; then tail -n "${lines}" -F "${f}"; else tail -n "${lines}" "${f}"; fi
}
cmd_version() { cur_version; }
# ------------------------------------------------------------------ ports
# apply_env_change <description> KEY=VAL... ; restarts if running, rolls back on failure.
apply_env_change() {
local what="$1"
shift
local was=0
svc_running "${PREFIX}" "${NETX_DATA}" && was=1
cp -p "${ENV_FILE}" "${ENV_FILE}.bak"
[[ "${was}" == "1" ]] && svc_stop "${PREFIX}" "${NETX_DATA}"
if [[ -n "${PG_CONF_CHANGE:-}" ]]; then
cp -p "${PGDATA_DIR}/postgresql.conf" "${PGDATA_DIR}/postgresql.conf.bak"
netx_admin pg-conf "${PGDATA_DIR}/postgresql.conf" --set "${PG_CONF_CHANGE}"
fi
netx_admin env-set "${ENV_FILE}" "$@"
if [[ "${was}" == "1" ]]; then
svc_start "${PREFIX}" "${NETX_DATA}"
if ! wait_healthy 120; then
warn "$(t "启动失败,恢复原配置" "start failed; restoring previous settings")"
svc_stop "${PREFIX}" "${NETX_DATA}"
mv -f "${ENV_FILE}.bak" "${ENV_FILE}"
[[ -n "${PG_CONF_CHANGE:-}" ]] && mv -f "${PGDATA_DIR}/postgresql.conf.bak" "${PGDATA_DIR}/postgresql.conf"
svc_start "${PREFIX}" "${NETX_DATA}"
die "${what}_failed"
fi
fi
rm -f "${ENV_FILE}.bak" "${PGDATA_DIR}/postgresql.conf.bak"
return 0
}
firewall_hint() {
local p="$1"
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
echo " $(t "防火墙放行" "open firewall"): firewall-cmd --permanent --add-port=${p}/tcp && firewall-cmd --reload"
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
echo " $(t "防火墙放行" "open firewall"): ufw allow ${p}/tcp"
fi
}
cmd_set_port() {
need_root set-port; need_install
local p
p="$(netx_admin valid-port "${1:-}")" || exit 1
[[ "${p}" == "$(api_port)" ]] && { ok "$(t "端口未变" "port unchanged"): ${p}"; return 0; }
netx_admin port-free "${p}" --host "$(api_bind)" || die "port_in_use: ${p}"
apply_env_change set_port "NETX_PORT=${p}"
ok "$(t "Web/API 端口已改为" "Web/API port is now") ${p}: http://$(primary_ip):${p}/"
firewall_hint "${p}"
}
cmd_set_bind() {
need_root set-bind; need_install
local h="${1:-}"
[[ -n "${h}" ]] || die "usage: netxctl set-bind <0.0.0.0|127.0.0.1|ip>"
local pairs=("NETX_HOST=${h}")
# Same prerequisites NetX enforces for a non-loopback bind (security_bootstrap.py).
if ! is_loopback "${h}"; then
[[ -n "$(cfg NETX_BOOTSTRAP_ADMIN_PASSWORD)" && "$(cfg NETX_BOOTSTRAP_ADMIN_PASSWORD)" != "admin123" ]] ||
pairs+=("NETX_BOOTSTRAP_ADMIN_PASSWORD=$(netx_admin gen-secret password)")
[[ -n "$(cfg NETX_UME_VERIFY_TLS)" ]] || pairs+=("NETX_UME_VERIFY_TLS=true")
fi
apply_env_change set_bind "${pairs[@]}"
ok "$(t "监听地址已改为" "listen address is now") ${h}:$(api_port)"
}
cmd_set_pg_port() {
need_root set-pg-port; need_install
[[ "$(db_mode)" == "bundled" ]] || die "$(t "当前是外置数据库,请用 netxctl reconfigure-db" "external database in use; run netxctl reconfigure-db")"
local p old url
p="$(netx_admin valid-port "${1:-}")" || exit 1
old="$(pg_port)"
[[ "${p}" == "${old}" ]] && { ok "$(t "端口未变" "port unchanged"): ${p}"; return 0; }
netx_admin port-free "${p}" --host 127.0.0.1 || die "port_in_use: 127.0.0.1:${p}"
url="$(netx_admin url-port "$(cfg NETX_DATABASE_URL)" "${p}")"
PG_CONF_CHANGE="port=${p}" apply_env_change set_pg_port "NETX_BUNDLED_PG_PORT=${p}" "NETX_DATABASE_URL=${url}"
ok "$(t "内置 PostgreSQL 端口已改为" "bundled PostgreSQL port is now") 127.0.0.1:${p}"
}
cmd_reconfigure_db() {
need_root reconfigure-db; need_install
exec "${CUR}/install.sh" --mode reinstall --prefix "${PREFIX}" --data "${NETX_DATA}" "$@"
}
# ------------------------------------------------------------------ update
cmd_check_update() {
need_install
local out
out="$(netx_admin check-update --env "${ENV_FILE}" --prefix "${PREFIX}" --arch "${ARCH}")" || exit $?
local f="${NETX_DATA}/updates/latest.json"
mkdir -p "${NETX_DATA}/updates"
printf '%s\n' "${out}" >"${f}"
local latest avail src
latest="$(netx_admin json-get "${f}" latest)"
avail="$(netx_admin json-get "${f}" update_available)"
src="$(netx_admin json-get "${f}" source)"
echo "$(t "当前版本" "current"): $(cur_version)"
echo "$(t "最新版本" "latest") : ${latest} (${src})"
if [[ "${avail}" == "True" ]]; then
ok "$(t "有新版本,执行 sudo netxctl update 升级" "update available: sudo netxctl update")"
return 0
fi
ok "$(t "已是最新版本" "already up to date")"
}
cmd_update() {
need_root update; need_install
local yes=0 file="" force=0 quiet=0
while [[ $# -gt 0 ]]; do
case "$1" in
-y|--yes) yes=1; shift ;;
--file) file="${2:-}"; shift 2 ;;
--force) force=1; shift ;;
--quiet) quiet=1; shift ;;
*) die "unknown option: $1" ;;
esac
done
local work="${NETX_DATA}/updates/work" pkg
if [[ -n "${file}" ]]; then
pkg="$(netx_admin extract "$(readlink -f "${file}")" --workdir "${work}" --arch "${ARCH}")" || exit 1
else
local f="${NETX_DATA}/updates/latest.json" out
mkdir -p "${NETX_DATA}/updates"
if [[ "${quiet}" == "1" ]]; then
out="$(netx_admin check-update --env "${ENV_FILE}" --prefix "${PREFIX}" --arch "${ARCH}" --quiet)" || exit $?
else
out="$(netx_admin check-update --env "${ENV_FILE}" --prefix "${PREFIX}" --arch "${ARCH}")" || exit $?
fi
printf '%s\n' "${out}" >"${f}"
if [[ "$(netx_admin json-get "${f}" update_available)" != "True" && "${force}" != "1" ]]; then
[[ "${quiet}" == "1" ]] || ok "$(t "已是最新版本" "already up to date") ($(cur_version))"
return 0
fi
local latest
latest="$(netx_admin json-get "${f}" latest)"
if [[ "${yes}" != "1" ]]; then
local ans
read -r -p "$(t "升级 $(cur_version) → ${latest}?[y/N] " "Update $(cur_version) -> ${latest}? [y/N] ")" ans
[[ "${ans}" =~ ^[Yy] ]] || { echo "$(t "已取消" "cancelled")"; return 1; }
fi
pkg="$(netx_admin fetch-update --env "${ENV_FILE}" --info "${out}" --workdir "${work}" --arch "${ARCH}")" || exit 1
fi
local newver cmp
newver="$(netx_admin json-get "${pkg}/version.json" version)"
cmp="$(netx_admin ver-cmp "${newver}" "$(cur_version)")"
if [[ "${cmp}" != "1" && "${force}" != "1" ]]; then
rm -rf "${work}"
die "$(t "包版本 ${newver} 不高于当前 $(cur_version);如确需重装加 --force" "package ${newver} is not newer than $(cur_version); use --force to reinstall it")"
fi
local rc=0
"${pkg}/install.sh" --mode update --yes --prefix "${PREFIX}" --data "${NETX_DATA}" || rc=$?
rm -rf "${work}"
return "${rc}"
}
cmd_auto_update() {
need_root auto-update; need_install
case "${1:-}" in
on)
if have_systemd; then
sed -e "s#@PREFIX@#${PREFIX}#g" "${CUR}/packaging/linux/systemd/netx-update.service.in" >/etc/systemd/system/netx-update.service
cp "${CUR}/packaging/linux/systemd/netx-update.timer" /etc/systemd/system/netx-update.timer
systemctl daemon-reload
systemctl enable --now netx-update.timer
elif [[ -d /etc/cron.d ]]; then
printf '30 3 * * * root %s update --yes --quiet >>%s/logs/update.log 2>&1\n' "${PREFIX}/current/packaging/linux/netxctl" "${NETX_DATA}" >/etc/cron.d/netx-update
else
die "$(t "系统既没有 systemd 也没有 cron,无法自动更新" "neither systemd nor cron available")"
fi
ok "$(t "已开启每日自动更新" "daily auto-update enabled")"
;;
off)
if have_systemd; then
systemctl disable --now netx-update.timer 2>/dev/null || true
rm -f /etc/systemd/system/netx-update.timer /etc/systemd/system/netx-update.service
systemctl daemon-reload
fi
rm -f /etc/cron.d/netx-update
ok "$(t "已关闭自动更新" "auto-update disabled")"
;;
*) die "usage: netxctl auto-update on|off" ;;
esac
}
# ------------------------------------------------------------------ backup / uninstall
cmd_backup() {
need_root backup; need_install
local out="${1:-${NETX_DATA}/backups/netx-backup-$(date +%Y%m%d-%H%M%S).tar.gz}"
local tmp started=0
tmp="$(mktemp -d)"
chmod 0755 "${tmp}"
chown "${NETX_USER}:${NETX_USER}" "${tmp}"
local url
url="$(cfg NETX_DATABASE_URL)"
if [[ "$(db_mode)" == "bundled" ]] && ! pg_is_running; then
pg_start && started=1
fi
info "pg_dump → ${out}"
if ! as_netx env PGPASSWORD="$(netx_admin url-part "${url}" password)" LC_ALL=C "${PGBIN}/pg_dump" -Fc \
-h "$(netx_admin url-part "${url}" host)" -p "$(netx_admin url-part "${url}" port)" \
-U "$(netx_admin url-part "${url}" user)" -d "$(netx_admin url-part "${url}" db)" -f "${tmp}/netx.dump"; then
[[ "${started}" == "1" ]] && pg_stop
rm -rf "${tmp}"
die "pg_dump_failed"
fi
[[ "${started}" == "1" ]] && pg_stop
cp -p "${ENV_FILE}" "${tmp}/env"
[[ -d "${NETX_DATA}/data/auth" ]] && cp -rp "${NETX_DATA}/data/auth" "${tmp}/auth"
netx_admin tar-create "${out}" "${tmp}"
rm -rf "${tmp}"
ok "$(t "备份完成(含 .env 与密钥,请妥善保管)" "backup written (contains .env and secrets)"): ${out}"
}
cmd_uninstall() {
need_root uninstall
local purge=0 yes=0
while [[ $# -gt 0 ]]; do
case "$1" in
--purge) purge=1; shift ;;
-y|--yes) yes=1; shift ;;
*) die "unknown option: $1" ;;
esac
done
if [[ "${yes}" != "1" ]]; then
local ans
if [[ "${purge}" == "1" ]]; then
read -r -p "$(t "将删除程序和全部数据(${NETX_DATA}),输入 DELETE 确认: " "This removes NetX AND all data in ${NETX_DATA}. Type DELETE: ")" ans
[[ "${ans}" == "DELETE" ]] || { echo "$(t "已取消" "cancelled")"; return 1; }
else
read -r -p "$(t "卸载程序(保留 ${NETX_DATA})?[y/N] " "Uninstall NetX (keep ${NETX_DATA})? [y/N] ")" ans
[[ "${ans}" =~ ^[Yy] ]] || { echo "$(t "已取消" "cancelled")"; return 1; }
fi
fi
[[ -x "${NETX_PY}" ]] && svc_stop "${PREFIX}" "${NETX_DATA}"
if have_systemd; then
systemctl disable --now netx-update.timer 2>/dev/null || true
systemctl disable netx.service 2>/dev/null || true
rm -f /etc/systemd/system/netx.service /etc/systemd/system/netx-update.service /etc/systemd/system/netx-update.timer
systemctl daemon-reload || true
fi
rm -f /etc/cron.d/netx-update /etc/logrotate.d/netx
local link
for link in /usr/bin/netxctl /usr/local/bin/netxctl; do
if [[ -L "${link}" && "$(readlink "${link}")" == "${PREFIX}"/* ]]; then rm -f "${link}"; fi
done
# Run from a copy-safe place: this script lives under PREFIX.
cd /
rm -rf "${PREFIX:?}/releases" "${PREFIX:?}/current" "${PREFIX:?}/netx.conf"
rmdir "${PREFIX}" 2>/dev/null || true
if [[ "${purge}" == "1" ]]; then
rm -rf "${NETX_DATA:?}"
if id "${NETX_USER}" >/dev/null 2>&1; then
userdel "${NETX_USER}" 2>/dev/null || deluser "${NETX_USER}" 2>/dev/null || true
fi
ok "$(t "已卸载并删除全部数据" "NetX and all data removed")"
else
ok "$(t "已卸载,数据保留在" "NetX removed; data kept in") ${NETX_DATA}"
fi
}
# ------------------------------------------------------------------ main
cmd="${1:-}"
[[ $# -gt 0 ]] && shift
case "${cmd}" in
run) cmd_run ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs "$@" ;;
set-port) cmd_set_port "$@" ;;
set-bind) cmd_set_bind "$@" ;;
set-pg-port) cmd_set_pg_port "$@" ;;
reconfigure-db) cmd_reconfigure_db "$@" ;;
check-update) cmd_check_update ;;
update) cmd_update "$@" ;;
auto-update) cmd_auto_update "$@" ;;
backup) cmd_backup "$@" ;;
uninstall) cmd_uninstall "$@" ;;
version|--version|-V) cmd_version ;;
""|-h|--help|help) usage ;;
*) err "unknown command: ${cmd}"; usage; exit 2 ;;
esac

109
packaging/linux/smoke_test.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Offline install smoke test, run inside a bare distro container as root:
# docker run --rm --network none -v "$PWD/packaging/release:/pkg:ro" \
# -v "$PWD/packaging/linux/smoke_test.sh:/smoke.sh:ro" centos:7 bash /smoke.sh /pkg/netx-X-linux-x86_64.tar.gz
# Exercises: install (custom ports) -> login -> set-port -> set-pg-port -> update
# (same package, with backup) -> data survives -> stop -> uninstall --purge.
set -euo pipefail
TARBALL="$1"
PKGDIR="${2:-}" # pre-extracted package (minimal images may lack tar)
PORT1=18890 PORT2=18891 PG1=25432 PG2=25433
step() { printf '\n\033[36m### %s\033[0m\n' "$*"; }
fail() { printf '\033[31m[SMOKE FAIL] %s\033[0m\n' "$*" >&2; tail -n 60 /var/lib/netx/logs/*.log 2>/dev/null >&2 || true; exit 1; }
PY() { /opt/netx/current/python/bin/python3 -I -c "$@"; }
login() { # login <port> -> prints access token
PY "
import json, urllib.request
req = urllib.request.Request('http://127.0.0.1:$1/v1/auth/login',
data=json.dumps({'username': 'admin', 'password': open('/var/lib/netx/.env').read().split('NETX_BOOTSTRAP_ADMIN_PASSWORD=')[1].split()[0]}).encode(),
headers={'Content-Type': 'application/json'})
import time
for attempt in range(3):
try:
body = json.load(urllib.request.urlopen(req, timeout=60))
break
except Exception:
if attempt == 2:
raise
time.sleep(5)
print(body.get('access_token') or body['token'])
"
}
health() { PY "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:$1/health', timeout=5).status == 200 else 1)" 2>/dev/null; }
listening() { # listening <port> -> 0 when something accepts on 127.0.0.1:<port>
PY "import socket,sys; s=socket.socket(); s.settimeout(1); sys.exit(s.connect_ex(('127.0.0.1', $1)))"
}
step "environment: $(. /etc/os-release && echo "${PRETTY_NAME}") / $(getconf GNU_LIBC_VERSION)"
if [[ -n "${PKGDIR}" ]]; then
work="$(mktemp -d)"
cp -a "${PKGDIR}" "${work}/"
else
work="$(mktemp -d)"
tar xzf "${TARBALL}" -C "${work}"
fi
pkg="$(echo "${work}"/netx-*)"
step "install (bundled PG ${PG1}, API ${PORT1})"
"${pkg}/install.sh" --yes --no-service --db bundled --port "${PORT1}" --pg-port "${PG1}" || fail "install"
health "${PORT1}" || fail "health after install"
listening "${PG1}" || fail "PG not on ${PG1}"
token="$(login "${PORT1}")" || fail "login"
[[ -n "${token}" ]] || fail "empty token"
netxctl status || fail "status"
step "marker row survives update"
PGPASSWORD="$(grep '^NETX_DATABASE_URL=' /var/lib/netx/.env | sed 's#.*://netx:\([^@]*\)@.*#\1#')" \
/opt/netx/current/pgsql/bin/psql -h 127.0.0.1 -p "${PG1}" -U netx -d netx -v ON_ERROR_STOP=1 \
-c "CREATE TABLE IF NOT EXISTS smoke_marker(v text); INSERT INTO smoke_marker VALUES ('kept');" || fail "marker"
step "set-port ${PORT2}"
netxctl set-port "${PORT2}" || fail "set-port"
health "${PORT2}" || fail "health after set-port"
step "set-pg-port ${PG2}"
netxctl set-pg-port "${PG2}" || fail "set-pg-port"
health "${PORT2}" || fail "health after set-pg-port"
listening "${PG2}" || fail "PG not on ${PG2}"
listening "${PG1}" && fail "PG still on ${PG1}"
step "set-pg-port to a busy port is refused"
if netxctl set-pg-port "${PORT2}"; then fail "busy port accepted"; fi
health "${PORT2}" || fail "health after refused port change"
step "update with the same package (--force path via install.sh --mode update)"
"${pkg}/install.sh" --mode update --yes || fail "update"
health "${PORT2}" || fail "health after update"
ls /var/lib/netx/backups/pre-*.dump >/dev/null || fail "no pre-update dump"
out="$(PGPASSWORD="$(grep '^NETX_DATABASE_URL=' /var/lib/netx/.env | sed 's#.*://netx:\([^@]*\)@.*#\1#')" \
/opt/netx/current/pgsql/bin/psql -h 127.0.0.1 -p "${PG2}" -U netx -d netx -tAc "SELECT v FROM smoke_marker")"
[[ "${out}" == "kept" ]] || fail "marker lost: ${out}"
login "${PORT2}" >/dev/null || fail "login after update"
step "offline update via netxctl update --file (same version needs --force)"
if netxctl update --file "${TARBALL}" --yes; then fail "same-version update without --force accepted"; fi
netxctl update --file "${TARBALL}" --yes --force || fail "netxctl update --file --force"
health "${PORT2}" || fail "health after netxctl update"
step "backup"
netxctl backup /tmp/netx-backup.tar.gz || fail "backup"
netxctl_admin_list="$(/opt/netx/current/python/bin/python3 -I /opt/netx/current/packaging/linux/netx_admin.py tar-list /tmp/netx-backup.tar.gz)"
[[ "${netxctl_admin_list}" == *netx.dump* ]] || fail "backup content"
step "stop"
netxctl stop || fail "stop"
health "${PORT2}" && fail "still healthy after stop"
listening "${PG2}" && fail "PG still running after stop"
pgrep -f netx_api >/dev/null 2>&1 && fail "netx_api processes left" || true
step "start again + uninstall --purge"
netxctl start || fail "restart"
netxctl uninstall --purge --yes || fail "uninstall"
[[ ! -e /opt/netx && ! -e /var/lib/netx && ! -e /usr/bin/netxctl ]] || fail "leftovers after purge"
printf '\n\033[32m[SMOKE OK] %s\033[0m\n' "$(. /etc/os-release && echo "${PRETTY_NAME}")"

View file

@ -0,0 +1,8 @@
[Unit]
Description=NetX automatic update
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=@PREFIX@/current/packaging/linux/netxctl update --yes --quiet

View file

@ -0,0 +1,10 @@
[Unit]
Description=Daily NetX update check
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,20 @@
[Unit]
Description=NetX network operations platform
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=@USER@
Group=@USER@
ExecStart=@PREFIX@/current/packaging/linux/netxctl run
# SIGTERM goes to the supervisor, which stops workers and the bundled PostgreSQL.
KillMode=mixed
TimeoutStopSec=120
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
UMask=0027
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,16 @@
# Pinned third-party inputs for the Linux release (sourced by build scripts).
# PostgreSQL major must match the Windows bundle (packaging/cache/pgsql-16.x).
PG_VERSION=16.4
PG_MAJOR=16
PG_URL=https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.bz2
PG_SHA256=971766d645aa73e93b9ef4e3be44201b4f45b5477095b049125403f9f3386d6f
# python-build-standalone: glibc 2.17 baseline on x86_64-unknown-linux-gnu.
PY_VERSION=3.12.7
PY_URL=https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz
PY_SHA256=3a4d53a7ba3916c0c1f35cbbe57068e2571b138389f29cf5c35367fec8f4c617
# Build container: CentOS 7 userland => everything compiled or pip-installed
# links against glibc <= 2.17.
BUILD_IMAGE=quay.io/pypa/manylinux2014_x86_64
MAX_GLIBC=2.17