diff --git a/.gitattributes b/.gitattributes index dfdb8b7..67733fc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.sh text eol=lf +packaging/linux/** text eol=lf diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..8eb23bc --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -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 diff --git a/docs/reviews/netx-linux-release-plan-2026-10-14.md b/docs/reviews/netx-linux-release-plan-2026-10-14.md new file mode 100644 index 0000000..989ec0f --- /dev/null +++ b/docs/reviews/netx-linux-release-plan-2026-10-14.md @@ -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--linux-x86_64.tar.gz + .sha256 +``` + +解压后: + +``` +netx--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//` | 某个版本的程序(只读) | root:root 0755 | +| `/opt/netx/current` → `releases/` | 当前版本的软链,切换是原子的 | | +| `/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 `:在 `quay.io/pypa/manylinux2014_` 容器里从源码编译 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-.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 `:下载 python-build-standalone `cpython-3.12.x+--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 `、`--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 # 不填则自动生成 +--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 # 复用别的安装的 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/`,切换 `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 = ` 和 `listen_addresses = '127.0.0.1'`,`pg_hba` 只允许本机,然后创建 `netx` 角色和库(照搬 Windows 的 bootstrap SQL); +5. 如果系统有 SELinux 并且有 `restorecon`,执行 `restorecon -R `; +6. 有 systemd(`[ -d /run/systemd/system ]`)就安装 `netx.service` 并 `enable --now`;没有就执行 `netxctl start`; +7. 等待 `/health` 返回正常(最多 90 秒),最后打印访问地址、首次登录说明和防火墙提示。 + +**更新**(`--mode update`,也是在线更新最后调用的入口): +1. 解压到 `releases/`(版本已存在就覆盖); +2. 内置 PG 时做 `pg_dump -Fc` 到 `backups/pre--.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 # 需求 4 +netxctl set-port / set-bind +netxctl reconfigure-db # 等于 install.sh --mode reinstall 里的数据库部分 +netxctl check-update / update [--yes] [--file ] +netxctl auto-update on|off +netxctl backup [] # 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 ` / `set-bind `:校验 → 停服务 → 改 `.env` 里的 `NETX_PORT` / `NETX_HOST` → 启动 → 等 `/health`;失败就恢复 `.env` 并重新启动。改完后打印 firewalld/ufw 的放行命令。 +- 重装(`--mode reinstall`)时,默认值取当前配置,并允许沿用自己正在占用的端口。 + +### L7 内置 PG 端口可配置(Linux 部分) + +- `install.sh`:支持 `--pg-port`;交互模式默认值 15432,会检查是否被占用,被占用时重新问。 +- `netxctl set-pg-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[\d.]+)-linux-\.tar\.gz$`;sha256 优先取 `.sha256` 资产,其次取 manifest 里的值,**两者都没有就拒绝更新**。 +- 代理:遵循 `HTTPS_PROXY/NO_PROXY`(urllib 默认就支持)。 +- 流程:下载到 `/var/lib/netx/updates/` → 校验 → 解压到临时目录 → 执行**新包里的** `install.sh --mode update --yes` → 清理临时文件。 +- `netxctl update --file `:离线升级,跳过下载这一步。 +- 自动更新:`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--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 `(会自动提权):做的事和 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://: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 为主。 diff --git a/packaging/README.md b/packaging/README.md index b0dfae3..6a58a7f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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--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: diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index 722f9d7..443f5f4 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -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 diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index d630e0e..6e60002 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -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'); diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..c1801c2 --- /dev/null +++ b/packaging/linux/README.md @@ -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 包。 diff --git a/packaging/linux/build_in_container.sh b/packaging/linux/build_in_container.sh new file mode 100755 index 0000000..8988722 --- /dev/null +++ b/packaging/linux/build_in_container.sh @@ -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" < 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" diff --git a/packaging/linux/build_linux_release.sh b/packaging/linux/build_linux_release.sh new file mode 100755 index 0000000..e0b787c --- /dev/null +++ b/packaging/linux/build_linux_release.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Build netx--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" diff --git a/packaging/linux/check_bundle.sh b/packaging/linux/check_bundle.sh new file mode 100755 index 0000000..49fb764 --- /dev/null +++ b/packaging/linux/check_bundle.sh @@ -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 [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}" diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..c3f9db1 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,614 @@ +#!/usr/bin/env bash +# NetX Linux installer / updater. Ships at the root of netx--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 < 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