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

1
.gitattributes vendored
View file

@ -1 +1,2 @@
*.sh text eol=lf
packaging/linux/** text eol=lf

92
.github/workflows/release-linux.yml vendored Normal file
View file

@ -0,0 +1,92 @@
name: Release Linux
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Check tag matches pyproject version
if: startsWith(github.ref, 'refs/tags/')
run: |
ver="$(sed -n 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' pyproject.toml | head -n1)"
[ "${GITHUB_REF_NAME}" = "v${ver}" ] || { echo "tag ${GITHUB_REF_NAME} != v${ver}"; exit 1; }
- name: Cache PostgreSQL build + downloads
uses: actions/cache@v4
with:
path: packaging/cache/linux
key: linux-x86_64-${{ hashFiles('packaging/linux/versions.env', 'packaging/linux/build_in_container.sh') }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
linux-x86_64-${{ hashFiles('packaging/linux/versions.env', 'packaging/linux/build_in_container.sh') }}-
- name: Build package
run: bash packaging/linux/build_linux_release.sh
- uses: actions/upload-artifact@v4
with:
name: netx-linux-x86_64
path: |
packaging/release/netx-*-linux-x86_64.tar.gz
packaging/release/netx-*-linux-x86_64.tar.gz.sha256
retention-days: 7
smoke:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
image:
- centos:7
- rockylinux:9
- ubuntu:18.04
- ubuntu:24.04
- debian:12
- opensuse/leap:15
- openeuler/openeuler:22.03
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: netx-linux-x86_64
path: pkg
- name: Offline install smoke (${{ matrix.image }})
run: |
tarball="$(ls pkg/netx-*-linux-x86_64.tar.gz)"
# Minimal images may not ship tar: unpack on the runner and mount both.
mkdir -p pkgx && tar xzf "$tarball" -C pkgx
docker run --rm --network none \
-v "$PWD/pkg:/pkg:ro" -v "$PWD/pkgx:/pkgx:ro" \
-v "$PWD/packaging/linux/smoke_test.sh:/smoke.sh:ro" \
"${{ matrix.image }}" bash /smoke.sh "/pkg/$(basename "$tarball")" "/pkgx/$(ls pkgx)"
release:
needs: smoke
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: netx-linux-x86_64
path: pkg
# Release notes come from the Windows workflow; only attach files here.
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
pkg/netx-*-linux-x86_64.tar.gz
pkg/netx-*-linux-x86_64.tar.gz.sha256

View file

@ -0,0 +1,288 @@
# NetX Linux 发行版实施计划(2026-10-14)
> 修订(同日):用户确认**只出 x86_64**;**API/Web 端口也必须可自选**(服务器上服务多,避免冲突)。下文已按此调整;由 Claude 直接实现。
## 0. 目标与已确认的需求
| # | 需求(用户确认) | 落地方式 |
|---|---|---|
| 1 | 覆盖尽量多的 Linux 发行版 | 以 **glibc ≥ 2.17** 为基线(CentOS 7 级别),只出 **x86_64** 包 |
| 2 | tar.gz + install.sh;离线安装,目标机上**什么都不用装** | 包内自带 Python 3.12、全部依赖、PostgreSQL 16 和 web/dist;目标机只需要 bash、tar、gzip、coreutils 和 useradd,这些是任何 glibc 发行版的最小系统都有的 |
| 3 | 在线更新 | `netxctl update`,更新源与 Windows 版一致(GitHub / Forgejo / manifest),可选每日自动更新(systemd timer);失败自动回滚程序 |
| 4a | API/Web 端口可配置 | 安装时 `--port` 或交互输入(检查占用,被占用时重新输入);装好后用 `netxctl set-port`,失败自动回滚 |
| 4b | 内置 PG 端口可配置 | Linux:安装时 `--pg-port` 或交互输入,装好后用 `netxctl set-pg-port`;**Windows 同步补齐**:Setup 数据库页加端口框,静默参数 `/BundledPgPort=`,另提供改端口脚本 |
现状:后端是纯 Python,依赖都有 manylinux2014 wheel,[host_metrics.py](../../netx_api/host_metrics.py) 已有 Linux 分支。所以 **`netx_api` 业务代码不需要改**,工作量都在 `packaging/linux/`、CI 和 Windows 的端口配置上。`scripts/start_netx.sh` / `stop_netx.sh` 保留,继续作为源码/开发模式的启动方式,不动。
### 支持范围
- **支持**(glibc ≥ 2.17,x86_64):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 等。
- **不支持(第一期)**:ARM(鲲鹏/飞腾)、Alpine 等 musl 系统、32 位系统、CentOS 6(glibc 2.12)、龙芯 LoongArch / MIPS(没有现成的 standalone Python,后续单独评估)。
- 有没有 systemd 都能用:有 systemd 就注册服务;没有(容器、精简系统)就用 `netxctl start/stop` 后台运行。
---
## 1. 产物与目录布局
### 1.1 发布产物(与 Windows Setup 挂在同一个 GitHub/Forgejo Release 下)
```
netx-<ver>-linux-x86_64.tar.gz + .sha256
```
解压后:
```
netx-<ver>-linux-x86_64/
install.sh # 入口:安装 / 更新 / 重装
version.json # {"version": "...", "arch": "x86_64", "pg_major": 16, "python": "3.12.x"}
netx_api/ alembic/ alembic.ini pyproject.toml requirements.txt README.md LICENSE
web/dist/ # 由 API 托管,不需要 Node
python/ # python-build-standalone 3.12,依赖直接装在它的 site-packages 里(不用 venv)
pgsql/{bin,lib,share} # PostgreSQL 16 只保留运行需要的部分;rpath=$ORIGIN/../lib
packaging/linux/ # netxctl、netx_admin.py、systemd 单元模板、logrotate、README
```
### 1.2 安装后的目录
| 路径 | 内容 | 属主 / 权限 |
|---|---|---|
| `/opt/netx/releases/<ver>/` | 某个版本的程序(只读) | root:root 0755 |
| `/opt/netx/current` → `releases/<ver>` | 当前版本的软链,切换是原子的 | |
| `/usr/bin/netxctl` → `/opt/netx/current/packaging/linux/netxctl` | 管理命令 | |
| `/var/lib/netx/` | `.env`、`pgdata/`、`data/…`(与 Windows 的 `Get-NetxDataEnvMap` 子目录一致)、`backups/`、`logs/` | netx:netx 0750,`.env` 0600 |
| `/etc/systemd/system/netx.service`、`netx-update.{service,timer}` | 服务单元 | |
`--prefix` 和 `--data` 都可以改。始终只保留最近 2 个 release 目录,用于回滚。
---
## 2. 任务清单
### L1 构建:PostgreSQL 16 可移植二进制(glibc 2.17)
- 新文件 `packaging/linux/build_pg.sh <arch>`:在 `quay.io/pypa/manylinux2014_<arch>` 容器里从源码编译 PostgreSQL 16.x,**和 Windows 用同一个小版本**(现在是 16.4,见 `packaging/cache/pgsql-16.4-1`)。
- `./configure --prefix=/pgsql --without-readline --without-icu --with-zlib --without-openssl LDFLAGS='-Wl,-rpath,$ORIGIN/../lib'`
- 把 `libz.so.1` 复制进 `pgsql/lib/`,避免最小系统里没有 zlib。
- 只保留运行需要的二进制:`postgres pg_ctl initdb psql pg_dump pg_dumpall pg_restore pg_isready`(与 [build_release.ps1:163](../../packaging/build_release.ps1:163) 一致),外加 `lib/`、`share/`。
- 打包成 `pg-16.x-linux-<arch>.tar.gz`;CI 按版本+架构缓存,本地放 `packaging/cache/`。
- 版本号和下载校验值统一写在 `packaging/linux/versions.env`(PG 源码 URL+sha256,python-build-standalone URL+sha256)。
**验收**:在 `centos:7` 容器里,`pgsql/bin/initdb` / `pg_ctl start` / `psql -c 'select version()'` 都能跑;`L4` 的检查脚本通过。
### L2 构建:Python 运行时 + 依赖
- `packaging/linux/build_python.sh <arch>`:下载 python-build-standalone `cpython-3.12.x+<date>-<arch>-unknown-linux-gnu-install_only_stripped.tar.gz`(pin 住版本并校验 sha256)。
- **在 manylinux2014 容器里**执行 `python/bin/python3 -m pip install --only-binary=:all: -r requirements.txt`。容器的 glibc 是 2.17,pip 只会选兼容的 wheel;任何依赖要编译或要求更高的 glibc,构建就会直接失败。
- 删掉 `test/`、`idlelib/`、`tkinter/`、`ensurepip/`,再执行 `compileall -q`,这样 `/opt` 只读也不会在运行时写 pyc。
- 不用 venv(standalone Python 本身可以搬动,venv 的软链和绝对路径反而碍事)。所有启动都用 `python/bin/python3 -m …`,不依赖 `bin/` 下脚本的 shebang。
**验收**:在 `centos:7` 和 `ubuntu:24.04` 里,`python/bin/python3 -c "import netx_api.main, psycopg, cryptography, netmiko, bcrypt, psutil"` 都成功。
### L3 构建:打包脚本 `packaging/linux/build_linux_release.sh`
- 参数:`--version`(默认读 pyproject)、`--web-dist <path>`、`--skip-web-build`。
- 流程:先检查 web/dist(没有就执行 `npm ci && npm run build:release`),调用 L1/L2,拷贝程序文件(清单与 [build_release.ps1:88-93](../../packaging/build_release.ps1:88) 一致),写 `version.json`,执行 `tar czf` 并生成 `.sha256`,最后调用 L4 检查。
- 依赖 docker(manylinux 容器)。本机是 Windows,所以本地构建要在 WSL2 里的 Ubuntu+docker 上做,或者用一台 Linux 构建机。**CI 是主要的构建途径**。
- 文件:`packaging/linux/README.md`,写清楚构建、安装、运维、支持范围。同时把 [packaging/README.md](../../packaging/README.md) 第 3 行"Linux is unchanged"改成指向 Linux 发行版的说明。
### L4 兼容性闸门 `packaging/linux/check_bundle.sh`
- 用 `objdump -T` 扫描包里所有 ELF(`python/`、`site-packages/**/*.so`、`pgsql/`),引用的 `GLIBC_x.y` 最高不能超过 **2.17**。
- 用 `readelf -d` 检查 NEEDED:除了 `libc/libm/libdl/libpthread/librt/libutil/libcrypt/ld-linux*/libgcc_s/libstdc++` 以外,都必须能在包内找到。
- 不通过就让构建失败。
### L5 `install.sh`(安装 / 更新 / 重装)
只依赖 bash、coreutils、tar、gzip、useradd(或 `adduser`)。凡是复杂逻辑(改 .env、检查端口、URL 编码、生成密钥)都交给**包里自带的 Python**(`packaging/linux/netx_admin.py`,只用标准库),不依赖 curl、ss、openssl 这些。
参数(交互模式下会逐项提问;中文还是英文按 `$LANG` 判断;`--yes` 为非交互):
```
--mode install|update|reinstall # 已有安装时默认 update
--prefix /opt/netx --data /var/lib/netx
--db bundled|external
--pg-port 15432 # 内置 PG 端口(需求 4)
--pg-password <pw> # 不填则自动生成
--db-url postgresql+psycopg://… # external 用;也可以拆成 --db-host/--db-port/--db-user/--db-password/--db-name
--bind 0.0.0.0 --port 8890 # Linux 一般是无桌面的服务器,默认绑 0.0.0.0(Windows 是 127.0.0.1)
--credential-key-file <path> # 复用别的安装的 NE 密码加密密钥(对应 Windows 的 /CredentialSecretKey)
--no-service # 不注册 systemd 服务
--auto-update # 打开每日自动更新(默认关闭,与 Windows 一致)
--open-firewall # 有 firewall-cmd / ufw 时放行 --port;默认只打印提示
--no-backup # 更新前不做 pg_dump
```
预检(失败时给出中英文提示并退出,不留下半装的状态):
1. 必须是 root;
2. 架构和包一致;`getconf GNU_LIBC_VERSION` ≥ 2.17;检测到 musl 就拒绝;
3. 磁盘空间够(≥ 1 GB);
4. 用自带 Python 检查 `--port`、`--pg-port` 是否空闲(被占用时交互模式重新问,`--yes` 模式直接报 `port_in_use`);
5. external 模式用 `psql SELECT 1` 测试连接(对应 Windows 的行为)。
**首次安装**的步骤:
1. 创建系统用户 `netx`(`useradd -r -s /sbin/nologin -d /var/lib/netx`;没有 useradd 就退到 `adduser -S`);
2. 解压到 `releases/<ver>`,切换 `current` 软链,建立 `netxctl` 链接;
3. 创建数据目录、写 `.env`(键与 Windows [setup_first_run.ps1](../../packaging/setup_first_run.ps1) 一致:`NETX_DB_MODE`、`NETX_HOST`、`NETX_PORT`、`NETX_UI_DIST_DIR`、`NETX_BUNDLED_PG_PORT`、`NETX_DATABASE_URL`、`Get-NetxDataEnvMap` 里的路径、凭据密钥);
4. 内置 PG:用 `netx` 用户执行 `initdb -U postgres -A scram-sha-256 -E UTF8 --locale=C`,在 `postgresql.conf` 写入 `port = <pg-port>` 和 `listen_addresses = '127.0.0.1'`,`pg_hba` 只允许本机,然后创建 `netx` 角色和库(照搬 Windows 的 bootstrap SQL);
5. 如果系统有 SELinux 并且有 `restorecon`,执行 `restorecon -R <prefix>`;
6. 有 systemd(`[ -d /run/systemd/system ]`)就安装 `netx.service` 并 `enable --now`;没有就执行 `netxctl start`;
7. 等待 `/health` 返回正常(最多 90 秒),最后打印访问地址、首次登录说明和防火墙提示。
**更新**(`--mode update`,也是在线更新最后调用的入口):
1. 解压到 `releases/<newver>`(版本已存在就覆盖);
2. 内置 PG 时做 `pg_dump -Fc` 到 `backups/pre-<newver>-<ts>.dump`,只保留最近 3 份;
3. 停服务,切换 `current` 软链,启动,等待 `/health`;
4. 如果没有起来:软链切回旧版本再启动,返回非 0,并提示备份文件的位置(数据库可能已经被 alembic 迁移过,所以只回滚程序,数据库靠这个 dump 恢复);
5. 成功后清理旧 release,只保留 2 个;
6. 不会改动 `.env` 和 PG 端口;systemd 单元按新版本的模板重新生成。
`--mode reinstall`:重新走数据库配置,但**不删除** `/var/lib/netx`(和 Windows 一样)。
### L6 `netxctl` 管理命令
bash 写的薄入口,复杂的子命令转交 `netx_admin.py`。以 root 调用时,需要降权的操作用 `runuser -u netx --`(没有 runuser 就用 `su -s /bin/sh netx -c`)。
```
netxctl run # 前台监督进程(systemd 的 ExecStart 就是它)
netxctl start|stop|restart|status
netxctl logs [-f] [api|worker|pg]
netxctl set-pg-port <port> # 需求 4
netxctl set-port <port> / set-bind <host>
netxctl reconfigure-db # 等于 install.sh --mode reinstall 里的数据库部分
netxctl check-update / update [--yes] [--file <tar.gz>]
netxctl auto-update on|off
netxctl backup [<file>] # pg_dump + data/auth 下的密钥,打成一个 tar.gz
netxctl uninstall [--purge] # --purge 删除数据,需要输入 "DELETE" 确认
netxctl version
```
`netxctl run`(前台监督进程,对应 Windows 的 [service_run.ps1](../../packaging/service_run.ps1)):
- 加载 `/var/lib/netx/.env`,并强制覆盖为数据目录下的路径;设置 `PYTHONPATH=current`、`NETX_UI_DIST_DIR`;
- 内置模式先执行 `pg_ctl -D pgdata -w start`。启动前检查 `pgdata/PG_VERSION` 的主版本与 `version.json` 里的 `pg_major` 是否一致,不一致就报 `pg_major_mismatch`,不擅自升级数据;
- 启动 API(`-m netx_api.main`)、worker(`-m netx_api.worker`)和 biz_state workers,副本数和参数照搬 [start_netx.sh:122-160](../../scripts/start_netx.sh:122),`NETX_RUN_INLINE_SCHEDULERS=false`;
- `trap TERM INT`:先停所有子进程,再 `pg_ctl stop -m fast`;
- 用 `wait -n` 等待:任何一个子进程退出就整体退出并返回非 0,由 systemd `Restart=on-failure` 重启;
- 日志写到 `/var/lib/netx/logs/*.log`,并安装 `/etc/logrotate.d/netx`(copytruncate,7 份)。
没有 systemd 时:`netxctl start` 执行 `nohup netxctl run &`,pid 写到 `data/runtime/netx.pid`;`stop` 发 TERM,30 秒后还没退出就 KILL。
`netx.service`:
```ini
[Unit]
Description=NetX
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netx
Group=netx
ExecStart=/opt/netx/current/packaging/linux/netxctl run
KillMode=mixed
TimeoutStopSec=60
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
```
### L7a API/Web 端口可配置
- `install.sh`:支持 `--port` 和 `--bind`;交互模式下默认 8890 / 0.0.0.0,会检查端口是否被占用(自带 Python 的 socket 检测),被占用时重新问,`--yes` 模式直接报 `port_in_use`。
- `netxctl set-port <port>` / `set-bind <host>`:校验 → 停服务 → 改 `.env` 里的 `NETX_PORT` / `NETX_HOST` → 启动 → 等 `/health`;失败就恢复 `.env` 并重新启动。改完后打印 firewalld/ufw 的放行命令。
- 重装(`--mode reinstall`)时,默认值取当前配置,并允许沿用自己正在占用的端口。
### L7 内置 PG 端口可配置(Linux 部分)
- `install.sh`:支持 `--pg-port`;交互模式默认值 15432,会检查是否被占用,被占用时重新问。
- `netxctl set-pg-port <port>`:校验范围在 1024–65535 且端口空闲 → 停服务 → 改 `postgresql.conf` 里的 `port` → 改 `.env` 里的 `NETX_BUNDLED_PG_PORT`,并把 `NETX_DATABASE_URL` 中的端口一并改掉(只换 host:port 部分,密码保持原样)→ 启动 → 等 `/health`;失败就把三处改回去。
- external 模式下执行这个命令会报错,提示改用 `reconfigure-db`。
### L8 在线更新 `packaging/linux/netx_update.py`(只用标准库)
- 更新源和配置项与 Windows [check_update.ps1](../../packaging/check_update.ps1) **完全一致**:`NETX_UPDATE_SOURCES`(默认 `github,forgejo`)、`NETX_UPDATE_GITHUB_REPO`、`NETX_UPDATE_FORGEJO_URL`、`NETX_UPDATE_URL`(manifest)、`NETX_UPDATE_TOKEN`;会探测所有源,选最新的那个;版本相同时优先 GitHub;token 只发给非 GitHub 的主机。
- 资产匹配规则:`^netx-(?P<ver>[\d.]+)-linux-<arch>\.tar\.gz$`;sha256 优先取 `<asset>.sha256` 资产,其次取 manifest 里的值,**两者都没有就拒绝更新**。
- 代理:遵循 `HTTPS_PROXY/NO_PROXY`(urllib 默认就支持)。
- 流程:下载到 `/var/lib/netx/updates/` → 校验 → 解压到临时目录 → 执行**新包里的** `install.sh --mode update --yes` → 清理临时文件。
- `netxctl update --file <tar.gz>`:离线升级,跳过下载这一步。
- 自动更新:`netx-update.timer`(`OnCalendar=*-*-* 03:30`,`RandomizedDelaySec=1h`,`Persistent=true`),执行 `netxctl update --yes --quiet`。没有 systemd 但有 crontab 时写到 `/etc/cron.d/netx-update`;两者都没有就提示不支持。
- [manifest.example.json](../../packaging/manifest.example.json) 增加:
```json
"linux": {
"x86_64": {"url": "...netx-0.4.19-linux-x86_64.tar.gz", "sha256": "", "size": 0},
}
```
- Windows 这边 [check_update.ps1:115](../../packaging/check_update.ps1:115) 只匹配 `Setup-.*\.exe`,同一个 Release 里出现 Linux 资产不受影响。需要加一条回归验证。
### L9 CI:`.github/workflows/release-linux.yml`
- 触发:`v*` tag(和 Windows 一致);同样校验 tag 必须等于 pyproject 里的版本。
- job `web`:Node 20,执行 `npm ci && npm run build:release`,上传 `web/dist` 作为 artifact。
- job `build`:`ubuntu-latest` + manylinux2014_x86_64 容器
- 步骤:缓存/构建 PG(L1),执行 L2、L3、L4,上传 tar.gz 和 sha256 artifact。
- job `smoke`(必须通过才发布),x86_64 在下面这些镜像里各跑一遍(`docker run --network none`,同时验证离线安装):
`centos:7`、`rockylinux:9`、`ubuntu:18.04`、`ubuntu:24.04`、`debian:12`、`opensuse/leap:15`、`openeuler/openeuler:22.03`。
每个镜像执行 `packaging/linux/smoke_test.sh`:
1. `install.sh --yes --no-service --db bundled --pg-port 25432`
2. `/health` 返回 200;用默认管理员登录拿到 token
3. `netxctl set-pg-port 25433`,之后 `/health` 仍然是 200
4. `install.sh --mode update --yes`(用同一个包模拟升级):`backups/` 里多出一份 dump,数据还在(登录前先建一条测试数据)
5. `netxctl stop`,检查没有残留进程
6. `netxctl uninstall --purge`(用 `--yes` 跳过确认)
- job `release`:用 `softprops/action-gh-release@v2` 把 4 个文件上传到同一个 tag 的 Release(**不要**设 `generate_release_notes`,release notes 由 Windows 的 workflow 生成,避免互相覆盖)。
### L10 发布脚本
- [publish_forgejo_release.ps1](../../packaging/publish_forgejo_release.ps1):`packaging/release/` 下如果有 `netx-<ver>-linux-*.tar.gz(.sha256)`,就一起上传;Release 说明里加上 Linux 安装方式。
- [publish_release.ps1](../../packaging/publish_release.ps1):GitHub Release 说明模板(第 107 行附近)加一段 Linux 的说明。
- 本地发布流程(写进 `packaging/linux/README.md`):CI 发布 tag 后,用 `gh release download vX -p 'netx-*-linux-*'` 把 Linux 包取回 `packaging/release/`,再执行 `publish_forgejo_release.ps1` 同步到 Forgejo。
### W1 Windows:Setup 数据库页增加内置 PG 端口
- [netx.iss](../../packaging/installer/netx.iss):在 `RbBundled`(约第 762 行)下面加一个"端口"输入框 `EdBundledPort`,默认 15432,只有选内置时才可用(`DbModeClick`,第 469 行)。翻页时校验它是 1024–65535 的数字,并且端口没有被占用(可以复用 `Test-NetxTcpPortFree`,或者在 Pascal 里用 `netstat -ano` 检查)。
- 静默安装参数 `/BundledPgPort=25432`(加在第 644 行附近解析 `DbMode` 的地方)。
- 执行 `setup_first_run.ps1` 时传入 `-BundledPort`(这个参数已经存在,见 [setup_first_run.ps1:11](../../packaging/setup_first_run.ps1:11))。
- **Update 模式**不显示、也不修改端口。
- 中英文文案(`english.` / `chinesesimplified.`)。
### W2 Windows:安装后修改端口
- 新文件 `packaging/set_pg_port.ps1 -Port <n>`(会自动提权):做的事和 L7 一样:校验 → 通过 `runtime_ctl.ps1` 停服务 → 改 `postgresql.conf`、`.env` 里的 `NETX_BUNDLED_PG_PORT` 和 `NETX_DATABASE_URL` 的端口 → 启动 → 等 `/health`,失败就回滚。
- 托盘的"设置"里加一项"内置数据库端口…",调用这个脚本。
- [build_release.ps1:121](../../packaging/build_release.ps1:121) 的拷贝清单里加上 `set_pg_port.ps1`。
---
## 3. 验收清单
| ID | 场景 | 通过标准 |
|---|---|---|
| A1 | CI 构建 | `release-linux.yml` 在测试 tag 上完整跑通;两个架构的 tar.gz 都 ≤ 150 MB;L4 检查通过 |
| A2 | 离线安装矩阵 | L9 的 smoke 在全部镜像里通过,并且都是 `--network none` |
| A3 | 真机 + systemd | 在一台真实的 Linux 服务器(例如 10.0.0.131 或一台 CentOS 7 / 麒麟虚拟机)上交互式安装:开机自启;`systemctl restart netx` 正常;`kill -9` API 进程后 5 秒内被拉起;浏览器能打开 `http://<ip>:8890/` 并登录 |
| A4 | PG 端口 | 安装时 `--pg-port 25432` 生效(`ss -lnt` 只看到 `127.0.0.1:25432`);`netxctl set-pg-port 25433` 后服务正常;改成一个已被占用的端口会被拒绝,原配置不变 |
| A4b | API 端口 | 安装时 `--port 18890` 生效;`netxctl set-port 18891` 后新端口能访问、旧端口不再监听;设成一个已被占用的端口会被拒绝 |
| A5 | 在线更新 | 先装 vN,在 Release 上挂 vN+1,执行 `netxctl check-update` 能看到新版本;`netxctl update --yes` 成功,`current` 指向新版本,数据还在,`backups/` 里有 dump |
| A6 | 更新回滚 | 构造一个启动就失败的包(比如删掉 `netx_api/main.py`),`netxctl update --file` 之后 `current` 自动切回旧版本,服务正常,命令返回非 0 |
| A7 | 校验失败 | 篡改 `.sha256` 后执行 update,结果是拒绝更新,并且不会解压 |
| A8 | 自动更新 | `netxctl auto-update on` 后 `systemctl list-timers` 里能看到 `netx-update.timer`;`off` 后消失 |
| A9 | external 模式 | `--db external --db-url …` 连接到外部 PG 能正常工作;`set-pg-port` 会给出明确的报错 |
| A10 | 卸载 | `netxctl uninstall` 保留 `/var/lib/netx`;`--purge` 加确认后全部清掉;systemd 单元和 `netxctl` 链接都被删除 |
| A11 | Windows 端口 | Setup 选内置并把端口填成 25432:装好后 `.env` 和 `postgresql.conf` 一致,服务正常;静默 `/BundledPgPort=25432` 一样生效;Update 模式下端口不变;托盘改端口成功 |
| A12 | Windows 回归 | 现有的 Windows 沙箱离线验证(静默安装、启动、登录、停止、服务模式)全部通过;同一个 Release 里有 Linux 资产时,Windows 的 `check_update.ps1` 仍然只选 Setup.exe |
## 4. 建议的实现顺序
1. L1 → L2 → L4(先把"能在 CentOS 7 上运行的运行时"做出来,这是风险最大的部分)
2. L6 `netxctl run`/start/stop → L5 install.sh → L7
3. L3 + L9(CI + smoke 矩阵)
4. L8 在线更新 + L10 发布脚本
5. W1、W2(Windows 端口,相对独立,可以并行做)
## 5. 风险与说明
- **PG 大版本**:固定为 16。以后要升到 17 需要单独做 `pg_upgrade` 流程;第一期只检测、不处理(`pg_major_mismatch`)。
- **API 默认绑 0.0.0.0**:Linux 服务器一般通过远程访问,所以默认对外。安装完成后会醒目提示修改默认管理员密码,并提醒放通防火墙端口。
- **本地构建**:本机目前没有 docker,WSL 里也没有装发行版。要在本地构建 Linux 包,需要 `wsl --install -d Ubuntu` 再装 docker,或者在一台 Linux 构建机上执行。日常发布以 CI 为主。

View file

@ -1,6 +1,6 @@
# NetX Windows packaging
**Linux is unchanged:** keep using your own PostgreSQL and `NETX_DATABASE_URL` with `scripts/start_netx.sh`. Nothing under this folder is required on Linux.
**Linux:** the offline `netx-<ver>-linux-x86_64.tar.gz` package lives in [`linux/`](linux/README.md) (install.sh + netxctl). `scripts/start_netx.sh` stays for running from a source checkout.
This directory builds a Windows deliverable with:

View file

@ -121,7 +121,7 @@ foreach ($name in @(
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
"stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1",
"launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1",
"service_run.ps1", "service_lifecycle.ps1", "install_update_task.ps1", "repair_venv.ps1", "runtime_ctl.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1",
"service_run.ps1", "service_lifecycle.ps1", "install_update_task.ps1", "repair_venv.ps1", "runtime_ctl.ps1", "set_pg_port.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1",
"build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json"
)) {
$src = Join-Path $PSScriptRoot $name

View file

@ -76,6 +76,9 @@ english.UninstallDeleteData=Also delete NetX data (%ProgramData%\NetX)?%n%nYes =
english.DbPageCaption=Database
english.DbPageDescription=Choose built-in or external PostgreSQL. Optional credential key: paste from an existing install, or leave empty to auto-generate.
english.DbBundled=Built-in PostgreSQL (portable, offline)
english.DbBundledPort=Port
english.DbBundledPortInvalid=The built-in PostgreSQL port must be a number between 1024 and 65535.
english.DbBundledPortInUse=Port %1 is already in use on this computer.%n%nChoose another port for the built-in PostgreSQL.
english.DbExternal=External PostgreSQL (existing server)
english.DbHost=Host
english.DbPort=Port
@ -118,6 +121,9 @@ chinesesimplified.UninstallDeleteData=是否同时删除 NetX 数据(%ProgramD
chinesesimplified.DbPageCaption=数据库
chinesesimplified.DbPageDescription=选择内置或外置 PostgreSQL。可选粘贴已有 NETX_CREDENTIAL_SECRET_KEY;留空则自动生成。
chinesesimplified.DbBundled=内置 PostgreSQL(便携,可离线)
chinesesimplified.DbBundledPort=端口
chinesesimplified.DbBundledPortInvalid=内置 PostgreSQL 端口必须是 1024–65535 之间的数字。
chinesesimplified.DbBundledPortInUse=本机端口 %1 已被占用。%n%n请为内置 PostgreSQL 换一个端口。
chinesesimplified.DbExternal=外置 PostgreSQL(已有服务器)
chinesesimplified.DbHost=主机
chinesesimplified.DbPort=端口
@ -236,6 +242,9 @@ var
DbPage: TWizardPage;
RbBundled: TNewRadioButton;
RbExternal: TNewRadioButton;
LblBundledPort: TNewStaticText;
EdBundledPort: TNewEdit;
GBundledPort: String;
LblHost: TNewStaticText;
LblPort: TNewStaticText;
LblUser: TNewStaticText;
@ -293,6 +302,46 @@ begin
end;
end;
function ReadEnvValue(Key: String): String;
var
Lines: TArrayOfString;
i: Integer;
Line: String;
begin
Result := '';
if not LoadStringsFromFile(ExpandConstant('{commonappdata}\NetX\.env'), Lines) then
Exit;
for i := 0 to GetArrayLength(Lines) - 1 do
begin
Line := Trim(Lines[i]);
if Pos(Key + '=', Line) = 1 then
Result := Trim(Copy(Line, Length(Key) + 2, Length(Line)));
end;
end;
function ValidBundledPort(Port: String; var ErrMsg: String): Boolean;
var
N: Integer;
begin
N := StrToIntDef(Trim(Port), -1);
Result := (N >= 1024) and (N <= 65535);
if not Result then
ErrMsg := ExpandConstant('{cm:DbBundledPortInvalid}');
end;
{ True when nothing listens on Port (loopback or any address). }
function TcpPortFree(Port: String): Boolean;
var
ResultCode: Integer;
begin
Result := True;
if Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "$p=' + Port +
'; foreach ($a in @([Net.IPAddress]::Loopback, [Net.IPAddress]::Any)) { try { $l = New-Object Net.Sockets.TcpListener($a, $p); $l.Start(); $l.Stop() } catch { exit 1 } }; exit 0"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
Result := (ResultCode = 0);
end;
function EnvModeIsExternal(): Boolean;
var
Lines: TArrayOfString;
@ -464,6 +513,8 @@ begin
EdUser.Enabled := Ext;
EdPassword.Enabled := Ext;
EdDbName.Enabled := Ext;
LblBundledPort.Enabled := not Ext;
EdBundledPort.Enabled := not Ext;
end;
procedure DbModeClick(Sender: TObject);
@ -646,6 +697,13 @@ begin
Mode := 'bundled';
if Mode = 'bundled' then
begin
GBundledPort := Trim(ExpandConstant('{param:BundledPgPort|}'));
if GBundledPort = '' then
GBundledPort := ReadEnvValue('NETX_BUNDLED_PG_PORT');
if GBundledPort = '' then
GBundledPort := '15432';
if not ValidBundledPort(GBundledPort, ErrMsg) then
Exit;
GDbMode := 'bundled';
GDbUrl := '';
Result := True;
@ -697,6 +755,9 @@ begin
GDbPassword := '';
GDbName := '';
GCredKey := '';
GBundledPort := ReadEnvValue('NETX_BUNDLED_PG_PORT');
if GBundledPort = '' then
GBundledPort := '15432';
GDbConfigured := False;
GPgToolsReady := False;
GDbConnOk := False;
@ -765,9 +826,23 @@ begin
RbBundled.Checked := True;
RbBundled.Top := ScaleY(8);
RbBundled.Left := ScaleX(0);
RbBundled.Width := DbPage.SurfaceWidth;
RbBundled.Width := DbPage.SurfaceWidth - ScaleX(150);
RbBundled.OnClick := @DbModeClick;
{ Bundled PostgreSQL port (127.0.0.1 only), on the same row as its radio button. }
LblBundledPort := TNewStaticText.Create(DbPage);
LblBundledPort.Parent := DbPage.Surface;
LblBundledPort.Caption := ExpandConstant('{cm:DbBundledPort}');
LblBundledPort.Top := RbBundled.Top + ScaleY(2);
LblBundledPort.Left := DbPage.SurfaceWidth - ScaleX(140);
EdBundledPort := TNewEdit.Create(DbPage);
EdBundledPort.Parent := DbPage.Surface;
EdBundledPort.Top := RbBundled.Top - ScaleY(2);
EdBundledPort.Left := DbPage.SurfaceWidth - ScaleX(80);
EdBundledPort.Width := ScaleX(80);
EdBundledPort.Text := GBundledPort;
RbExternal := TNewRadioButton.Create(DbPage);
RbExternal.Parent := DbPage.Surface;
RbExternal.Caption := ExpandConstant('{cm:DbExternal}');
@ -922,6 +997,20 @@ begin
CaptureCredKeyFromControls();
if RbBundled.Checked then
begin
GBundledPort := Trim(EdBundledPort.Text);
if not ValidBundledPort(GBundledPort, ErrMsg) then
begin
MsgBox(ErrMsg, mbError, MB_OK);
Result := False;
Exit;
end;
{ The current install's own PG may hold its port; only probe a changed port. }
if (GBundledPort <> ReadEnvValue('NETX_BUNDLED_PG_PORT')) and (not TcpPortFree(GBundledPort)) then
begin
MsgBox(FmtMessage(ExpandConstant('{cm:DbBundledPortInUse}'), [GBundledPort]), mbError, MB_OK);
Result := False;
Exit;
end;
GDbMode := 'bundled';
GDbUrl := '';
GDbConnOk := True;
@ -1021,6 +1110,9 @@ begin
' -DataRoot ''' + ExpandConstant('{commonappdata}\NetX') + '''' +
' -NonInteractive -DbMode ' + GDbMode;
if (GDbMode = 'bundled') and (GBundledPort <> '') then
WrapBody := WrapBody + ' -BundledPort ' + GBundledPort;
if GDbMode = 'external' then
begin
UrlFile := ExpandConstant('{tmp}\netx_db_url.txt');

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

View file

@ -9,5 +9,12 @@
"package": "setup",
"sha256": "",
"size": 0
},
"linux": {
"x86_64": {
"url": "https://github.com/hansjone/netx/releases/download/v0.4.18/netx-0.4.18-linux-x86_64.tar.gz",
"sha256": "",
"size": 0
}
}
}

View file

@ -214,6 +214,7 @@ try {
$runtimePs1 = Join-Path $PSScriptRoot "runtime_ctl.ps1"
$checkPs1 = Join-Path $PSScriptRoot "check_update.ps1"
$setupPs1 = Join-Path $PSScriptRoot "setup_first_run.ps1"
$pgPortPs1 = Join-Path $PSScriptRoot "set_pg_port.ps1"
$autostartPs1 = Join-Path $PSScriptRoot "install_autostart.ps1"
$updateTaskPs1 = Join-Path $PSScriptRoot "install_update_task.ps1"
@ -631,6 +632,7 @@ $miSource = New-NetxMenuItem -Text (T "Update source" "更新源") -Glyph "updat
$miSourceGithub = New-NetxMenuItem -Text (T "GitHub (default)" "GitHub(默认)") -Glyph "check"
$miSourceForgejo = New-NetxMenuItem -Text "git.avelo.top" -Glyph "check"
$miReconfig = New-NetxMenuItem -Text (T "Reconfigure database…" "重新配置数据库…") -Glyph "repair"
$miPgPort = New-NetxMenuItem -Text (T "Built-in database port…" "内置数据库端口…") -Glyph "settings"
$miLogs = New-NetxMenuItem -Text (T "Open log folder" "打开日志目录") -Glyph "log"
$miData = New-NetxMenuItem -Text (T "Open data folder" "打开数据目录") -Glyph "folder"
$miUpdate = New-NetxMenuItem -Text (T "Check for updates…" "检查更新…") -Glyph "update"
@ -640,7 +642,7 @@ $miStopExit = New-NetxMenuItem -Text (T "Stop NetX and exit" "停止 NetX 并退
[void]$miSource.DropDownItems.AddRange(@($miSourceGithub, $miSourceForgejo))
[void]$miSettings.DropDownItems.AddRange(@(
$miAutostart, $miAutoUpdate, $miSource, (New-NetxMenuSeparator),
$miReconfig, $miLogs, $miData
$miReconfig, $miPgPort, $miLogs, $miData
))
[void]$menu.Items.AddRange(@(
$miHeader, $miStatus, (New-NetxMenuSeparator),
@ -844,6 +846,30 @@ $miReconfig.add_Click({
}
})
$miPgPort.add_Click({
Invoke-NetxTrayDeferred {
if ($script:dbModeLabel -ne (T "Built-in DB" "内置库")) {
Show-NetxDialog (T "NetX uses an external database; use Reconfigure database instead." "当前使用外置数据库,请改用“重新配置数据库”。") "Information" | Out-Null
return
}
try {
$p = Start-NetxChild -File $pgPortPs1 -WindowStyle Normal
} catch {
Show-NetxDialog ((T "Could not change the port: " "无法修改端口:") + $_.Exception.Message) "Error" | Out-Null
return
}
Watch-NetxProcess $p {
param($sp)
$code = Get-NetxExitCode $sp
if ($null -ne $code -and $code -ne 0) {
Show-NetxDialog ((T "Changing the port failed (exit $code)." "修改端口失败(退出码 $code)。")) "Error" | Out-Null
return
}
Read-NetxTrayConfig
}
}
})
# --- updates ---
function Restart-NetxTraySelf {
$trayFile = Join-Path $PSScriptRoot "netx_tray.ps1"

View file

@ -7,7 +7,7 @@
[string]$ReleaseDir = ""
)
# Publish Windows Setup.exe to Forgejo/Gitea (mirror sync does NOT copy GitHub Release files).
# Publish Windows Setup.exe (+ the Linux tar.gz when present in the release dir) to Forgejo/Gitea (mirror sync does NOT copy GitHub Release files).
# Requires a Forgejo token with repo write (Settings → Applications → Generate New Token).
# Default: https://git.avelo.top (public domain). LAN IP only when explicitly reachable.
#
@ -29,6 +29,9 @@ $tag = "v$Version"
$relDir = if ($ReleaseDir) { $ReleaseDir } else { Join-Path $PSScriptRoot "release" }
$setup = Join-Path $relDir "NetX-Setup-$Version.exe"
$checksum = "$setup.sha256"
# Linux package from CI (gh release download vX -p 'netx-*-linux-*' -D packaging\release) or a local build.
$linuxPkg = Join-Path $relDir "netx-$Version-linux-x86_64.tar.gz"
$linuxSum = "$linuxPkg.sha256"
if (-not $Token) {
$Token = $env:NETX_FORGEJO_TOKEN
@ -84,10 +87,15 @@ try {
}
$notes = @"
NetX $Version Windows installer (published from GitHub release build).
NetX $Version (published from GitHub release build).
- NetX-Setup-$Version.exe
- NetX-Setup-$Version.exe — Windows installer
"@
if (Test-Path $linuxPkg) {
$notes += "`n- netx-$Version-linux-x86_64.tar.gz — Linux x86_64 offline package (``sudo ./install.sh``)"
} else {
Write-Host "[WARN] Linux package not found ($linuxPkg); publishing Windows only" -ForegroundColor Yellow
}
if ($existing -and $existing.id) {
Write-Host "==> Release $tag already exists (id=$($existing.id)); deleting old release to re-upload assets"
@ -127,6 +135,14 @@ function Upload-ForgejoAsset {
Upload-ForgejoAsset -Path $setup
Upload-ForgejoAsset -Path $checksum
if (Test-Path $linuxPkg) {
if (-not (Test-Path $linuxSum)) {
$lh = (Get-FileHash -LiteralPath $linuxPkg -Algorithm SHA256).Hash.ToLowerInvariant()
Set-Content -LiteralPath $linuxSum -Value "$lh $(Split-Path -Leaf $linuxPkg)" -Encoding ascii -NoNewline
}
Upload-ForgejoAsset -Path $linuxPkg
Upload-ForgejoAsset -Path $linuxSum
}
$releaseUrl = "$ForgejoBase/$Owner/$Repo/releases/tag/$tag"
Write-Host ""

View file

@ -118,7 +118,9 @@ if ($viewCode -eq 0) {
5. Default login: ``admin`` / ``admin123`` (change after first login)
### Linux
Unchanged — use your own PostgreSQL and ``scripts/start_netx.sh``.
- **netx-$Version-linux-x86_64.tar.gz** — offline package for x86_64 Linux (glibc 2.17+: CentOS 7+, Ubuntu 18.04+, Debian, openEuler, Kylin, UOS…), built and attached by the Linux workflow.
- ``tar xzf netx-$Version-linux-x86_64.tar.gz && cd netx-$Version-linux-x86_64 && sudo ./install.sh``
- See [packaging/linux/README.md](https://github.com/hansjone/netx/blob/main/packaging/linux/README.md).
See [packaging/README.md](https://github.com/hansjone/netx/blob/main/packaging/README.md) for details.
"@

110
packaging/set_pg_port.ps1 Normal file
View file

@ -0,0 +1,110 @@
param(
[int]$Port = 0,
[string]$ProgramRoot = "",
[string]$DataRoot = ""
)
# Change the bundled PostgreSQL port: postgresql.conf + .env (NETX_BUNDLED_PG_PORT and
# NETX_DATABASE_URL), restarting NetX when it was running. Rolls back on failure.
# powershell -ExecutionPolicy Bypass -File set_pg_port.ps1 -Port 25432
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Normal
$prog = Get-NetxProgramRoot -Override $ProgramRoot
$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot
$envPath = Join-Path $data ".env"
$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')
function T([string]$En, [string]$Zh) { if ($zh) { return $Zh } else { return $En } }
function Invoke-NetxRuntimeCtl([string]$Action) {
$p = Start-NetxPowerShell -File (Join-Path $PSScriptRoot "runtime_ctl.ps1") `
-Arguments @("-Action", $Action, "-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Hidden -PassThru
$null = $p.Handle
$p.WaitForExit()
return $p.ExitCode
}
try {
if (-not (Test-Path $envPath)) { throw "env_missing: $envPath" }
$map = Read-DotEnv -Path $envPath
if ((Get-DbMode -EnvMap $map) -ne "bundled") {
throw (T "NetX uses an external database; use Reconfigure database instead." "当前使用外置数据库,请改用“重新配置数据库”。")
}
$old = 15432
if ($map["NETX_BUNDLED_PG_PORT"]) { try { $old = [int]$map["NETX_BUNDLED_PG_PORT"] } catch {} }
if ($Port -eq 0) {
$entered = (Read-Host (T "New bundled PostgreSQL port (current $old)" "新的内置 PostgreSQL 端口(当前 $old)")).Trim()
if (-not $entered) { Write-Host (T "Cancelled." "已取消。"); exit 0 }
$Port = [int]$entered
}
if ($Port -lt 1024 -or $Port -gt 65535) { throw "invalid_port: $Port (1024-65535)" }
if ($Port -eq $old) {
Write-Host ((T "Port unchanged: " "端口未变:") + $Port) -ForegroundColor Green
exit 0
}
if (-not (Test-NetxTcpPortFree -HostName "127.0.0.1" -Port $Port)) {
throw ((T "Port $Port is already in use." "端口 $Port 已被占用。"))
}
$pgData = if ($map["NETX_BUNDLED_PG_DATA_DIR"]) { $map["NETX_BUNDLED_PG_DATA_DIR"] -replace '/', '\' } else { Join-Path $data "pgdata" }
$conf = Join-Path $pgData "postgresql.conf"
if (-not (Test-Path $conf)) { throw "postgresql_conf_missing: $conf" }
$pgCtl = Join-Path $prog "postgres\pgsql\bin\pg_ctl.exe"
$bind = if ($map["NETX_HOST"]) { $map["NETX_HOST"] } else { "127.0.0.1" }
$apiPort = if ($map["NETX_PORT"]) { [int]$map["NETX_PORT"] } else { 8890 }
$clientHost = Get-NetxClientHost -BindHost $bind
$wasRunning = Test-NetxApiHealthy -HostName $clientHost -Port $apiPort
Write-Host ("==> " + (T "Stopping NetX" "停止 NetX"))
$null = Invoke-NetxRuntimeCtl -Action "stop"
if (Test-Path $pgCtl) {
$status = & $pgCtl -D $pgData status 2>&1 | Out-String
if ($status -match "server is running") { & $pgCtl -D $pgData -m fast -w stop | Out-Null }
}
$confBak = "$conf.bak"
$envBak = "$envPath.bak"
Copy-Item -LiteralPath $conf -Destination $confBak -Force
Copy-Item -LiteralPath $envPath -Destination $envBak -Force
$confText = Get-Content -Raw -Path $conf
if ($confText -match '(?m)^\s*port\s*=') {
$confText = $confText -replace '(?m)^\s*port\s*=\s*\d+', "port = $Port"
} else {
$confText += "`nport = $Port`n"
}
# No BOM: PowerShell 5.1 `Set-Content -Encoding utf8` adds one and postgres then
# rejects postgresql.conf (syntax error at line 1) and will not start.
[IO.File]::WriteAllText($conf, $confText, (New-Object System.Text.UTF8Encoding($false)))
$values = @{ "NETX_BUNDLED_PG_PORT" = "$Port" }
$url = [string]$map["NETX_DATABASE_URL"]
if ($url) {
$values["NETX_DATABASE_URL"] = ($url -replace '@(127\.0\.0\.1|localhost):\d+/', "@`$1:$Port/")
}
Write-DotEnvValue -Path $envPath -Values $values
Write-Host ("==> " + (T "Bundled PostgreSQL port set to " "内置 PostgreSQL 端口已改为 ") + $Port)
if ($wasRunning) {
Write-Host ("==> " + (T "Starting NetX" "启动 NetX"))
$null = Invoke-NetxRuntimeCtl -Action "start"
if (-not (Wait-NetxApiHealthy -HostName $clientHost -Port $apiPort -TimeoutSec 180)) {
Write-Host ("[ERR] " + (T "NetX did not start; restoring the previous port." "NetX 未能启动,正在恢复原端口。")) -ForegroundColor Red
$null = Invoke-NetxRuntimeCtl -Action "stop"
Copy-Item -LiteralPath $confBak -Destination $conf -Force
Copy-Item -LiteralPath $envBak -Destination $envPath -Force
$null = Invoke-NetxRuntimeCtl -Action "start"
throw "set_pg_port_failed"
}
}
Remove-Item -LiteralPath $confBak, $envBak -Force -ErrorAction SilentlyContinue
Write-Host ("==> " + (T "Done." "完成。")) -ForegroundColor Green
} catch {
Write-Host "[ERR] $($_.Exception.Message)" -ForegroundColor Red
if ([Environment]::UserInteractive) { Read-Host (T "Press Enter to close" "按回车关闭") | Out-Null }
exit 1
}

View file

@ -113,6 +113,21 @@ if ($DbMode -eq "bundled") {
}
}
}
# Keep the configured port on reconfigure unless the caller passes -BundledPort.
if (-not $PSBoundParameters.ContainsKey("BundledPort")) {
$keepPort = 0
if ($existing.ContainsKey("NETX_BUNDLED_PG_PORT")) {
try { $keepPort = [int]$existing["NETX_BUNDLED_PG_PORT"] } catch { $keepPort = 0 }
}
if ($keepPort -ge 1024 -and $keepPort -le 65535) { $BundledPort = $keepPort }
if (-not $NonInteractive) {
$entered = (Read-Host (T "Bundled PostgreSQL port [$BundledPort]" "内置 PostgreSQL 端口 [$BundledPort]")).Trim()
if ($entered) { $BundledPort = [int]$entered }
}
}
if ($BundledPort -lt 1024 -or $BundledPort -gt 65535) {
throw "invalid_bundled_port: $BundledPort (1024-65535)"
}
$pgData = Join-Path $data "pgdata"
$values["NETX_BUNDLED_PG_PORT"] = "$BundledPort"
$values["NETX_BUNDLED_PG_DATA_DIR"] = (ConvertTo-NetxFsPath $pgData)
@ -147,7 +162,9 @@ if ($DbMode -eq "bundled") {
} else {
$confText = $confText -replace "(?m)^\s*listen_addresses\s*=\s*'[^']*'", "listen_addresses = '127.0.0.1'"
}
Set-Content -Path $conf -Value $confText -Encoding utf8
# No BOM: PowerShell 5.1 `Set-Content -Encoding utf8` adds one and postgres then
# rejects postgresql.conf (syntax error at line 1) and will not start.
[IO.File]::WriteAllText($conf, $confText, (New-Object System.Text.UTF8Encoding($false)))
}
}
if (Test-Path $hba) {