Linux installer: catch paths the netx user cannot enter; --grant-access.

Installing under /root left systemd failing with 203/EXEC and empty logs.
The installer now checks that the service user can enter the program and
data dirs; blocked parents are granted traverse-only access (setfacl
u:netx:x, else chmod o+x) with --grant-access or after asking, otherwise
it stops with the exact command. Start failures show the supervisor log
or journalctl when api.log is empty. README: /root installs and UME with
self-signed certificates (NETX_ALLOW_INSECURE_DEFAULTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-14 15:28:21 +08:00
parent 78db39eabe
commit b0b64ba07d
4 changed files with 126 additions and 4 deletions

View file

@ -26,6 +26,20 @@ sudo ./install.sh
sudo ./install.sh --prefix /data/netx --data /data/netx-data sudo ./install.sh --prefix /data/netx --data /data/netx-data
``` ```
**目录在 `/root` 下面时**:NetX 服务以系统用户 `netx` 运行(内置 PostgreSQL 不允许用 root 运行),而 `/root` 默认不允许其他用户进入。安装程序会检测到这种情况:交互安装时询问是否授权;非交互安装加 `--grant-access` 自动授权。授权只给 `netx` 开放上级目录的"进入"权限(不能查看目录内容):优先用 `setfacl -m u:netx:x`,系统没有 ACL 工具时用 `chmod o+x`。
```bash
sudo ./install.sh --prefix /root/service/netx/programfile --data /root/service/netx/userdata --grant-access
```
已经装在 `/root` 下、启动失败且日志为空的,手动授权后直接 `sudo netxctl start` 即可,不用重装:
```bash
setfacl -m u:netx:x /root /root/service /root/service/netx
```
卸载时不会撤销这些授权;需要的话用 `setfacl -x u:netx <目录>` 撤销。
全部安装参数见 `./install.sh --help`。 全部安装参数见 `./install.sh --help`。
交互式安装会依次询问:监听地址(默认 `0.0.0.0`)、**Web/API 端口**(默认 8890,被占用会提示重新输入)、数据库(内置 / 外置)、**内置 PostgreSQL 端口**(默认 15432,只监听 127.0.0.1)。 交互式安装会依次询问:监听地址(默认 `0.0.0.0`)、**Web/API 端口**(默认 8890,被占用会提示重新输入)、数据库(内置 / 外置)、**内置 PostgreSQL 端口**(默认 15432,只监听 127.0.0.1)。
@ -39,7 +53,19 @@ sudo ./install.sh --yes --db external --db-url 'postgresql+psycopg://netx:密码
装好后访问 `http://<服务器IP>:<端口>/`,初始账号 `admin`,密码在安装结束时显示(随机生成,也可用 `--admin-password` 指定;同时保存在 `/var/lib/netx/.env` 的 `NETX_BOOTSTRAP_ADMIN_PASSWORD`),首次登录必须改密码。 装好后访问 `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 用的是自签名证书,需要配置证书,或者改成只监听本机再通过反向代理访问。 > 对外监听(非 127.0.0.1)时 NetX 自身要求:管理员初始密码不能是 admin123,并且开启 UME 的 TLS 校验,否则拒绝启动(api.log 末尾是 `security_bootstrap.py ... SystemExit: 2`)。安装程序会自动生成随机管理员密码,并设置 `NETX_UME_VERIFY_TLS=true`。
>
> **对接 UME(通常是自签名证书)**:在 `/var/lib/netx/.env` 中加入下面几行,然后 `sudo netxctl restart`:
>
> ```
> NETX_UME_BASE_URL=https://<UME地址>:<端口>
> NETX_UME_USERNAME=...
> NETX_UME_PASSWORD=...
> NETX_UME_VERIFY_TLS=false
> NETX_ALLOW_INSECURE_DEFAULTS=1
> ```
>
> `NETX_ALLOW_INSECURE_DEFAULTS=1` 会跳过上面这组启动检查。由于管理员密码和 JWT 密钥在安装时已经单独生成,实际放开的只有"访问 UME 时不校验证书"这一项(与 Windows 版默认行为相同)。
| 路径 | 内容 | | 路径 | 内容 |
|---|---| |---|---|

View file

@ -33,6 +33,7 @@ NO_SERVICE=0
AUTO_UPDATE=0 AUTO_UPDATE=0
OPEN_FIREWALL=0 OPEN_FIREWALL=0
NO_BACKUP=0 NO_BACKUP=0
GRANT_ACCESS=0
YES=0 YES=0
usage() { usage() {
@ -55,6 +56,7 @@ $(t "用法" "Usage"): sudo ./install.sh [options]
--auto-update $(t "开启每日自动更新" "enable daily automatic updates") --auto-update $(t "开启每日自动更新" "enable daily automatic updates")
--open-firewall $(t "自动放行防火墙端口(firewalld/ufw)" "open the port in firewalld/ufw") --open-firewall $(t "自动放行防火墙端口(firewalld/ufw)" "open the port in firewalld/ufw")
--no-backup $(t "更新前不备份数据库" "skip the pre-update database dump") --no-backup $(t "更新前不备份数据库" "skip the pre-update database dump")
--grant-access $(t "目录在 /root 等受限目录下时,自动给服务用户 netx 开放上级目录的“进入”权限" "if a parent dir (e.g. /root) blocks the netx service user, grant it traverse-only access")
-y, --yes $(t "非交互,使用默认值" "non-interactive, accept defaults") -y, --yes $(t "非交互,使用默认值" "non-interactive, accept defaults")
EOF EOF
} }
@ -81,6 +83,7 @@ while [[ $# -gt 0 ]]; do
--auto-update) AUTO_UPDATE=1; shift ;; --auto-update) AUTO_UPDATE=1; shift ;;
--open-firewall) OPEN_FIREWALL=1; shift ;; --open-firewall) OPEN_FIREWALL=1; shift ;;
--no-backup) NO_BACKUP=1; shift ;; --no-backup) NO_BACKUP=1; shift ;;
--grant-access) GRANT_ACCESS=1; shift ;;
-y|--yes) YES=1; shift ;; -y|--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) err "unknown option: $1"; usage; exit 2 ;; *) err "unknown option: $1"; usage; exit 2 ;;
@ -524,6 +527,7 @@ summary() {
do_install() { # install or reinstall do_install() { # install or reinstall
ensure_user ensure_user
check_netx_access "${PREFIX}" "${DATA}"
mkdir -p "${DATA}" mkdir -p "${DATA}"
ensure_data_dirs "${DATA}" ensure_data_dirs "${DATA}"
if [[ -x "${PREFIX}/current/packaging/linux/netxctl" ]]; then if [[ -x "${PREFIX}/current/packaging/linux/netxctl" ]]; then
@ -539,7 +543,7 @@ do_install() { # install or reinstall
if [[ "${AUTO_UPDATE}" == "1" ]]; then "${PREFIX}/current/packaging/linux/netxctl" auto-update on || true; fi if [[ "${AUTO_UPDATE}" == "1" ]]; then "${PREFIX}/current/packaging/linux/netxctl" auto-update on || true; fi
if ! start_and_wait; then if ! start_and_wait; then
err "$(t "NetX 启动失败,日志" "NetX failed to start; logs"): ${DATA}/logs/" err "$(t "NetX 启动失败,日志" "NetX failed to start; logs"): ${DATA}/logs/"
tail -n 30 "${DATA}/logs/api.log" >&2 2>/dev/null || true show_start_failure "${DATA}"
exit 1 exit 1
fi fi
prune_releases "" prune_releases ""
@ -549,6 +553,7 @@ do_install() { # install or reinstall
do_update() { do_update() {
[[ -f "${ENV_FILE}" ]] || die "$(t "未找到已有安装(${ENV_FILE}),请用 --mode install" "no existing install (${ENV_FILE}); use --mode install")" [[ -f "${ENV_FILE}" ]] || die "$(t "未找到已有安装(${ENV_FILE}),请用 --mode install" "no existing install (${ENV_FILE}); use --mode install")"
ensure_user ensure_user
check_netx_access "${PREFIX}" "${DATA}"
ensure_data_dirs "${DATA}" ensure_data_dirs "${DATA}"
local old_ver="" local old_ver=""
if [[ -f "${PREFIX}/current/version.json" ]]; then if [[ -f "${PREFIX}/current/version.json" ]]; then
@ -585,7 +590,7 @@ do_update() {
return 0 return 0
fi fi
err "$(t "新版本启动失败" "new version failed to start")" err "$(t "新版本启动失败" "new version failed to start")"
tail -n 30 "${DATA}/logs/api.log" >&2 2>/dev/null || true show_start_failure "${DATA}"
svc_stop "${PREFIX}" "${DATA}" svc_stop "${PREFIX}" "${DATA}"
local back="" local back=""
if [[ -n "${old_ver}" && "${old_ver}" != "${VERSION}" && -d "${PREFIX}/releases/${old_ver}" ]]; then if [[ -n "${old_ver}" && "${old_ver}" != "${VERSION}" && -d "${PREFIX}/releases/${old_ver}" ]]; then

View file

@ -81,6 +81,97 @@ client_host() {
esac esac
} }
# The service runs as ${NETX_USER}: it must be able to enter the program and data
# dirs. Typical failure: paths under /root (mode 550/700) -> systemd 203/EXEC and
# empty logs, because netxctl never runs.
netx_can_enter() { as_netx /bin/sh -c 'cd "$1"' sh "$1" 2>/dev/null; }
# Let NETX_USER traverse one directory: ACL for that user only, else o+x.
grant_traverse() {
local p="$1"
if command -v setfacl >/dev/null 2>&1 && setfacl -m "u:${NETX_USER}:x" "${p}" 2>/dev/null; then
info "setfacl -m u:${NETX_USER}:x ${p}"
else
chmod o+x "${p}" && info "chmod o+x ${p}"
fi
}
# Can NETX_USER pass through this dir judging by mode bits alone? (Used only to
# name the blocking dirs below an already blocked one, which cannot be probed.)
others_can_traverse() {
local m
m="$(stat -c %A "$1" 2>/dev/null)" || return 1
[[ "${m:9:1}" == "x" || "${m:9:1}" == "t" ]]
}
# check_netx_access DIR... — every dir must be enterable by NETX_USER.
# Blocked parents (typically /root) are granted traverse-only access when
# GRANT_ACCESS=1 or the interactive user agrees; otherwise abort with the commands.
# (Plain strings, not arrays: empty arrays trip `set -u` on bash 4.2 / CentOS 7.)
check_netx_access() {
local d p rest comp ans blocked="" seen_block
for d in "$@"; do
mkdir -p "${d}"
netx_can_enter "${d}" && continue
p=""
seen_block=0
rest="${d#/}"
while [[ -n "${rest}" ]]; do
comp="${rest%%/*}"
if [[ "${rest}" == */* ]]; then rest="${rest#*/}"; else rest=""; fi
[[ -n "${comp}" ]] || continue
p="${p}/${comp}"
if [[ "${GRANT_ACCESS:-0}" == "1" ]]; then
if ! netx_can_enter "${p}"; then
grant_traverse "${p}"
blocked="${blocked} ${p}"
fi
elif [[ "${seen_block}" == "0" ]]; then
if ! netx_can_enter "${p}"; then
seen_block=1
[[ " ${blocked} " == *" ${p} "* ]] || blocked="${blocked} ${p}"
fi
elif ! others_can_traverse "${p}" && [[ " ${blocked} " != *" ${p} "* ]]; then
blocked="${blocked} ${p}"
fi
done
done
blocked="${blocked# }"
[[ -n "${blocked}" ]] || return 0
if [[ "${GRANT_ACCESS:-0}" != "1" ]]; then
warn "$(t "服务用户 ${NETX_USER} 无法进入以下目录(常见于 /root 下面)" "service user ${NETX_USER} cannot enter these directories (typically under /root)"): ${blocked}"
if [[ "${INTERACTIVE:-0}" == "1" ]]; then
read -r -p "$(t "只给 ${NETX_USER} 开放这些目录的“进入”权限(不能查看内容)?[Y/n] " "Grant ${NETX_USER} traverse-only access to them? [Y/n] ")" ans || true
if [[ ! "${ans}" =~ ^[Nn] ]]; then
GRANT_ACCESS=1
check_netx_access "$@"
return
fi
fi
die "$(t "请加 --grant-access 自动授权,或手动执行:setfacl -m u:${NETX_USER}:x ${blocked}(没有 setfacl 时用 chmod o+x),或换到 /root 以外的目录" "re-run with --grant-access, or run: setfacl -m u:${NETX_USER}:x ${blocked} (chmod o+x without setfacl), or use a path outside /root")"
fi
for d in "$@"; do
netx_can_enter "${d}" || die "$(t "授权后 ${NETX_USER} 仍无法进入" "still not enterable by ${NETX_USER} after granting"): ${d}"
done
ok "$(t "已授权 ${NETX_USER} 进入程序/数据目录" "granted ${NETX_USER} access to the program/data directories")"
}
# Show why NetX did not come up: app log first, else supervisor log, else systemd.
show_start_failure() {
local data="$1" f
for f in "${data}/logs/api.log" "${data}/logs/netxctl.log"; do
if [[ -s "${f}" ]]; then
echo "--- ${f}" >&2
tail -n 30 "${f}" >&2
return 0
fi
done
if svc_unit_installed; then
echo "--- journalctl -u netx" >&2
journalctl -u netx.service -n 30 --no-pager >&2 2>/dev/null || systemctl status netx.service --no-pager >&2 || true
fi
}
is_loopback() { case "$1" in 127.0.0.1|localhost|::1) return 0 ;; *) return 1 ;; esac; } is_loopback() { case "$1" in 127.0.0.1|localhost|::1) return 0 ;; *) return 1 ;; esac; }
primary_ip() { primary_ip() {

View file

@ -72,7 +72,7 @@ wait_healthy() {
return 0 return 0
fi fi
err "$(t "NetX 未在 ${timeout} 秒内就绪" "NetX not healthy after ${timeout}s"): $(health_url)" 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 show_start_failure "${NETX_DATA}"
return 1 return 1
} }