diff --git a/scripts/svc.sh b/scripts/svc.sh index 63a9797e..c9485bf3 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -1198,15 +1198,61 @@ addr_is_loopback() { esac } -# agent_args_authed ARGS:按二进制 AuthEnabled 口径判断启动参数是否开启认证 -# (--auth on;或未显式 off 且带 --dsn——auto+PG 即开)。--mock 只影响存储档不影响认证。 +# agent_args_authed ARGS [DSN_ENV]:按二进制 AuthEnabled 口径(config.go: +# 显式 on;或非显式 off 且 StorageDSN 非空)判断认证是否开启。DSN 两个来源等价: +# 命令行 --dsn(历史兼容)与 AGENT_SYSTEM_DSN 环境变量(G1-7 起的标准通道, +# 第二参数传其值)——只看参数串会把「非回环 + auto + env DSN」误判为认证关 +#(2026-10-10 dev 实测误拒)。--mock 只影响存储档不影响认证。 agent_args_authed() { case "$1" in *--auth[=\ ]on*) return 0 ;; *--auth[=\ ]off*) return 1 ;; - *--dsn\ *) return 0 ;; - *) return 1 ;; esac + [ -n "${2:-}" ] && return 0 + case "$1" in *--dsn\ *) return 0 ;; esac + return 1 +} + +# agent_listen_rejected EXEC_ARGS DEFAULT_ADDR INSECURE DSN_ENV:启动预检判定(0=拒绝)。 +# 与二进制 listen guard 同口径:回环或显式放行(insecure)不拦;认证开不拦; +# 仅非回环 + 未放行 + 认证关才拦。 +agent_listen_rejected() { + local addr; addr="$(printf '%s' "$1" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" + addr="${addr:-$2}" + addr_is_loopback "${addr%:*}" && return 1 + [ -n "$3" ] && return 1 + agent_args_authed "$1" "$4" && return 1 + return 0 +} + +# agent_reuse_exec EXEC_ARGS OUT_VAR:复用启动(无显式参数,复用 svc.env exec=) +# 时的 exec 参数与 DSN 解析。处理后的 exec 参数经 nameref OUT_VAR 回写;DSN 经 +# 全局 DSN_ENV 传递(agent_do_start 内已 local 化)。必须直接调用而非放进命令 +# 替换——$() 子壳里的 DSN_ENV 赋值回不到调用方。两步:先做 G1-7 迁移(存量 +# --dsn 剥到环境变量);exec= 不含 DSN 且 dbref= 登记在案时从实例登记重建—— +# 否则 G1-7 后 exec= 永不含 DSN,纯 start/restart 复用会静默回落 SQLite,数据库 +# 绑定丢失。 +agent_reuse_exec() { + local -n out_var="$2" + # 内部变量不得叫 exec_args:与 nameref 目标同名会在函数作用域自我遮蔽, + # 回写落到局部、调用方拿不到值。 + local cur="$1" bound_dsn + case "$cur" in + *--dsn\ *) + DSN_ENV="$(printf '%s' "$cur" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')" + cur="$(printf '%s' "$cur" | sed 's/--dsn [^ ]*//' | sed 's/[[:space:]]\{2,\}/ /g; s/^ //; s/ $//')" + c_info "[$ENV_NAME] DSN 已从命令行迁移到环境变量(G1-7)" + ;; + esac + if [ -z "${DSN_ENV:-}" ] && [ -n "$(svc_get dbref)" ]; then + if bound_dsn="$(dsn_from_dbref "$(svc_get dbref)")"; then + DSN_ENV="$bound_dsn" + c_info "[$ENV_NAME] 数据库绑定 $(svc_get dbref) 随复用启动保留(AGENT_SYSTEM_DSN)" + else + c_warn "[$ENV_NAME] dbref=$(svc_get dbref) 无法重建 DSN(实例缺失或登记不全),按无数据库绑定启动" + fi + fi + out_var="$cur" } agent_do_start() { @@ -1219,18 +1265,12 @@ agent_do_start() { fi SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)" # 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=。 - local exec_args + # DSN_ENV 必须 local 并清零:G1-7 起 DSN 经它传给启动守卫环境,全局残留会让 + # update --all 同进程连续启动时前一个服务的 DSN 泄漏进后一个的 AGENT_SYSTEM_DSN。 + local exec_args DSN_ENV="" if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then - exec_args="$(svc_get exec)" - # G1-7:存量 exec= 里的 --dsn 剥离到环境变量(迁移;命令行不再可见明文)。 - case "$exec_args" in - *--dsn\ *) - DSN_ENV="$(printf '%s' "$exec_args" | sed -n 's/.*--dsn \([^ ]*\).*/\1/p')" - exec_args="$(printf '%s' "$exec_args" | sed 's/--dsn [^ ]*//' | sed 's/[[:space:]]\{2,\}/ /g; s/^ //; s/ $//')" - c_info "[$ENV_NAME] DSN 已从命令行迁移到环境变量(G1-7)" - ;; - esac - c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")" + agent_reuse_exec "$(svc_get exec)" exec_args + c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$(mask_dsn "$exec_args")" else local args="" if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then @@ -1278,9 +1318,12 @@ agent_do_start() { [ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts") # 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作—— # 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。 + # 认证判定与二进制同口径(agent_listen_rejected → agent_args_authed): + # env DSN(AGENT_SYSTEM_DSN)同样算「已配置 DSN」,不再因命令行不见 --dsn + # 而把非回环+auto 误判为认证关(2026-10-10 dev 实测误拒)。 local pf_addr; pf_addr="$(printf '%s' "$exec_args" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" pf_addr="${pf_addr:-$ENV_ADDR}" - if ! addr_is_loopback "${pf_addr%:*}" && [ -z "$insecure" ] && ! agent_args_authed "$exec_args"; then + if agent_listen_rejected "$exec_args" "$ENV_ADDR" "$insecure" "${DSN_ENV:-}"; then c_err "[$ENV_NAME] 拒绝启动:非回环地址 $pf_addr 且认证判定为关(auto 无 DSN 即关)。" c_err " 任选其一:--addr 127.0.0.1:${pf_addr##*:};数据库绑定 scripts/svc.sh $ENV_NAME db ;" c_err " 显式 --auth on;或环境 SVC_$(printf '%s' "$ENV_NAME" | tr 'a-z-' 'A-Z_')_INSECURE_LISTEN=1 显式放行。" @@ -3072,4 +3115,7 @@ main() { esac } -main "$@" +# 仅作为脚本直接执行时进入主流程;被 source(治理单测 fixtures)时只定义函数。 +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/tests/test_svc_precheck.py b/tests/test_svc_precheck.py new file mode 100644 index 00000000..c417aafd --- /dev/null +++ b/tests/test_svc_precheck.py @@ -0,0 +1,216 @@ +"""svc.sh 启动预检与复用 DSN 解析的治理回归。 + +背景(2026-10-10 dev 实测,记忆 svc-g17-precheck-dsn-bug):G1-7 把 DSN 从 +命令行迁到 AGENT_SYSTEM_DSN 环境变量后—— + +1. 预检 agent_args_authed 只看参数串,「非回环 + auth auto + env DSN」被误判 + 认证关而拒启;二进制 AuthEnabled(modules/backend/internal/config/config.go: + 显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN 来源)会放行。 +2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支, + 纯 start/restart 会静默回落 SQLite。 +3. DSN_ENV 为全局且每次启动不重置:update --all 同进程连续启动会跨服务泄漏 DSN。 + +测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫), +不启动真实服务、不触碰真实 runtime/。 +""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SVC = REPO / "scripts" / "svc.sh" + +PG_ADDR = "10.1.2.3:8899" +PG_USER = "svc_test" +PG_PASS = "fixture-only-pass" +PG_DSN = f"postgresql://{PG_USER}:{PG_PASS}@{PG_ADDR}/agentdb" +NONLOOP_EXEC = "--addr 0.0.0.0:8788 --data /tmp/x --auth auto" + + +def _bash(script: str, *args: str) -> str: + """source svc.sh 后在 bash 内执行脚本,返回 stdout(stderr 丢弃)。""" + proc = subprocess.run( + ["bash", "-c", script, "svc-test", str(SVC), *args], + capture_output=True, + text=True, + cwd=str(REPO), + env={**os.environ, "LC_ALL": "C"}, + timeout=30, + ) + if proc.returncode != 0: + raise AssertionError( + f"bash 脚本失败 rc={proc.returncode}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" + ) + return proc.stdout + + +def _authed(args: str, dsn_env: str = "") -> bool: + out = _bash( + 'source "$1"; shift; if agent_args_authed "$1" "${2:-}"; then echo authed; else echo denied; fi', + args, + dsn_env, + ) + return out.strip() == "authed" + + +def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788", + insecure: str = "", dsn_env: str = "") -> bool: + out = _bash( + "source \"$1\"; shift; if agent_listen_rejected \"$1\" \"$2\" \"$3\" \"$4\"; " + "then echo rejected; else echo allowed; fi", + exec_args, + default_addr, + insecure, + dsn_env, + ) + return out.strip() == "rejected" + + +class AgentArgsAuthedMatrix(unittest.TestCase): + """认证判定与二进制 AuthEnabled 口径一致(config.go:301,默认 auth=auto)。""" + + def test_auth_on_explicit(self): + self.assertTrue(_authed("--addr 1.2.3.4:80 --auth on")) + self.assertTrue(_authed("--addr 1.2.3.4:80 --auth=on")) + + def test_auth_off_beats_env_dsn(self): + # 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。 + self.assertFalse(_authed("--auth off", PG_DSN)) + self.assertFalse(_authed("--auth=off", PG_DSN)) + + def test_auto_with_env_dsn_is_authed(self): + # 2026-10-10 误拒主场景:dbref 重建/G1-7 迁移后 DSN 只在环境变量里。 + self.assertTrue(_authed("--auth auto", PG_DSN)) + + def test_default_auto_with_env_dsn_is_authed(self): + # exec= 里连 --auth 都没有(config 默认 auto):env DSN 即认证开。 + self.assertTrue(_authed(NONLOOP_EXEC, PG_DSN)) + + def test_auto_without_dsn_is_denied(self): + self.assertFalse(_authed("--auth auto")) + self.assertFalse(_authed(NONLOOP_EXEC)) + + def test_legacy_cli_dsn_still_authed(self): + # 向后兼容:历史 exec= 的命令行 --dsn 仍是合法 DSN 来源。 + self.assertTrue(_authed(f"--addr 1.2.3.4:80 --dsn {PG_DSN}")) + + def test_no_auth_no_dsn_denied(self): + self.assertFalse(_authed("--addr 127.0.0.1:80 --mock")) + + +class AgentListenRejectedPrecheck(unittest.TestCase): + """预检整体判定:非回环 + 未放行 + 认证关才拒绝。""" + + def test_incident_combo_not_rejected(self): + # 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。 + self.assertFalse(_rejected(NONLOOP_EXEC, dsn_env=PG_DSN)) + + def test_nonloop_no_dsn_rejected(self): + self.assertTrue(_rejected(NONLOOP_EXEC)) + + def test_nonloop_auth_off_with_env_dsn_rejected(self): + self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off", dsn_env=PG_DSN)) + + def test_loopback_not_rejected(self): + self.assertFalse(_rejected("--addr 127.0.0.1:8788")) + + def test_insecure_not_rejected(self): + self.assertFalse(_rejected(NONLOOP_EXEC, insecure="1")) + + def test_addr_defaults_to_default_addr(self): + # exec 参数里没有 --addr 时按 DEFAULT_ADDR 判。 + self.assertTrue(_rejected("--data /tmp/x", default_addr="0.0.0.0:8788")) + self.assertFalse(_rejected("--data /tmp/x", default_addr="127.0.0.1:8788")) + + +class AgentReuseExec(unittest.TestCase): + """复用启动的 DSN 解析:G1-7 迁移 + dbref 重建(防静默回落 SQLite)。""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + rt = Path(self._tmp.name) / "runtime" + (rt / "pg1").mkdir(parents=True) + (rt / "pg1" / "svc.env").write_text( + f"kind=postgres\naddr={PG_ADDR}\nuser={PG_USER}\npassword={PG_PASS}\n" + ) + (rt / "dev").mkdir() + self.rt = rt + self.dev_dir = rt / "dev" + + def tearDown(self): + self._tmp.cleanup() + + def _reuse(self, exec_line: str) -> tuple: + # 直接调用(非 $()):agent_reuse_exec 经 nameref 回写 exec 参数、 + # 全局 DSN_ENV 传 DSN——命令替换会把 DSN_ENV 赋值留在子壳。 + out = _bash( + 'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; DSN_ENV=""; ' + 'agent_reuse_exec "$3" exec_args; ' + 'printf "EXEC=%s\\n" "$exec_args"; printf "DSN=%s\\n" "${DSN_ENV:-}"', + str(self.rt), + str(self.dev_dir), + exec_line, + ) + lines = dict(part.split("=", 1) for part in out.strip().splitlines()) + return lines["EXEC"], lines["DSN"] + + def _write_dev_env(self, exec_line: str, dbref: str = "") -> None: + content = f"kind=agent\nenv=dev\nexec={exec_line}\n" + if dbref: + content += f"dbref={dbref}\n" + (self.dev_dir / "svc.env").write_text(content) + + def test_post_g17_exec_restores_dsn_from_dbref(self): + # G1-7 后 exec= 不含 DSN:复用启动必须从 dbref= 重建(否则回落 SQLite)。 + self._write_dev_env("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb") + exec_args, dsn = self._reuse("--addr 0.0.0.0:8788 --auth auto") + self.assertEqual(dsn, PG_DSN) + self.assertNotIn("--dsn", exec_args) + + def test_legacy_exec_dsn_migrates_to_env(self): + # 存量 exec= 的明文 --dsn:剥到环境变量,命令行不再可见。 + legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}" + self._write_dev_env(legacy, dbref="pg1/agentdb") + exec_args, dsn = self._reuse(legacy) + self.assertEqual(dsn, PG_DSN) + self.assertNotIn("--dsn", exec_args) + self.assertEqual(exec_args, "--addr 0.0.0.0:8788") + + def test_migration_dsn_wins_over_dbref(self): + # 命令行 --dsn 与 dbref 并存:迁移值优先,dbref 不覆盖。 + other = "postgresql://other:pw@10.9.9.9:5432/otherdb" + legacy = f"--addr 0.0.0.0:8788 --dsn {other}" + self._write_dev_env(legacy, dbref="pg1/agentdb") + _, dsn = self._reuse(legacy) + self.assertEqual(dsn, other) + + def test_no_dsn_no_dbref_stays_empty(self): + self._write_dev_env("--addr 127.0.0.1:8788") + exec_args, dsn = self._reuse("--addr 127.0.0.1:8788") + self.assertEqual(dsn, "") + self.assertEqual(exec_args, "--addr 127.0.0.1:8788") + + def test_broken_dbref_warns_and_starts_unbound(self): + # 实例登记不全:不伪造 DSN,保持为空(启动侧另有告警)。 + self._write_dev_env("--addr 127.0.0.1:8788", dbref="missing/agentdb") + _, dsn = self._reuse("--addr 127.0.0.1:8788") + self.assertEqual(dsn, "") + + +class DsnEnvScopeIsolation(unittest.TestCase): + """DSN_ENV 每次启动隔离(agent_do_start 内 local 化,防 update --all 串库)。""" + + def test_agent_do_start_localizes_dsn_env(self): + # 棘轮:agent_do_start 必须以 local DSN_ENV="" 起始,删除即回归 + # 「同进程连续启动跨服务泄漏 AGENT_SYSTEM_DSN」。 + out = _bash( + 'source "$1"; declare -f agent_do_start | grep -c \'local exec_args DSN_ENV=""\'' + ) + self.assertEqual(out.strip(), "1") + + +if __name__ == "__main__": + unittest.main()