# -*- coding: utf-8 -*- """验证 ProdGuard 真的会拒绝启动(用完即删)。 ## 为什么这条验证不能省 `ProdGuard` 的价值**完全**在于"配错时起不来"。如果它其实不生效 (比如 profile 判断写错、或者异常被 Spring 吞掉后照样启动), 那它就只是一段看起来很尽责的注释 —— 而**没有任何别的方式能发现这件事**: 配错的生产环境会照常启动、照常服务,直到某天出事。 所以要真的用 prod profile 起一次,看它是否 ① 拒绝启动 ② 报的是那几条。 ## 两种情形都试 1. **什么都没配** → 应该拒绝,且 6 条问题里至少报出 5 条; 2. **只修一半**(把 stub 关了、但密钥还是 dev 那把)→ 仍应拒绝, 且报错里要点名"用的还是开发环境那把"。 """ import os import re import subprocess import sys import time sys.stdout.reconfigure(encoding="utf-8") ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) JAR = os.path.join(ROOT, "backend", "xt-server", "target", "xt-server-1.0.0-SNAPSHOT.jar") if not os.path.exists(JAR): raise SystemExit("先 mvn package:" + JAR) PASSED = [] FAILED = [] def kill_tree(proc): """ 杀掉整个进程树(Windows 用 taskkill /T)。 ⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程; 更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**, 所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净, 再靠下面的 preclean_port 兜住剩下的。 """ if proc is None or proc.poll() is not None: return try: subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True, timeout=20) except Exception: try: proc.kill() except Exception: pass def preclean_port(port): """ 开跑前清掉这个端口上的残留 java。 ⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** —— 残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。 不清端口 = 残留永远不会被发现。 """ try: r = subprocess.run( ["powershell", "-NoProfile", "-Command", "(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)" ".OwningProcess" % port], capture_output=True, text=True, timeout=30) for line in (r.stdout or "").split(): pid = line.strip() if pid.isdigit(): subprocess.run(["taskkill", "/F", "/T", "/PID", pid], capture_output=True, timeout=20) print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid)) except Exception: pass def ok(n): PASSED.append(n) print(" [OK] " + n) def fail(n, d): FAILED.append((n, d)) print(" [FAIL] " + n + "\n " + d) def check(n, cond, d=""): ok(n) if cond else fail(n, d) def start_prod(extra_env, seconds=45, extra_args=None): """用 prod profile 起一次,抓输出后杀掉。返回 (是否起来了, 输出)。""" env = dict(os.environ) env.update(extra_env) # 让它在真正的"缺密钥"状态下启动:清掉可能存在的 dev 环境变量 for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY", "XT_RSA_PRIVATE_KEY", "PG_PASSWORD", "REDIS_PASSWORD"): env.pop(k, None) env.update(extra_env) preclean_port(18099) p = subprocess.Popen( ["java", "-jar", JAR, "--spring.profiles.active=prod", "--server.port=18099"] + (extra_args or []), cwd=os.path.join(ROOT, "backend"), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace") out = [] started = False deadline = time.time() + seconds try: while time.time() < deadline: line = p.stdout.readline() if not line: if p.poll() is not None: break continue out.append(line) if "Started XtServerApplication" in line or "Tomcat started" in line: started = True break if p.poll() is not None: break finally: kill_tree(p) try: rest = p.stdout.read() or "" out.append(rest) except Exception: pass return started, "".join(out) print("=" * 74) print(" ProdGuard:生产环境配错时应当拒绝启动") print("=" * 74) print("\n[1] 什么都没配(缺全部密钥)") started, out = start_prod({}) check("⭐ **拒绝启动**(真的没起来)", not started, "它居然起来了 —— 那 ProdGuard 等于没有") # 场景 1 里 prod 配置把 stub 写成 false、存储写成 qiniu, # 所以这两条**不该**出现在清单里(它们由场景 3 显式覆盖来验) check("⭐ 六个必需变量都点名了", all(k in out for k in ("XT_JWT_SECRET", "XT_AES_KEY", "XT_HMAC_KEY", "XT_RSA_PRIVATE_KEY", "PG_PASSWORD", "REDIS_PASSWORD")), out[:1500]) check("⭐ **不再**出现「占位符被当成密钥长度」那种自造假问题" "(`${XT_JWT_SECRET}` 正好 16 字符,不排除就会多报一条「太短」)", "太短" not in out, out[:1500]) check("⭐ 报了「配错了也能启动、运行时完全不报错」这句话(让人知道为什么要拦)", "配错了也能启动" in out, out[-400:]) print("\n[2] 只修一半:密钥仍是 dev 那把") dev_secret = "BC0LUCKD3T4FfgMioHyARklQBa5phAXrYmzJStAwKiqftTJGURd4UblkjXFF+m8ZtaIC6BSeXKN2wuqBlkHPbA==" started2, out2 = start_prod({ "XT_JWT_SECRET": dev_secret, "XT_AES_KEY": "CaJuBbvbSKkLGn+d3WfWcmbkZE48NHglssoW53fKbrY=", "XT_HMAC_KEY": "1x75SulC+e+dQYZMEJMoVLf+E6KmgE0lUv6ShfJ6FDzNJdtJpmaxacPmX+/qCzhS", "XT_RSA_PRIVATE_KEY": "x", "PG_PASSWORD": "x", "REDIS_PASSWORD": "x", }) check("⭐ 仍**拒绝启动**(不是「有值就行」,而是「不能是 dev 那把」)", not started2, "它起来了 —— 那说明只检查了「非空」") check("⭐ 报错写明「用的还是开发环境那把」并说清后果(可伪造任意 token)", "开发环境那把" in out2 and "伪造" in out2, out2[-600:]) print("\n[3] 有人为了排障把后门打开、然后忘了关") started3, out3 = start_prod( {"XT_JWT_SECRET": "a" * 80, "XT_AES_KEY": "b" * 44, "XT_HMAC_KEY": "c" * 44, "XT_RSA_PRIVATE_KEY": "d" * 100, "PG_PASSWORD": "x", "REDIS_PASSWORD": "y"}, extra_args=["--fill.mp-h5.stub-enabled=true", "--fill.storage.provider=local", "--springdoc.api-docs.enabled=true"]) check("⭐ 拒绝启动(密钥齐全也不行 —— 后门/本地存储/接口文档开着就不许上)", not started3, "它起来了") check("⭐ 三条都点名了,并说清了后果", "stub-enabled" in out3 and "storage.provider" in out3 and "api-docs" in out3, out3[:1500]) check("⭐ 文案里说清 stub 的后果是「任何人可以登录任何学生」", "任何人可以登录任何学生" in out3, out3[:1500]) print("\n" + "=" * 74) print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED))) print("=" * 74) for n, d in FAILED: print(" x " + n + "\n " + d) sys.stdout.flush() raise SystemExit(1 if FAILED else 0)