mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 19:50:53 +08:00
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:
parent
c141a37263
commit
846aa3e680
26 changed files with 3120 additions and 9 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +1,2 @@
|
|||
*.sh text eol=lf
|
||||
packaging/linux/** text eol=lf
|
||||
|
|
|
|||
92
.github/workflows/release-linux.yml
vendored
Normal file
92
.github/workflows/release-linux.yml
vendored
Normal 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
|
||||
288
docs/reviews/netx-linux-release-plan-2026-10-14.md
Normal file
288
docs/reviews/netx-linux-release-plan-2026-10-14.md
Normal 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 为主。
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
82
packaging/linux/README.md
Normal 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 包。
|
||||
109
packaging/linux/build_in_container.sh
Executable file
109
packaging/linux/build_in_container.sh
Executable 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"
|
||||
77
packaging/linux/build_linux_release.sh
Executable file
77
packaging/linux/build_linux_release.sh
Executable 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
37
packaging/linux/check_bundle.sh
Executable 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
614
packaging/linux/install.sh
Executable 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
171
packaging/linux/lib.sh
Normal 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
|
||||
}
|
||||
10
packaging/linux/logrotate.netx
Normal file
10
packaging/linux/logrotate.netx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@DATA@/logs/*.log {
|
||||
su @USER@ @USER@
|
||||
daily
|
||||
rotate 7
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
}
|
||||
675
packaging/linux/netx_admin.py
Normal file
675
packaging/linux/netx_admin.py
Normal 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
522
packaging/linux/netxctl
Executable 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
109
packaging/linux/smoke_test.sh
Executable 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}")"
|
||||
8
packaging/linux/systemd/netx-update.service.in
Normal file
8
packaging/linux/systemd/netx-update.service.in
Normal 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
|
||||
10
packaging/linux/systemd/netx-update.timer
Normal file
10
packaging/linux/systemd/netx-update.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=Daily NetX update check
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
RandomizedDelaySec=3600
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
20
packaging/linux/systemd/netx.service.in
Normal file
20
packaging/linux/systemd/netx.service.in
Normal 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
|
||||
16
packaging/linux/versions.env
Normal file
16
packaging/linux/versions.env
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
110
packaging/set_pg_port.ps1
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue