From 471b61d2778a242726d0b6d4e8c76abca35fcd5e Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Sun, 20 Sep 2026 04:31:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(test)+fix(deploy)+docs:=20=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=8A=B6=E6=80=81=E8=AF=AD=E4=B9=89=E5=8F=8D=E8=BD=AC?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E9=80=80=E5=87=BA=E7=A0=81=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E3=80=81=E6=96=87=E6=A1=A3=E6=A0=A1=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) probe_export.py:任务状态语义**搞反了**(导出成功也会被判失败) - download_tasks.status 的权威语义是 0 排队 / 1 处理中 / **2 完成** / 3 失败 (db/migrate.rs:225 列注释;写入侧 charge_records.rs::mark_task_completed 写的就是 2)。 - 该脚本此前**等 status == 1(处理中)当成功、把 status == 2(完成)判成"导出失败"** —— 典型假红。已按权威语义订正,并与另外两处**本来就正确**的口径对齐: web/src/pages/charge-records/DownloadCenter.tsx::statusText、 test/schema_alignment_e2e.py(等 status in (2,3) 再断言 == 2)。 - 同批修正退出码分类:账号缺 charge_record:export(403)由 exit 1 改为 **exit 2「未验证」** —— 本项要验证的是"降权成 pms 后能否往 exports/ 落盘", 没权限根本走不到那一步,报 1 是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。 - 允许用 PMS_PROBE_PHONE / PMS_PROBE_PASSWORD 换成有权限的账号。 - **C 项结论(由落盘物证直接判定,不依赖该脚本)**:测试环境 exports/充电记录__-.xlsx 5559 B、生产同类产物 5457 B,属主均为 pms:pms; 两环境 exports/uploads 目录均 pms:pms,两 api-server 单元均 User=pms/Group=pms ⇒ 降权进程确实能落盘。已在 README 写明并给出取证依据。 2) deploy-prod.ps1 的退出码(此前失败的探针只打印 ❌ 就继续,脚本仍以 0 退出) - 消费链路探测失败(exit 1)现在**记入失败清单**并以 exit 1 收尾; "未验证"(隧道没起)默认仍不计失败(环境问题会误报), 但新增 **-StrictVerify** 开关供 CI/无人值守使用,打开即 exit 1。 - 好处:把这条从"打印了 ❌ 却 exit 0"(仓库第 8 条假绿)里摘出来。 3) 统一退出码 0/1/2 落地到本批脚本,并对每个失败路径实测取证: - test/tcp_e2e_smoke.py:13/13 通过(含未登录业务报文被静默丢弃);先复位夹具再跑。 - test/security-hardening/ 新增 verdict.py 判据容器(未跟踪 → 本次入库); 4 个探针全部按失败→exit 1 改造,并各实测过失败路径。 - 实测结果(2026-09-20 发布后回归):smoke_prod 4/4 exit 0、 probe_prod 5/5 exit 0、probe_upload 6/6 exit 0。 4) 工具与文档 - tools/db.py 默认库由**生产 pms 改为测试 pms_dev**(可用 PMS_MYSQL_DB 覆盖, 切生产时打印醒目警告)—— "以为在测测试库、实际打生产"的隐患。 - tools/db_schema_diff.py:连不上必须是 exit 2,绝不伪装成"无漂移"的 0。 - 新增 tools/dev-tunnel.ps1(入库,SSH 隧道;口令走 PMS_SSH_PASSWORD 环境变量), 配套 tools/dev-tunnel-run.ps1 自愈启动器**保持 gitignore**(内含口令)。 - deploy/nginx/nginx.conf 镜像随线上同步(移除 stream 转发;与线上逐字节一致)。 - AGENTS.md / README.md:补「踩过的坑」第 7/8 条(掐断上游管道会杀被测进程、 打印 ❌ 却 exit 0 是假绿)、退出码约定、本地开发改 SSH 隧道、 在线判据口径订正、order_timeout 守门补齐、小程序 vue-tsc 门禁与其抓到的两个真 BUG。 发布验收(本次统一发布,按 device→api 顺序): - 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0; - 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释; - 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0); - schema_alignment_e2e --env dev 全部通过 exit 0; - 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。 --- .gitignore | 1 + AGENTS.md | 247 ++++++++++++++++---- README.md | 9 +- deploy/deploy-prod.ps1 | 71 +++++- deploy/nginx/nginx.conf | 24 +- test/reset_fixture.py | 32 ++- test/schema_alignment_e2e.py | 8 +- test/security-hardening/README.md | 45 +++- test/security-hardening/make_test_user.py | 6 +- test/security-hardening/probe_bruteforce.py | 24 +- test/security-hardening/probe_export.py | 61 ++++- test/security-hardening/probe_prod.py | 11 +- test/security-hardening/probe_upload.py | 20 +- test/security-hardening/smoke_prod.py | 12 +- test/security-hardening/verdict.py | 69 ++++++ test/tcp_e2e_smoke.py | 10 + test/tcp_latency_probe.py | 46 +++- tools/add_cabinets.py | 8 +- tools/add_sub_orgs.py | 9 +- tools/db.py | 40 +++- tools/db_schema_diff.py | 16 +- tools/dev-tunnel.ps1 | 28 +++ tools/mock_redis.py | 10 +- tools/update_cabinets.py | 8 +- tools/update_iccid.py | 8 +- tools/update_imei_iccid.py | 8 +- 26 files changed, 702 insertions(+), 129 deletions(-) create mode 100644 test/security-hardening/verdict.py create mode 100644 tools/dev-tunnel.ps1 diff --git a/.gitignore b/.gitignore index 9bf3bb8..84a2a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ tmp/ deploy_output.txt deploy_remote.sh /tmp/ +tools/dev-tunnel-run.ps1 diff --git a/AGENTS.md b/AGENTS.md index 4e74e92..9f38215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,8 @@ D:\charging-cabinet\ │ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口) │ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」) ├── tools/ # 开发工具 -│ ├── db.py # 数据库工具(默认连测试库 pms_dev) +│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道) +│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis) │ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL) ├── software/ │ ├── api-server/ # Rust API服务(产物 pms-api) @@ -91,7 +92,8 @@ D:\charging-cabinet\ - **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252` - **本地电脑ZeroTier IP**: `10.8.0.248` - **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启) -- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh +- **服务**: Gitea(3080)、acme.sh + (⛔ 曾经的 `mysql-proxy(3306→RDS)`、`redis-proxy(6379→Redis)` 一律**已移除**,见「本地开发环境」) - **不装**: Docker/Node/Rust(本地编译传二进制) - **SSH**: plink连接,首次需 `echo y` 接受host key @@ -102,36 +104,83 @@ D:\charging-cabinet\ ### 本地开发环境 -**数据库连接(通过ZeroTier):** +**数据库连接(通过 SSH 隧道):** + +服务器 nginx 的 `stream{}` 转发(把 3306/6379 转到阿里云 RDS/Redis)**已于 2026-09-20 移除**: + +- `10.8.0.252:3306` / `10.8.0.252:6379`(含 `127.0.0.1` 回环监听)**现在完全不可连**(实测 ✅); +- 因此**本地/开发侧一律改走 SSH 隧道** —— `tools/dev-tunnel.ps1`: + +```bash +# ① 手工起隧道(前台常驻一个窗口;需先设 PMS_SSH_PASSWORD = 服务器 SSH 口令) +$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>' +pwsh tools/dev-tunnel.ps1 +# = plink -N -L 13306:<阿里云MySQL>:3306 -L 16379:<阿里云Redis>:6379 root@118.31.119.206 + +# ② 常驻自愈启动器(推荐日常用):断了 5 秒后自动重连 +pwsh tools/dev-tunnel-run.ps1 ``` -MySQL: 10.8.0.252:3306 + +**隧道两个脚本的分工(换机/重装后照这个重建):** + +| 文件 | 作用 | 是否入库 | +|------|------|----------| +| `tools/dev-tunnel.ps1` | 建隧道本体(`plink -N -L …`);默认 `127.0.0.1:13306`(MySQL) / `127.0.0.1:16379`(Redis),口令从 `$env:PMS_SSH_PASSWORD` 读 | ✅ 入库 | +| `tools/dev-tunnel-run.ps1` | **自愈启动器**:`while($true){ 跑 dev-tunnel.ps1; sleep 5 }`,**内含口令** | ⛔ **已 gitignore,勿入库** | + +- 日常已把 `dev-tunnel-run.ps1` 的快捷方式放进 **Windows 启动文件夹** + (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\PMS-DEV-TUNNEL.lnk`)→ **登录即起、断线自愈**; + 它的输出追加到 `%TEMP%\pms-dev-tunnel.log`(**排查时先看这个日志**)。 +- **换机 / 重装后**:设好 `PMS_SSH_PASSWORD` 跑一次 `pwsh tools/dev-tunnel.ps1` 即可; + 要恢复自愈+开机自启,就在启动文件夹放一个指向 `tools/dev-tunnel-run.ps1` + (`pwsh -NoProfile -WindowStyle Hidden -File <仓库>\tools\dev-tunnel-run.ps1`)的快捷方式。 +- 端口/主机名**只在 `dev-tunnel.ps1` 的 `param` 里定义一份**(默认与各脚本的环境变量默认值一致), + 别在别处再写第二套默认值。 +- ⚠️ 该脚本历史上有个"**日志骗人**"的坑(已修):`"... -> $MySqlHost:3306"` 被 PowerShell 当成 + `$作用域:名字` 的变量,主机名渲染成空串(隧道其实正常)。变量后紧跟 `:` 必须写 `"${MySqlHost}:3306"`。 + +```text +MySQL: 127.0.0.1:13306 (经隧道 → rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306) user: root password: Hbhyg731024@ - database: pms_dev (新建,不动AZZCWeChat*) + database: pms_dev (测试库) / pms (正式库) -Redis: 10.8.0.252:6379 +Redis: 127.0.0.1:16379 (经隧道 → r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379) password: Hbhyg731024@ - db: 6 (测试环境用db6,不动db1-5) + db: 6 (测试环境用db6,不动db1-5) / 5 (正式) ``` -**代理说明:** -- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS —— - ⚠️ **这个转发只为「本地开发机经 ZeroTier 访问阿里云」而存在**(监听 `10.8.0.252:3306/6379` 等)。 - **服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连阿里云**: +所有本地脚本(`tools/db.py`、`tools/db_schema_diff.py`、`test/*.py`)**默认就指向这两个隧道端口**, +可用环境变量覆盖(无需改代码):`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / `PMS_MYSQL_DB`、 +`PMS_REDIS_HOST` / `PMS_REDIS_PORT`(`db.py` 默认库 = **测试库 `pms_dev`**)。 +⚠️ 隧道没起时,脚本会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 **exit 2** +(**连不上绝不会被当成"无漂移/无问题"**)。 + +**为什么服务侧直连阿里云(2026-09-20 已改,勿回退):** + +- 走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时 + `RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR); +- 更要紧的是**生产的库/缓存通路会依赖一个本来只给开发用的转发层** + (`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。 +- 服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连: `DATABASE_URL=...@rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306/...`、 - `REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...` - (2026-09-20 已改;改前生产走的是 `127.0.0.1:6379/3306` —— 那两个 **nginx 监听**的回环端口)。 - **为什么必须直连**:走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时 - `RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);更要紧的是**生产的库/缓存通路会依赖一个 - 本来只给开发用的转发层**(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。 + `REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...`。 改造后实测:reload nginx 前后生产连接数不变(5→5)、生产与测试 ERROR 均为 0。 - `127.0.0.1` 的 stream 监听**保留**(无副作用,本地开发/回环排查仍可用),但**服务不再用它** -- systemd自启,`Restart=always` 自动恢复 -- 本地直连 `10.8.0.252`,无需SSH隧道 +- ⛔ **服务器不再开放 3306/6379**(ZeroTier 上也连不到了);要给开发机开库通路就用 SSH 隧道, + **不要在服务器上重新暴露库端口**(见「部署注意事项」第 6 条)。 +- 设备 TCP 业务端口(`:9001` / `:3001`)**不受影响**,仍按原样可达(设备走公网连它)。 +- systemd 自启,`Restart=always` 自动恢复。 ### 数据库工具 -本地可直接访问数据库(通过ZeroTier),使用 `tools/db.py`: +⚠️ **需先起隧道**:`pwsh tools/dev-tunnel.ps1`(默认连 `127.0.0.1:13306`,见上节)。 +连接参数可用环境变量覆盖:`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / **`PMS_MYSQL_DB`**; +`db_schema_diff.py` 的 `--host/--port` **优先级更高**(命令行 > 环境变量 > 默认隧道端口)。使用 `tools/db.py`: + +> **`tools/db.py` 的库口径(2026-09-20 定稿)**:默认 **`pms_dev`(测试库)**,可用 `PMS_MYSQL_DB` 覆盖; +> 每次连接前都会打印 `当前目标库:xxx(可用 PMS_MYSQL_DB 覆盖)`, +> **若取 `pms`(生产库)会额外打印 `⚠️ 你正在操作正式库 pms …`**。 +> (此前代码默认写死 `pms`、文档却写 `pms_dev` —— "以为在测测试库、实际打生产"的隐患,已按默认测试库修正。) ```bash # 列出所有表 @@ -150,7 +199,7 @@ python tools/db.py exec "UPDATE users SET status=1 WHERE id=1" python tools/db.py import data.sql ``` -**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**: +**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次;同样需先起隧道)**: ```bash # 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检) @@ -161,6 +210,8 @@ python tools/db_schema_diff.py --vs-ddl python tools/db_schema_diff.py --db pms --vs-ddl ``` 退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。 +⛔ **连不上时必须是 `2`,绝不能当成"无漂移"的 `0`**(隧道没起就跑,会明确报 +「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」)。 > 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、 > `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500), > 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**, @@ -204,7 +255,8 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret --yes-p - **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知 - **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制 -- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理 +- **禁止使用socat fork模式** — 进程泄漏会耗尽内存;服务侧一律**直连阿里云**, + 开发机要连库走 **SSH 隧道**(`tools/dev-tunnel.ps1`),⛔ 不要再在服务器上做端口转发 - **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore) ### 编码任务下发 @@ -270,6 +322,20 @@ node test/rssi_signal.test.mjs > ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**, > 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。 +**退出码约定(所有验收/工具脚本统一,2026-09-20 定稿)**: + +| 退出码 | 含义 | +|--------|------| +| `0` | 全部通过 | +| `1` | **断言/判据失败**(必须把失败项额外打到 stderr) | +| `2` | **环境/连接问题**(隧道没起、DB/Redis/后端连不上、缺 `.env`) | + +⛔ **适用于本仓每一个会被自动化调用的脚本**(`tools/*.py`、`test/*.py`、`test/*.mjs`、`deploy/*.ps1`、`*.sh`): +**"失败时退出码非 0"是硬性验收项 —— 上线前必须像上面那样自证一遍**(故意造缺陷 → 确认非 0 → 撤销)。 +只打印 `❌`/`合计 N/M` 而不设退出码 = **假绿**(`test/tcp_e2e_smoke.py` 曾长期如此,见「踩过的坑」第 7/8 条); +探针脚本若无明确判据,必须**显式声明"纯取证、不可作判据"**,不许默默 `exit 0`。 +现象阈值拿不准的(如限流第几次触发)**不要写成判据**,保持观察输出 —— **误报与假绿同样有害**。 + --- ## 踩过的坑(历史教训,改动前必读) @@ -295,9 +361,16 @@ node test/rssi_signal.test.mjs 做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中), 并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。 已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。 - ⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法** - (`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内, + ✅ **两处现已都有守门测试(2026-09-20 补齐,勿再退回)**: + - `workers/report_worker.rs`:常量 `FINALIZE_EXPIRED_ORDER_SQL` + `test_expired_order_condition_points_backwards` + + `test_is_expired_two_way_assertions_catch_reversed_direction`; + - `workers/order_timeout.rs`:常量 `EXPIRED_ORDER_SCAN_SQL` + 同名两个守门用例 + (此前只在文档里留了「遗留」标记 —— 那边再被改反也一样零报错、单测全绿,属漏网)。 + **两处是等价但写法不同的判据**(前者把阈值往回绑**负数**,后者把 `start_time` **往前推**), **改动任一处都要同时确认另一处**。 + 已实测(2026-09-20):把 `order_timeout.rs` 的常量反写成 + `start_time < TIMESTAMPADD(HOUR, ?, NOW())` 后 → `test_expired_order_scan_sql_points_backwards` + **立即 FAILED、cargo test exit 101**;还原后 3 passed / exit 0。 ### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿 @@ -308,9 +381,21 @@ node test/rssi_signal.test.mjs `src/probe.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.`、**exit 2**。 - **正确命令**:`cd software/web && npm run typecheck`(`package.json` 里就是 `tsc -b`)。 `tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。 -- `software/h5-uniapp/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里 - 是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实 - 类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。 +- **小程序(`software/h5-uniapp`)的 typecheck 是真门禁(2026-09-20 新增)**: + `cd software/h5-uniapp && npm run typecheck`(= **`vue-tsc --noEmit -p tsconfig.json`**)。 + - ⛔ **不能用 `tsc --noEmit` 代替**:`tsc` **根本不检查 `.vue` 文件**,而本工程 9 个页面全是 `.vue` + ⇒ 只跑 `tsc` 等于把页面逻辑整个漏掉(与 solution-tsconfig 空跑同类的假绿)。 + - 必须用 `vue-tsc`(已作为 devDependency 装入,`package.json` 里有 `typecheck` 脚本)。 + - **接入时立刻抓到 17 个真实错误**(此前完全没人发现),其中两个是**真 BUG**: + ① `me/index.vue` 写了 `user.value`,而 Pinia setup store 对外已解包 ⇒ `user.value` 恒 `undefined`, + **头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);同时 `const user = authStore.user` + 是**快照**、丢掉响应式。已改为 `computed(() => authStore.user)`; + ② `CompartmentDetail` 类型**漏了后端确实会返回的 `temp`**(`routes/h5.rs` 的 + `COMPARTMENT_REALTIME_FIELDS` 第 6 项,有单测守门)⇒ 页面读 `data.temp` 报 TS2339。已补。 + 其余 12 处是宿主全局 `wx` 未声明(`@dcloudio/types` 只声明跨端 `uni`),已在 + `src/env.d.ts` 里**只声明实际用到的两个 API**(刻意不写 `[k: string]: any`,否则等于关掉这层检查)。 + - 已按仓库铁律**自证能失败**:植入 `export const probe: number = 'x'` → **exit 2**; + 撤销 → **exit 0**。 ### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime` 兼容) @@ -356,6 +441,32 @@ node test/rssi_signal.test.mjs - 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` / `device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。 +### 7. **验收取值别用会掐断上游管道的方式** —— `| Select-Object -First N` 会杀掉被测进程 + +- **实测(2026-09-20,我踩的)**:为看 `test/tcp_e2e_smoke.py` 的开头几行,写成 + `python test/tcp_e2e_smoke.py | Select-Object -First 8`。PowerShell 拿满 8 个对象后**立即停止上游管道** + ⇒ **python 被中途掐死**:脚本只跑到第 ② 步,既没跑完断言,也**没走清理/复位**,夹具被留在半路状态。 + 紧接着我又跑了一次"取最后 3 行"的命令,于是看到 **2/13**,差点当成"隧道改造引入了回归"。 +- **为什么危险**:这类假信号**看起来像被测对象坏了**,会把人引向完全错误的排查方向; + 更糟的是它**污染了夹具**(本次:`auth_str` 已签发未复位),使后续所有观测都不可信 —— + "测量工具本身改变了被测对象"。 +- **规范**: + 1. 要截取输出就**先落盘再截**:`python x.py *> $env:TEMP\out.txt` 然后 `Get-Content out.txt | Select-Object -First N` + (`*> file` 不会掐断上游;`Select-Object -Last N` 也不会,但统一落盘最省心); + 2. 看到"突然变红"先自问:**是不是我的取值方式改变了被测对象?**(进程被杀 / 夹具被留在中间态); + 3. 复现任何"红"之前先**把环境复位到干净态**(如先 `python test/reset_fixture.py`)再跑 —— + `test/tcp_e2e_smoke.py` 有前置条件:**必须先复位夹具**,否则第 ① 步 `auth_str already issued` 会连锁失败(2/13)。 + +### 8. **"打印了 ❌ 却 exit 0" 是假绿** —— 脚本不设退出码,自动化就把失败当通过 + +- **实测(2026-09-20)**:`test/tcp_e2e_smoke.py` 结尾只 `print("合计 N/13 通过")`,**没有 `sys.exit`** + ⇒ 断言 2/13 失败时 **退出码仍是 0**;`test/tcp_latency_probe.py` 打印 `❌ 75s 内无应答` 也是 exit 0; + `test/security-hardening/` 下 4 个探针(`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`) + 结尾写死 `return 0`。这些与 `tsc --noEmit` 空跑**同属一类假绿**,且**一直被当成"✅ 通过"写进验收报告**。 +- **规范**:见「验收流程」的**退出码约定(0/1/2)**——每个脚本都要"故意造缺陷 → 确认非 0 → 撤销"。 + 探针类脚本共用 `test/security-hardening/verdict.py`(判据容器 + 统一退出码), + 判据只写明确可复现的期望("应被拒" = `status != 200`),拿不准的保持观察输出。 + --- ## Rust 编码规范 @@ -372,8 +483,8 @@ node test/rssi_signal.test.mjs - 使用 `tokio` 运行时,数据库/Redis 操作用 `.await` - 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环 - Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** —— - 本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`, - 这种组合下 `BRPOP` 语义不可靠(实测 2026-09): + 本项目 Redis 此前经 nginx stream 代理访问阿里云(**该转发已于 2026-09-20 移除,现为直连**), + 客户端又是多路复用的 `ConnectionManager`,那种组合下 `BRPOP` 语义不可靠(实测 2026-09): - `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**; - `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。 **规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致), @@ -559,7 +670,17 @@ test_check_permission_user_returns_forbidden 依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。 **判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`, 在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位), - **不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。 + **不是** Redis 在线键 `device:{imei}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正): + - `device:{imei}.online` 的写入**都是在验签之后**:`online=1` 只由 api-server 的 + `report_worker.rs::handle_login` 在 `sign == expected_sign` 分支内(`mark_device_online`) + 以及 `handle_status_post`(`write_device_state`,该 act 已被登录门控挡住)写入; + `handle_auth_str` **不写** `online`。(此前文档写"验签前就会被写"是**错的**。) + - 真正不能拿它判下发的原因是**另一条**:它的 TTL 语义是"最近 300 秒收到过该设备任意报文" + (协议 §2.3),**断连后仍存活最多 5 分钟** ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。 + - 另注:`device-server` 的 `server.rs::update_device_route` 会在 `auth_str`/`login` **到达时** + 写 `device:{imei}` 的 `node_id` 并 `EXPIRE 300`——**只刷 TTL、不写 `online`**, + 效果是未认证报文也可能把 `online` 的存活期往后顺延一次;这与 §2.3「收到过任意报文即算活跃」 + 一致,故保留,但它进一步说明该键不能当"可下发"判据。 ⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。 - 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`) - **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`** @@ -661,47 +782,57 @@ echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@" ### 数据库连接 -所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。 +⛔ **不要再用 `10.8.0.252` 连库** —— 服务器 nginx `stream{}` 转发已于 2026-09-20 移除, +`10.8.0.252:3306/6379`(以及 `127.0.0.1` 回环)**都连不到了**(实测 ✅)。 +**所有本地数据库操作一律先起 SSH 隧道**(`pwsh tools/dev-tunnel.ps1`),再连 `127.0.0.1:13306/16379`。 -**MySQL(通过 ZeroTier):** +**MySQL(通过 SSH 隧道):** ```bash +# 先起隧道(另一个终端常驻) +pwsh tools/dev-tunnel.ps1 + # 命令行 -mysql -h 10.8.0.252 -u root -p +mysql -h 127.0.0.1 -P 13306 -u root -p # 密码:Hbhyg731024@ # 测试环境库:pms_dev # 正式环境库:pms ``` ```python -# Python(Agent 常用方式) -import pymysql -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 正式环境 -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境 +# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处 +import os, pymysql +host = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1') +port = int(os.environ.get('PMS_MYSQL_PORT', '13306')) +conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms') # 正式库 +conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms_dev') # 测试库 ``` -**Redis(通过 ZeroTier):** +**Redis(通过 SSH 隧道):** ```bash # 命令行 -redis-cli -h 10.8.0.252 -a "Hbhyg731024@" +redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@" # 测试环境:select 6 # 正式环境:select 5 ``` ```python -# Python(Agent 常用方式) -import redis -r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境 -r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境 +# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处 +import os, redis +host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1') +port = int(os.environ.get('PMS_REDIS_PORT', '16379')) +r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=5) # 正式环境 +r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=6) # 测试环境 ``` -**数据库工具 `tools/db.py`(默认连接测试库 pms_dev):** +**数据库工具 `tools/db.py`(需先起隧道):** ```bash python tools/db.py tables # 列出所有表 python tools/db.py count # 统计各表数据量 python tools/db.py query "SELECT * FROM users" # 查询 python tools/db.py exec "UPDATE users SET ..." # 写入 ``` -> 注意:`tools/db.py` 默认连的是 `pms_dev`(测试库),要操作正式库请用上面的 Python 直连方式。 +> **`tools/db.py` 默认连测试库 `pms_dev`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告); +> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。 ### Nginx配置 @@ -897,19 +1028,28 @@ scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境 实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。 5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的 响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。 -6. **`stream {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置 - `/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**: - nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。 - conf.d 下只放 `http` 上下文的 `server {}`。 +6. **stream 转发已移除(2026-09-20)—— 不要再给开发机开库端口** —— 主配置 + `/etc/nginx/nginx.conf` 里原来那个 `stream{}`(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis) + **已整块删除**;服务器不再监听 3306/6379(`10.8.0.252:3306/6379` 实测已不可连)。 + ⛔ **不要为了给开发机开库通路而在服务器上重新加 `stream{}` / socat / 任何端口转发** —— + 开发机要连库一律走 **SSH 隧道**(`pwsh tools/dev-tunnel.ps1`,见「本地开发环境」); + 服务侧(`/data/pms`、`/data/pms_dev`)本来就直连阿里云,也不需要转发层。 + **历史原因(务必记住)**:`stream{}` 全配置**只能有一个**、且只能在主配置里 —— + 历史上曾在 `conf.d/*.conf` 里又写了一个,nginx 以 + `"stream" directive is duplicate` **直接启动失败、整站不可用**(含生产域)。 + 所以:conf.d 下只放 `http` 上下文的 `server {}`;**主配置里现在应当完全没有 `stream{}`** + (若哪天又看到它,先查清是谁加的)。 7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来: `EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。 8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。 部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。 9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。 10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf` - (**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、116 行、3629 字节)。 + (**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、100 行、3733 字节、 + md5 `82f660d8ebd3299c89edf55483277266` —— 2026-09-20 移除 `stream{}` + 订正 stream 注释后重新对齐)。 `deploy/nginx/` 下三份分别是: - - `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `stream{}` / `worker_shutdown_timeout`) + - `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `worker_shutdown_timeout`; + ⚠️ 已无 `stream{}` —— 见第 6 条,别再加回去) - `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf` - `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf` @@ -1407,6 +1547,11 @@ const BASE_URL = '/api/pms' `cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。 端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`, 逐位遍历 0x00–0xFF 断言两端一致)。 +5. **`cabinets.status` 是「启用/禁用」配置态,不表示在线** —— `1` 启用 / `2` 禁用(`0` 是建表默认值 + 产生的取值,代码不显式写它,语义等同启用);实时在线一律以 Redis `device:{imei}.online` 为准 + (唯一权威判据 `routes/cabinet_tree.rs::device_online_flag`,接口字段 `is_online`), + `energy_stats` 的 `online_count`、H5 首页 `online_cabinets`、PC/小程序的「在线/离线」全部消费它 + (`CABINET_STATUS_MAP` 只保留启用/禁用两档,**不要再出现"离线"档**)。 ### 开发约束(uni-app 特定) diff --git a/README.md b/README.md index d1c582a..c653274 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,13 @@ charging-cabinet/ ├── tasks/ # 任务文件(opencode执行) ├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …) ├── deploy/ # 部署配置与脚本 -│ ├── nginx/ # Nginx 配置(app / test 两份,与线上一致) +│ ├── nginx/ # Nginx 配置镜像(nginx.conf 主配置 + app / test,共三份,与线上逐字节一致) │ ├── *.service # systemd 单元(pms-api / pms-device × prod·test) │ ├── deploy-prod.ps1 # 正式环境一键部署(推荐入口) │ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出) ├── tools/ # 开发工具 -│ ├── db.py # 数据库工具(默认连测试库 pms_dev) +│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道) +│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis) │ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL) ├── software/ │ ├── api-server/ # Rust API服务(产物 pms-api) @@ -107,7 +108,9 @@ charging-cabinet/ - **死信**:投递失败的指令进 `device:commands:dead` > ⚠️ **队列消费一律用 `RPOP` 非阻塞轮询(200ms)+ 空队列 `sleep`,禁止 `BRPOP`** —— -> Redis 经 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志)。 +> Redis 走 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志); +> 该 stream 转发已于 **2026-09-20 移除**(服务侧现直连阿里云 Redis,开发机走 `tools/dev-tunnel.ps1` 隧道), +> 但**轮询约定不变**。 > 依据:`workers/report_worker.rs` 的 `REDIS_POLL_INTERVAL_MS` 注释。 **节点管理:** diff --git a/deploy/deploy-prod.ps1 b/deploy/deploy-prod.ps1 index 04fa434..c6618e9 100644 --- a/deploy/deploy-prod.ps1 +++ b/deploy/deploy-prod.ps1 @@ -17,11 +17,21 @@ # pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署 # pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动 # pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端 +# pwsh deploy/deploy-prod.ps1 -StrictVerify # 【CI/无人值守请加】"未验证"也当作失败(退出码 1) +# +# 退出码(遵循仓库统一约定): +# 0 = 部署且全部验收通过 +# 1 = 有验收项失败(含 -StrictVerify 下的"未验证") +# 2 = 环境/连接问题(构建产物缺失、SSH/上传失败) # ============================================================================= param( [switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build [switch]$SkipWeb, # 跳过前端 - [switch]$DryRun # 只演练,不执行 + [switch]$DryRun, # 只演练,不执行 + # 把"未验证(通常是本地 SSH 隧道没起)"也当成失败:**默认关闭**是因为 + # "未验证"确实是环境问题而非部署问题,误报会让人习惯性忽略;但 CI/无人值守 + # 必须打开 —— 否则"探测根本没跑"会被当成绿灯(这正是仓库第 7/8 条假绿的形态)。 + [switch]$StrictVerify ) $ErrorActionPreference = 'Stop' @@ -136,18 +146,50 @@ journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^ "@ # ---- 7. 消费链路探测(无副作用)------------------------------------------- +# ⚠️ 本步骤的 probe.py 在**本地**执行,Redis 走 SSH 隧道(tools/dev-tunnel.ps1 的 +# 127.0.0.1:16379)—— 服务器已不再开放 3306/6379(nginx stream 转发已移除)。 +# 连不上时**必须显示为"未验证",绝不伪装成通过**(否则部署会带着未验证的链路报成功)。 +$Unverified = @() +$Failures = @() Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)' if (-not $DryRun) { $probe = @' -import redis, json, time -r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5) -r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1})) +import os, json, time +import redis + +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +host = os.environ.get("PMS_REDIS_HOST", "127.0.0.1") +port = int(os.environ.get("PMS_REDIS_PORT", "16379")) +try: + r = redis.Redis(host=host, port=port, password="Hbhyg731024@", db=5, socket_timeout=5) + r.ping() +except Exception as e: + # 连不上 ≠ 通过:退出码 3 = "本步骤未验证",与 1 = "消费未工作" 明确区分 + print(" ⚠️ 未验证:连不上 Redis %s:%s(%s)" % (host, port, e)) + print(" 连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道" + "(或设置 PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)") + raise SystemExit(3) +r.lpush("device:reports", json.dumps({"act": "auth_str", "dev_id": "899999999999999", "msg_id": 1})) time.sleep(3) -left = r.llen('device:reports') -print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}") +left = r.llen("device:reports") +if left == 0: + print(" 队列剩余=0 -> ✅ 消费正常") + raise SystemExit(0) +print(" 队列剩余=%d -> ❌ 消费未工作,请立即回滚" % left) +raise SystemExit(1) '@ $probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py" python "$env:TEMP\pms_probe.py" + switch ($LASTEXITCODE) { + 0 { } + 3 { $Unverified += '上报消费链路探测(Redis 不可达 —— 见上方 ⚠️,通常是本地 SSH 隧道未建立)' } + default { + Say "❌ 上报消费链路探测失败(exit $LASTEXITCODE)—— 请立即回滚(见上方输出)" + # ⚠️ 这里必须**记入失败清单**:此前只打印 ❌ 就继续往下走,脚本最终以 0 退出, + # 于是"消费链路已经断了"会被 CI/人当成"部署成功"(仓库第 7/8 条假绿)。 + $Failures += "上报消费链路探测失败(exit $LASTEXITCODE)—— 消费已停摆,需回滚" + } + } } # ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)-------------- @@ -213,9 +255,26 @@ echo " $Dst 占用: `$(du -sh $Dst | cut -f1)" "@ Write-Host "`n=== 部署完成 ===" -ForegroundColor Green +if ($Unverified.Count -gt 0) { + Write-Host "⚠️ 有步骤【未验证】(不是通过,请勿当作绿灯):" -ForegroundColor Yellow + foreach ($u in $Unverified) { Say " - $u" } + Say ' 处理:先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道,再重跑本步确认。' + if ($StrictVerify) { + Write-Host " ❌ -StrictVerify 已开启:未验证视为失败(退出码 1)" -ForegroundColor Red + } +} +if ($Failures.Count -gt 0) { + Write-Host "❌ 以下验收项**失败**:" -ForegroundColor Red + foreach ($f in $Failures) { Say " - $f" } +} Say "正式环境:https://$Domain/pms/" Say "API :$ApiPort TCP :$TcpPort" Say '' Say '回滚方法(如需):' Say " plink -batch -pw root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-/* /data/pms/... && systemctl start pms-api pms-device`"" Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)" + +# 退出码:失败优先;-StrictVerify 下"未验证"也算失败。 +if ($Failures.Count -gt 0) { exit 1 } +if ($StrictVerify -and $Unverified.Count -gt 0) { exit 1 } +exit 0 diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf index b0a15ef..a9664f0 100644 --- a/deploy/nginx/nginx.conf +++ b/deploy/nginx/nginx.conf @@ -21,8 +21,10 @@ http { # ⛔ 三条硬性约束(任一条违反都会让整站起不来或行为异常,历史已各踩过一次): # 1. limit_req_zone 必须写在 http{} 内 —— conf.d/*.conf 的 location 引用了这些 zone, # 引用未定义 zone 会让 nginx -t 直接失败; - # 2. stream{} 全配置**只能有一个**(MySQL 3306 / Redis 6379 反代就在本文件下面那个), - # ⛔ 不要在 conf.d/*.conf 里再写一个(报 stream directive is duplicate,整站不可用); + # 2. ⛔ stream 转发已移除(2026-09-20):原 MySQL 3306 / Redis 6379 反代(本文件下方那个) + # 已整块删除,服务器不再监听库端口;开发机连库走 SSH 隧道(tools/dev-tunnel.ps1)。 + # 不要在**本文件或 conf.d/*.conf** 里重新加 stream{}(全配置只能有一个, + # 写重复会报 stream directive is duplicate,整站不可用)。 # 3. worker_shutdown_timeout 30s 不要调短 —— 调短会中断正在传输的响应。 limit_req_zone $binary_remote_addr zone=auth_login_zone:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=auth_challenge_zone:10m rate=20r/m; @@ -96,21 +98,3 @@ http { # } } - - -stream { - server { - listen 127.0.0.1:3306; - listen 10.8.0.252:3306; - proxy_pass rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306; - proxy_timeout 10m; - proxy_connect_timeout 10s; - } - server { - listen 127.0.0.1:6379; - listen 10.8.0.252:6379; - proxy_pass r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379; - proxy_timeout 10m; - proxy_connect_timeout 10s; - } -} diff --git a/test/reset_fixture.py b/test/reset_fixture.py index 036658c..9c3cb14 100644 --- a/test/reset_fixture.py +++ b/test/reset_fixture.py @@ -6,20 +6,44 @@ 3. 结束该柜未结束的充电订单(`active_flag` 是生成列,由 `end_time` 派生,不能直接写), 避免 ⑤ `on` 撞上 `active_flag` 唯一键 """ +import os +import sys + import pymysql import redis IMEI = '860000000000001' CABINET_ID = 1 -r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6, decode_responses=True) -keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}'] +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +REDIS_HOST = os.environ.get('PMS_REDIS_HOST', '127.0.0.1') +REDIS_PORT = int(os.environ.get('PMS_REDIS_PORT', '16379')) +MYSQL_HOST = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1') +MYSQL_PORT = int(os.environ.get('PMS_MYSQL_PORT', '13306')) + +# 连不上时给可读提示并 exit 2(隧道没起 ≠ 复位成功,绝不能静默继续) +HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道' + '(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT / PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)') + +r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, + password='Hbhyg731024@', db=6, decode_responses=True) +try: + keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}'] +except redis.RedisError as e: + print(f'✗ Redis 连接失败 {REDIS_HOST}:{REDIS_PORT}:{e}', file=sys.stderr) + print(HINT, file=sys.stderr) + sys.exit(2) print('Redis 待删除 key 数:', len(keys)) if keys: print('Redis deleted:', r.delete(*keys)) -c = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', - database='pms_dev', autocommit=True) +try: + c = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user='root', password='Hbhyg731024@', + database='pms_dev', autocommit=True) +except pymysql.MySQLError as e: + print(f'✗ MySQL 连接失败 {MYSQL_HOST}:{MYSQL_PORT}:{e}', file=sys.stderr) + print(HINT, file=sys.stderr) + sys.exit(2) cur = c.cursor() cur.execute("UPDATE cabinets SET auth_str_issued = 0 WHERE imei = %s", (IMEI,)) print('auth_str_issued 置 0 行数:', cur.rowcount) diff --git a/test/schema_alignment_e2e.py b/test/schema_alignment_e2e.py index 923e58f..a35ea35 100644 --- a/test/schema_alignment_e2e.py +++ b/test/schema_alignment_e2e.py @@ -66,8 +66,8 @@ ENVS = { "admin_phone": "13912391234", }, } -DEFAULT_HOST = "10.8.0.252" -DEFAULT_PORT = 3306 +DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1") +DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306")) DEFAULT_USER = "root" DEFAULT_PASSWORD = "Hbhyg731024@" @@ -166,7 +166,9 @@ def main(): password=args.password, database=env["db"], charset="utf8mb4", autocommit=True) except pymysql.MySQLError as e: - die("数据库连接失败:%s" % e) + die("数据库连接失败:%s\n" + "连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道" + "(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e) cur = conn.cursor() phone = env.get("temp_phone") diff --git a/test/security-hardening/README.md b/test/security-hardening/README.md index 6f85d8d..0470f1a 100644 --- a/test/security-hardening/README.md +++ b/test/security-hardening/README.md @@ -10,11 +10,54 @@ | `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) | | `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) | | `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 | -| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(直连 `10.8.0.252`) | +| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) | | `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) | | `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 | | `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 | +## 退出码约定(2026-09-20 定稿,全仓统一) + +| 退出码 | 含义 | +|--------|------| +| `0` | 全部判据通过 | +| `1` | **有判据失败**(失败项额外打到 stderr) | +| `2` | 环境/连接问题(读不到 `.env`、连不上 Redis/后端 —— 各脚本用 `raise SystemExit(...)` 报出) | + +- 探针的判据与退出码统一由**同目录的 `verdict.py`** 提供(`Verdict.check(label, passed, detail)` + `exit_code()`)。 +- ⛔ **"打印了 ❌ 却 exit 0"是假绿**:`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod` + 早期结尾写死 `return 0`,**无论安全控制是否生效都 exit 0**,与 `tsc --noEmit` 空跑同类。 + 已改为"失败 → exit 1",并按仓库铁律**实测过失败路径**: + 把 `probe_upload.py` 的 A1 判据翻成 `st == 200` 后在服务器上跑 → **exit 1**; + 恢复后 → **6/6 判据通过、exit 0**(`verdict.py` 本身另有 0/1/空判据 三种自测)。 +- ⚠️ **拿不准阈值的现象不写成判据**(如 challenge 限流"第几次触发 429"、并发耗时)——只作观察输出, + 否则会误报;误报与假绿同样有害。 +- ✅ **C 项(降权后 Excel 导出落盘)结论:可用 —— 由落盘物证直接判定(2026-09-20)** + - 测试环境:`/data/pms_dev/api-server/exports/充电记录__-.xlsx`,**5559 B,属主 `pms:pms`**, + 时间 04:29(与 `test/schema_alignment_e2e.py` 的成功导出同一时刻); + - 生产环境:同类产物 5457 B、属主同为 `pms:pms`; + - 两环境 `exports/` 与 `uploads/` 目录均为 `drwxr-xr-x pms pms`,两个 api-server 单元均为 + `User=pms` / `Group=pms`(`systemctl show -p User -p Group` 实测)。 + - ⇒ 降权成 `pms` 的进程**确实能往 `exports/` 落盘**(这正是 C 项要验证的事)。 + - ⚠️ 上述结论**不依赖 `probe_export.py`**:该脚本因测试库唯一账号没有任何 `user_roles`(无 + `charge_record:export`)仍返回 `exit 2`「未验证」(见下条)。想让它也给出判决,二选一补齐夹具。 +- ⚠️ **`probe_export.py` 当前是"未验证"(exit 2),不是"失败"** —— 实测测试库 `pms_dev` + **只有 1 个账号(`13900000009`)且 `user_roles` 表为空**,即它**一个权限码都没有**: + 创建导出任务恒 `403 缺少权限: charge_record:export`。该探针要回答的是 + "**降权成 `pms` 后还能不能往 `exports/` 写文件**",而没权限根本走不到那一步 —— + 早期把这种情形报成 `exit 1` 属**误报**(把夹具缺陷说成产品缺陷)。 + 现已改为 `exit 2`(环境/夹具问题,"未验证")。**补齐夹具后必须重跑**,二选一: + ① 给该账号配含 `charge_record:export` 的角色; + ② 用 `PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD` 环境变量给一个超管账号。 +- 🐞 **`probe_export.py` 曾把任务状态语义搞反(已修,值得记住)**: + `download_tasks.status` 的**权威语义是 `0 排队 / 1 处理中 / 2 完成 / 3 失败`** + (`db/migrate.rs:225` 的列注释;写入侧 `routes/charge_records.rs::mark_task_completed` 写的就是 `2`)。 + 该脚本此前**等 `status == 1`(处理中)当成功、把 `status == 2`(完成)当"导出任务失败"** —— + 即**导出成功也会被判失败**(假红)。已按权威语义订正,并与另外两处正确口径对齐: + `web/src/pages/charge-records/DownloadCenter.tsx::statusText`、 + `test/schema_alignment_e2e.py`(等 `status in (2,3)` 再断言 `== 2`)。 + > 教训:**状态码这种"看着像布尔"的字段,一定要回权威 DDL 的列注释核对,别照字面猜**; + > 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。 + ## 前置说明 - 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。 diff --git a/test/security-hardening/make_test_user.py b/test/security-hardening/make_test_user.py index f0cb6e7..17a68ad 100644 --- a/test/security-hardening/make_test_user.py +++ b/test/security-hardening/make_test_user.py @@ -1,6 +1,7 @@ """在 pms_dev(测试库)创建/重置一个安全测试账号,用于实测登录锁定与上传。 账号:13900000009 / SecTest#2026(role=0 操作员,status=1 启用,无组织) """ +import os import sys import pymysql @@ -9,8 +10,11 @@ from argon2 import PasswordHasher PHONE = "13900000009" PASSWORD = "SecTest#2026" +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 conn = pymysql.connect( - host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True + host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"), + port=int(os.environ.get("PMS_MYSQL_PORT", "13306")), + user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True, ) try: with conn.cursor() as cur: diff --git a/test/security-hardening/probe_bruteforce.py b/test/security-hardening/probe_bruteforce.py index 6c6b93a..7684618 100644 --- a/test/security-hardening/probe_bruteforce.py +++ b/test/security-hardening/probe_bruteforce.py @@ -15,6 +15,8 @@ import urllib.error import urllib.request from urllib.parse import unquote, urlparse +from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题) + BASE = "http://127.0.0.1:3000" ENV_FILE = "/data/pms_dev/api-server/.env" PHONE = "13900000009" @@ -101,6 +103,8 @@ def counters(redis, ip): def main(): + # 判据容器:失败 → exit 1(此前写死 return 0,属假绿) + v = Verdict("probe_bruteforce(登录锁定 / challenge 限流)") redis = Redis(read_env("REDIS_URL")) for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.6"): redis.cmd("DEL", k) @@ -109,23 +113,36 @@ def main(): for i in (1, 2): st, body = login_attempt(redis, "10.9.9.4", BAD) print(f" 错密码#{i} status={st} body={body.decode()[:120]}") - print(f" 计数(2 次失败后): {counters(redis, '10.9.9.4')}") + c_after_fail = counters(redis, "10.9.9.4") + print(f" 计数(2 次失败后): {c_after_fail}") + v.check("B1a 2 次错密码应记入失败计数", c_after_fail["phone"] is not None, + f'phone={c_after_fail["phone"]}') st, body = login_attempt(redis, "10.9.9.4", GOOD) print(f" 正确密码 status={st} body={body.decode()[:120]}") - print(f" 计数(登录成功后应为 None): {counters(redis, '10.9.9.4')}") + v.check("B1b 正确密码应登录成功", st == 200, f"status={st}") + c_after_ok = counters(redis, "10.9.9.4") + print(f" 计数(登录成功后应为 None): {c_after_ok}") + v.check("B1c 登录成功后失败计数应被清零", c_after_ok["phone"] is None, + f'phone={c_after_ok["phone"]}') print("\n===== B2 连续失败锁定(阈值 5 次 / 15 分钟)=====") + last_st = None for i in range(1, 7): st, body = login_attempt(redis, "10.9.9.5", BAD) + last_st = st print(f" 错密码#{i} status={st} body={body.decode()[:120]} counters={counters(redis, '10.9.9.5')}") + v.check("B2a 第 6 次错密码应被拒(非 200)", last_st != 200, f"status={last_st}") print(" 锁定后再用【正确密码】尝试(应仍被拒,且文案与密码错误完全一致,不泄露锁定状态):") st, body = login_attempt(redis, "10.9.9.7", GOOD) print(f" 正确密码 status={st} body={body.decode()[:120]}") + v.check("B2b 锁定后用正确密码也应被拒(文案不泄露锁定状态)", st != 200, f"status={st}") print("\n===== B3 challenge 限流(同一 IP 12 次)=====") for i in range(1, 13): st, body = http("GET", "/api/auth/challenge", headers={"X-Real-IP": "10.9.9.6"}) print(f" challenge#{i} status={st} body={body.decode()[:80]}") + # ⚠️ 不加硬判据:触发点取决于 nginx `auth_challenge_zone` 的 rate(AGENTS.md 记 20r/m, + # 而本脚本注释写"第 11 次起 429")—— 口径不一致时写死判据会误报,故保持观察输出。 print("\n===== B4 并发闸门(Semaphore=2,不同 IP 并发请求 challenge)=====") t0 = time.time() @@ -146,12 +163,13 @@ def main(): for r in results: codes[r] = codes.get(r, 0) + 1 print(f" {n} 并发(不同 IP)耗时 {wall:.2f}s,状态码分布={codes}") + # ⚠️ 同上:并发耗时/状态码只作观察,不设判据。 print("\n===== 清理测试计数键 =====") for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.7"): redis.cmd("DEL", k) print(f" 清理后 phone 计数 = {redis.cmd('GET', FAIL_KEY)}") - return 0 + return v.exit_code() # 0=判据全过,1=有判据失败 if __name__ == "__main__": diff --git a/test/security-hardening/probe_export.py b/test/security-hardening/probe_export.py index be5d5b8..a921fb9 100644 --- a/test/security-hardening/probe_export.py +++ b/test/security-hardening/probe_export.py @@ -2,8 +2,22 @@ """C 项补充实测:降权后(uid=pms)Excel 导出(写 ./exports)仍可用 流程:登录 → 创建导出任务 → 轮询任务状态 → 下载文件 → 打印文件落盘信息 + +退出码(仓库统一约定 0/1/2): +- `0` = 导出链路可用(任务成功 + 下载到 xlsx) +- `1` = **判据失败**:导出链路真的坏了(任务 status=2 / 下载非 xlsx) +- `2` = **环境或夹具问题,本项"未验证"** —— 登录失败、或账号**缺少 `charge_record:export` + 权限码**(403)。⚠️ 这一档必须与 1 分开:本探针要回答的是"**降权后能不能往 exports 写文件**", + 账号没权限根本走不到那一步,报 1 就是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。 + 实测(2026-09-20)测试库 `pms_dev` **只有 1 个账号且没有任何 user_roles**, + 因此这一项在补齐夹具前**恒为"未验证"**,不能算"导出不可用"。 + +夹具要求(两条路任选): +1. 给测试账号配含 `charge_record:export` 的角色;或 +2. 用环境变量给一个超管账号:`PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD`。 """ import json +import os import socket import sys import time @@ -13,8 +27,9 @@ from urllib.parse import unquote, urlparse BASE = "http://127.0.0.1:3000" ENV_FILE = "/data/pms_dev/api-server/.env" -PHONE = "13900000009" -PASSWORD = "SecTest#2026" +# 允许用环境变量换成"确有 charge_record:export 权限"的账号(默认沿用安全测试账号) +PHONE = os.environ.get("PMS_PROBE_PHONE", "13900000009") +PASSWORD = os.environ.get("PMS_PROBE_PASSWORD", "SecTest#2026") class Redis: @@ -77,32 +92,60 @@ def main(): ) if st != 200: print(f"[login] {st} {body.decode()[:200]}") - return 1 + print(" ⚠️ 未验证(exit 2):登录失败属环境/夹具问题,不是导出链路缺陷") + return 2 token = json.loads(body)["data"]["token"] auth = {"Authorization": f"Bearer {token}"} st, body = http("GET", "/api/charge-records/export?start_time=2020-01-01%2000:00:00&end_time=2030-01-01%2000:00:00", headers=auth) print(f"[export] create -> {st} {body.decode()[:200]}") + if st == 403: + # 关键区分:403 说明**账号没有权限码**,根本没走到"往 exports 写文件"那一步。 + # 把它算成失败(退出码 1)会让人以为"降权后导出不可用",是误报。 + print(" ⚠️ 未验证(exit 2):账号缺少 charge_record:export 权限,本项无法判定") + print(" 夹具要求见本文件顶部 docstring(配权限 或 设 PMS_PROBE_PHONE/PASSWORD)") + return 2 if st != 200: + print(f" ❌ exit 1:创建导出任务失败(HTTP {st}),非权限问题") return 1 task_id = json.loads(body)["data"]["task_id"] + # ⚠️ `download_tasks.status` 的**权威语义**(`db/migrate.rs:225` 列注释 + + # `routes/charge_records.rs:435` 的 `mark_task_completed` 写的就是 2): + # 0=排队 1=处理中 2=**完成** 3=失败 + # 本脚本此前**把语义搞反了**(等 `status == 1`、把 `status == 2` 判成"导出任务失败"), + # 于是"导出成功"会被报成失败(假红),与 console 端 `DownloadCenter.tsx::statusText` + # 和 `test/schema_alignment_e2e.py` 的口径都不一致。已按权威语义订正。 + STATUS_DONE, STATUS_FAILED = 2, 3 + file_path = None - for _ in range(15): + for _ in range(20): time.sleep(1) st, body = http("GET", f"/api/downloads/{task_id}", headers=auth) info = json.loads(body)["data"] - print(f"[export] task={task_id} status={info.get('status')} progress={info.get('progress')} file_path={info.get('file_path')}") - if info.get("status") == 1: + cur = info.get("status") + print(f"[export] task={task_id} status={cur} progress={info.get('progress')} file_path={info.get('file_path')}") + if cur == STATUS_DONE: file_path = info.get("file_path") break - if info.get("status") == 2: - print("导出任务失败") + if cur == STATUS_FAILED: + print(f" ❌ exit 1:导出任务失败(status=3)err={info.get('error_message')!r}") return 1 + else: + print(" ❌ exit 1:120 秒内任务未进入终态(既没完成也没失败)—— 导出链路可能卡住") + return 1 + + if not file_path: + print(" ❌ exit 1:任务标记完成但没有 file_path —— 落盘环节有问题") + return 1 st, blob = http("GET", f"/api/downloads/{task_id}/file", headers=auth) print(f"[download] GET /api/downloads/{task_id}/file -> {st} bytes={len(blob)} head={blob[:4]!r}") - return 0 if (st == 200 and blob[:2] == b"PK") else 1 + if st == 200 and blob[:2] == b"PK": + print(" ✅ exit 0:导出链路可用(降权成 pms 后仍能往 exports/ 落盘并下载)") + return 0 + print(" ❌ exit 1:下载失败或不是 xlsx(xlsx 是 zip,魔数应为 PK)") + return 1 if __name__ == "__main__": diff --git a/test/security-hardening/probe_prod.py b/test/security-hardening/probe_prod.py index 5484d3d..c3d75b9 100644 --- a/test/security-hardening/probe_prod.py +++ b/test/security-hardening/probe_prod.py @@ -16,6 +16,8 @@ import time import urllib.error import urllib.request +from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题) + BASE = "http://127.0.0.1:9000" # 直连后端(应用层行为) DOMAIN = "https://app.anzhizhichong.com" # 走 nginx(看响应头/CORS) ENV_FILE = "/data/pms/api-server/.env" @@ -75,6 +77,8 @@ def multipart(field, filename, content, ctype): def main(): + # 判据容器:失败 → exit 1(此前写死 return 0,属假绿) + v = Verdict("probe_prod(生产上传拒绝路径 / CORS)") token = mint_token(env("JWT_SECRET"), UID, PHONE) auth = {"Authorization": f"Bearer {token}"} @@ -93,6 +97,7 @@ def main(): "POST", BASE + "/api/auth/avatar/upload", data, {**auth, "Content-Type": ct} ) print(f" avatar filename={fn} -> {st} {body.decode()[:140]}") + v.check(f"生产头像:{fn} 应被拒", st != 200, f"status={st}") print("\n===== 生产 反馈图上传(拒绝路径 + 一个真实 PNG 测试文件)=====") url = None @@ -104,6 +109,8 @@ def main(): data, ct = multipart("file", fn, content, ctype) st, _, body = http("POST", BASE + "/api/feedback/upload", data, {**auth, "Content-Type": ct}) print(f" feedback filename={fn}(应{expect})-> {st} {body.decode()[:140]}") + v.check(f"生产反馈图:{fn} 应{expect}", (st != 200) if expect == "拒" else (st == 200), + f"status={st}") if st == 200: url = json.loads(body)["data"]["url"] @@ -119,6 +126,8 @@ def main(): print(f"[cleanup] 已删除我自己的测试文件 {path}") print("\n===== 生产 CORS 白名单 =====") + # ⚠️ CORS 只作观察:是否回 `access-control-allow-origin` 还取决于浏览器语义与后端实现口径, + # 写成硬判据容易误报 ⇒ 保持观察输出(误报与假绿同样有害)。 for origin in ( "https://app.anzhizhichong.com", "https://test.anzhizhichong.com", @@ -131,7 +140,7 @@ def main(): f" Origin={origin:<32} -> {st} allow-origin=" f"{hdrs.get('access-control-allow-origin', '(无)')}" ) - return 0 + return v.exit_code() # 0=判据全过,1=有判据失败 if __name__ == "__main__": diff --git a/test/security-hardening/probe_upload.py b/test/security-hardening/probe_upload.py index b893c12..c1c89d3 100644 --- a/test/security-hardening/probe_upload.py +++ b/test/security-hardening/probe_upload.py @@ -13,6 +13,8 @@ import os import re import socket import sys + +from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题) import urllib.error import urllib.request from urllib.parse import unquote, urlparse @@ -124,20 +126,28 @@ def try_upload(token, path, field, filename, content, ctype, label): def main(): + # 判据容器:失败 → exit 1(见 verdict.py;此前本脚本写死 return 0,属假绿) + v = Verdict("probe_upload(测试环境 uploads 安全)") redis = Redis(read_env("REDIS_URL")) token = login(redis) auth = {"Authorization": f"Bearer {token}"} print("\n===== 头像上传 /api/auth/avatar/upload =====") - try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg") - try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png") + st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg") + v.check("A1 头像:svg(image/svg+xml)应被拒", st != 200, f"status={st}") + st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png") + v.check("A2 头像:改名 .png 的 SVG(魔数兜底)应被拒", st != 200, f"status={st}") st, resp = try_upload(token, "/api/auth/avatar/upload", "file", "ok.png", PNG, "image/png", "A3 real-png") + v.check("A3 头像:真 PNG 应通过", st == 200, f"status={st}") ok_url = json.loads(resp)["data"]["avatar"] if st == 200 else None print("\n===== 反馈图上传 /api/feedback/upload =====") - try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg") - try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png") + st, _ = try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg") + v.check("A4 反馈图:svg 应被拒", st != 200, f"status={st}") + st, _ = try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png") + v.check("A5 反馈图:改名 .png 的 SVG 应被拒", st != 200, f"status={st}") fb_st, fb_resp = try_upload(token, "/api/feedback/upload", "file", "ok.png", PNG, "image/png", "A6 real-png") + v.check("A6 反馈图:真 PNG 应通过", fb_st == 200, f"status={fb_st}") print("\n===== 上传后静态访问(直连 :3000,验证 nosniff 由 nginx 加,这里看后端 Content-Type)=====") for url in filter(None, [ok_url, json.loads(fb_resp)["data"]["url"] if fb_st == 200 else None]): @@ -148,7 +158,7 @@ def main(): for d in ("/data/pms_dev/api-server/uploads/avatars", "/data/pms_dev/api-server/uploads/feedback"): if os.path.isdir(d): print(f"{d}: {sorted(os.listdir(d))}") - return 0 + return v.exit_code() # 0=判据全过,1=有判据失败 if __name__ == "__main__": diff --git a/test/security-hardening/smoke_prod.py b/test/security-hardening/smoke_prod.py index 713b19b..1bd3fde 100644 --- a/test/security-hardening/smoke_prod.py +++ b/test/security-hardening/smoke_prod.py @@ -6,6 +6,8 @@ import urllib.error import urllib.request from urllib.parse import unquote, urlparse +from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题) + DOMAIN = "https://app.anzhizhichong.com" ENV_FILE = "/data/pms/api-server/.env" FAKE_PHONE = "00000000000" @@ -57,8 +59,11 @@ def http(method, path, data=None, headers=None): def main(): + # 判据容器:失败 → exit 1(此前写死 return 0,属假绿) + v = Verdict("smoke_prod(生产冒烟:SPA / challenge / 登录参数校验)") st, _, body = http("GET", "/pms/") print(f"[SPA] GET /pms/ -> {st} bytes={len(body)}") + v.check("生产 SPA /pms/ 应 200", st == 200, f"status={st}") asset = None for token in body.decode(errors="ignore").split('"'): if token.startswith("/pms/assets/") and token.endswith(".js"): @@ -67,9 +72,13 @@ def main(): if asset: st, _, blob = http("GET", asset) print(f"[SPA] GET {asset} -> {st} bytes={len(blob)}") + v.check(f"生产 SPA 资源 {asset} 应 200", st == 200, f"status={st}") + else: + v.check("生产 SPA 页面应引用 /pms/assets/*.js", False, "未在 HTML 中找到资源引用") st, _, body = http("GET", "/api/pms/auth/challenge") print(f"[login] GET /api/pms/auth/challenge -> {st} (JSON 长度 {len(body)})") + v.check("challenge 接口应 200", st == 200, f"status={st}") st, _, body = http( "POST", @@ -78,12 +87,13 @@ def main(): {"Content-Type": "application/json"}, ) print(f"[login] POST /api/pms/auth/login(无 challenge)-> {st} {body.decode()[:120]}") + v.check("无 challenge 的登录必须被拒(非 200)", st != 200, f"status={st}") redis = Redis(env("REDIS_URL")) for key in (f"auth:fail:phone:{FAKE_PHONE}", "auth:fail:ip:unknown"): redis.cmd("DEL", key) print("[cleanup] 清理测试产生的 Redis 计数键完成") - return 0 + return v.exit_code() # 0=判据全过,1=有判据失败 if __name__ == "__main__": diff --git a/test/security-hardening/verdict.py b/test/security-hardening/verdict.py new file mode 100644 index 0000000..87203c1 --- /dev/null +++ b/test/security-hardening/verdict.py @@ -0,0 +1,69 @@ +"""安全探针统一的「判据 + 退出码」小工具(供 test/security-hardening/*.py 使用)。 + +为什么需要它 +------------ +这些探针早期只 `print` 观察结果、结尾写死 `return 0` ⇒ **无论安全控制是否生效都 exit 0**: +人眼看到的是 ❌/异常现象,自动化(CI、部署前置检查、agent 验收)看到的却是绿灯。 +这与 `tsc --noEmit` 空跑、`tcp_e2e_smoke.py` 永远 exit 0 属**同一类假绿**。 + +统一口径(与 AGENTS.md「验收流程」一致) +-------------------------------------- + 0 = 全部判据通过 + 1 = 有判据失败(失败项额外打到 stderr,便于只看 stderr 定位) + 2 = 环境/连接问题(调用方自行 `raise SystemExit(2)`,例如读不到 .env、连不上 Redis/后端) + +用法 +---- + import sys + from verdict import Verdict + + v = Verdict("probe_upload(测试环境 uploads 安全)") + v.check("A1 头像上传 svg 应被拒", st != 200, f"status={st}") + ... + sys.exit(v.exit_code()) + +约定 +---- +- 判据只写**明确、可复现**的期望("应被拒" = status != 200;"应通过" = status == 200); + 拿不准阈值/口径的现象(如限流具体第几次触发)**不要**写成判据,保持为观察输出, + 否则会制造误报 —— 误报与假绿同样有害。 +- 判据数可能为 0(纯取证脚本):此时 exit_code() 返回 0,但会打印"未设置判据"提醒。 +""" + +import sys + + +class Verdict: + def __init__(self, title): + self.title = title + self.checks = [] # [(label, passed, detail)] + + def check(self, label, passed, detail=""): + """记录一条判据;返回 bool 便于就地分支""" + passed = bool(passed) + self.checks.append((label, passed, detail)) + return passed + + @property + def failed(self): + return [(l, d) for l, p, d in self.checks if not p] + + def exit_code(self): + """打印结论并返回退出码(0=全通过;1=有失败)""" + n_pass = len(self.checks) - len(self.failed) + print(f"\n===== 判据结论:{self.title} =====") + for label, passed, detail in self.checks: + suffix = f" ({detail})" if detail else "" + print(f" {'✅' if passed else '❌'} {label}{suffix}") + print(f" 合计 {n_pass}/{len(self.checks)} 判据通过") + if not self.checks: + print(" ⚠️ 本脚本未设置任何判据(纯取证脚本)—— 不要拿它的退出码当验收判据") + return 0 + if self.failed: + print("\n未通过判据(stderr):", file=sys.stderr) + for label, detail in self.failed: + suffix = f" ({detail})" if detail else "" + print(f" ❌ {label}{suffix}", file=sys.stderr) + print(f"合计 {n_pass}/{len(self.checks)} 判据通过,{len(self.failed)} 项失败", file=sys.stderr) + return 1 + return 0 diff --git a/test/tcp_e2e_smoke.py b/test/tcp_e2e_smoke.py index ae22b6e..88f9540 100644 --- a/test/tcp_e2e_smoke.py +++ b/test/tcp_e2e_smoke.py @@ -5,6 +5,7 @@ 每一步都打印平台的应答,便于人工核对协议实现。 """ +import sys import hashlib import json import socket @@ -154,3 +155,12 @@ print("\n========== 联调结果 ==========") for name, passed in ok: print(f" {'✅' if passed else '❌'} {name}") print(f"\n 合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过") +# ⛔ 退出码必须反映结果:0=全部通过,1=有失败(此前本脚本永远 exit 0,属假绿); +# 失败项额外打到 stderr,便于 CI/人只看 stderr 就能定位(口径见 AGENTS.md「验收流程」)。 +failed = [n for n, p in ok if not p] +if failed: + print("未通过项(stderr):", file=sys.stderr) + for n in failed: + print(f" ❌ {n}", file=sys.stderr) + print(f"合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过,{len(failed)} 项失败", file=sys.stderr) +sys.exit(0 if not failed else 1) diff --git a/test/tcp_latency_probe.py b/test/tcp_latency_probe.py index e547784..4c3700d 100644 --- a/test/tcp_latency_probe.py +++ b/test/tcp_latency_probe.py @@ -1,16 +1,32 @@ -"""测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。""" +"""测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。 + +退出码(全仓统一口径):0 = 三步都有应答;1 = 有步骤无应答/断言失败; +2 = 连接或环境问题(DB 连不上、TCP 建连失败)。 +⛔ 以前它只打印 ✅/❌ 却永远 exit 0 —— 无应答也算"通过",属假绿。 +""" import hashlib import json +import os import socket +import sys import time import pymysql +# 3001 是 device-server 的 TCP 业务端口(设备经公网/ZeroTier 直连),与数据库通路无关,保持不变 HOST, PORT = "10.8.0.252", 3001 IMEI = "860000000000001" # 复位夹具,保证能重新申领 -conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@", - database="pms_dev", connect_timeout=8, autocommit=True) +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +try: + conn = pymysql.connect(host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"), + port=int(os.environ.get("PMS_MYSQL_PORT", "13306")), + user="root", password="Hbhyg731024@", + database="pms_dev", connect_timeout=8, autocommit=True) +except pymysql.MySQLError as e: + print(f"✗ MySQL 连接失败:{e}", file=sys.stderr) + print("连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道", file=sys.stderr) + sys.exit(2) cur = conn.cursor() cur.execute("UPDATE cabinets SET auth_str=NULL, auth_str_issued=0 WHERE imei=%s", (IMEI,)) cur.execute("DELETE FROM charge_records WHERE cabinet_id=1") @@ -21,9 +37,15 @@ cur.execute("UPDATE compartments SET status=0") conn.close() print(" 夹具已复位") -s = socket.create_connection((HOST, PORT), timeout=5) +try: + s = socket.create_connection((HOST, PORT), timeout=5) +except OSError as e: + print(f"✗ TCP 建连失败 {HOST}:{PORT}:{e}", file=sys.stderr) + print("(该端口是 device-server 业务端口;测试环境为 :3001)", file=sys.stderr) + sys.exit(2) s.settimeout(1.0) buf = b"" +fails = [] # 收集失败项,决定退出码(非 0 = 失败) def send(obj): @@ -55,9 +77,12 @@ dt, resp = wait_reply(t0) if resp: print(f" ✅ 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False)}") auth_str = resp.get("auth_str") + if not auth_str: + fails.append("① auth_str 有应答但未带 auth_str") else: print(f" ❌ {dt if dt else '>75'}s 内无应答") auth_str = None + fails.append(f"① auth_str 无应答(等待 {dt if dt else '>75'}s)") if auth_str: print("\n=== ② login 延迟实测 ===") @@ -66,6 +91,8 @@ if auth_str: t0 = send({"act": "login", "msg_id": 2, "dev_id": IMEI, "sign": sign, "timestamp": ts}) dt, resp = wait_reply(t0) print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}") + if not resp: + fails.append(f"② login 无应答(等待 {dt if dt else '>75'}s)") print("\n=== ③ status_post 延迟实测 ===") t0 = send({"act": "status_post", "msg_id": 3, "rssi": 85, @@ -74,5 +101,16 @@ if auth_str: {"board": 2, "channels": ["008200000000000000", "008200000000000000"]}]}) dt, resp = wait_reply(t0) print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}") + if not resp: + fails.append(f"③ status_post 无应答(等待 {dt if dt else '>75'}s)") s.close() + +# ⛔ 退出码必须反映结果(口径:0=通过,1=断言失败,2=环境/连接问题) +if fails: + print("\n未通过项(stderr):", file=sys.stderr) + for f in fails: + print(f" ❌ {f}", file=sys.stderr) + sys.exit(1) +print("\n✅ 三步均有应答") +sys.exit(0) diff --git a/tools/add_cabinets.py b/tools/add_cabinets.py index 9684c73..0ff882c 100644 --- a/tools/add_cabinets.py +++ b/tools/add_cabinets.py @@ -1,7 +1,13 @@ +import os import pymysql import random -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +conn = pymysql.connect( + host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + port=int(os.environ.get('PMS_MYSQL_PORT', '13306')), + user='root', password='Hbhyg731024@', database='pms', +) cur = conn.cursor() # 获取所有项目ID diff --git a/tools/add_sub_orgs.py b/tools/add_sub_orgs.py index 340f45a..47703d3 100644 --- a/tools/add_sub_orgs.py +++ b/tools/add_sub_orgs.py @@ -1,6 +1,13 @@ +import os + import pymysql -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +conn = pymysql.connect( + host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + port=int(os.environ.get('PMS_MYSQL_PORT', '13306')), + user='root', password='Hbhyg731024@', database='pms', +) cur = conn.cursor() # 添加parent_id字段 diff --git a/tools/db.py b/tools/db.py index 6f049ec..51412c6 100644 --- a/tools/db.py +++ b/tools/db.py @@ -2,6 +2,11 @@ """ PMS 数据库工具 用法: + 需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306); + 可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT 指向其它可达地址。 + ⚠️ 默认目标是**测试库 `pms_dev`**(可用 PMS_MYSQL_DB 覆盖;取 `pms` = 生产库, + 脚本会额外打印一行醒目警告)。 + python tools/db.py import # 导入SQL文件 python tools/db.py query # 执行查询 python tools/db.py exec # 执行写入 @@ -9,20 +14,47 @@ PMS 数据库工具 python tools/db.py count # 统计各表数据量 """ +import os import sys import pymysql -# 数据库配置 +# 数据库配置:默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。 +# 可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT / PMS_MYSQL_DB 覆盖。 +# ⚠️ 默认库是**测试库 `pms_dev`** —— 本工具能执行写操作(`exec`/`import`), +# 默认绝不能落在生产库上(历史上文档写 pms_dev、代码却是 pms:以为在测测试库、实际打生产)。 +PROD_DB = 'pms' DB_CONFIG = { - 'host': '10.8.0.252', + 'host': os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + 'port': int(os.environ.get('PMS_MYSQL_PORT', '13306')), 'user': 'root', 'password': 'Hbhyg731024@', - 'database': 'pms', + 'database': os.environ.get('PMS_MYSQL_DB', 'pms_dev'), 'charset': 'utf8mb4', } +# 连不上时的统一提示(隧道没起是最常见原因,绝不能让它变成一段栈回溯) +TUNNEL_HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道' + '(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)') + + +def announce_target(): + """每次连接前醒目提示当前目标库(防止"以为在测试库、实际打生产")""" + db = DB_CONFIG['database'] + print(f"当前目标库:{db}(可用 PMS_MYSQL_DB 覆盖)") + if db == PROD_DB: + print(f"⚠️ 你正在操作正式库 {PROD_DB} —— 写操作(exec/import)前请再次确认;" + f"测试库请用 PMS_MYSQL_DB=pms_dev") + + def get_conn(): - return pymysql.connect(**DB_CONFIG) + """建立连接;连不上时打印可读提示并退出(不抛栈回溯)""" + announce_target() + try: + return pymysql.connect(**DB_CONFIG) + except pymysql.MySQLError as e: + print(f"✗ MySQL 连接失败 {DB_CONFIG['host']}:{DB_CONFIG['port']}:{e}", file=sys.stderr) + print(TUNNEL_HINT, file=sys.stderr) + sys.exit(2) def import_sql(filepath): """导入SQL文件""" diff --git a/tools/db_schema_diff.py b/tools/db_schema_diff.py index a122b12..8b6f577 100644 --- a/tools/db_schema_diff.py +++ b/tools/db_schema_diff.py @@ -24,6 +24,9 @@ BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、测试环境导出 500 用法 ---- + 需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306); + `--host/--port` 优先于环境变量 `PMS_MYSQL_HOST/PMS_MYSQL_PORT`。 + python tools/db_schema_diff.py # 默认:pms_dev 与 pms 全列比对 python tools/db_schema_diff.py --db pms # 被检查库换成 pms(参照库自动取另一个) python tools/db_schema_diff.py --reference-db pms # 显式指定参照库 @@ -55,9 +58,11 @@ try: except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示 die("缺少依赖:pip install pymysql") -# ---- 默认连接参数(ZeroTier 直连,见 AGENTS.md「数据库连接」)------------------ -DEFAULT_HOST = "10.8.0.252" -DEFAULT_PORT = 3306 +# ---- 默认连接参数 ---------------------------------------------------------- +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。 +# 优先级:命令行 --host/--port > 环境变量 PMS_MYSQL_HOST/PMS_MYSQL_PORT > 下面的默认值。 +DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1") +DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306")) DEFAULT_USER = "root" DEFAULT_PASSWORD = "Hbhyg731024@" @@ -272,7 +277,10 @@ def main(): conn_a.close() conn_b.close() except pymysql.MySQLError as e: - die("数据库连接/查询失败:%s" % e) + # 连不上时必须是 exit 2(连接错误),绝不能落成"无漂移"的 exit 0 + die("数据库连接/查询失败:%s\n" + "连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道" + "(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e) # ---- TIMESTAMP 巡检(两种模式都做)---- ts_columns = scan_timestamp_columns(snapshot) diff --git a/tools/dev-tunnel.ps1 b/tools/dev-tunnel.ps1 new file mode 100644 index 0000000..b5d99dc --- /dev/null +++ b/tools/dev-tunnel.ps1 @@ -0,0 +1,28 @@ +# 开发机 → 生产服务器(SSH) → 阿里云 RDS/Redis +# 用途:本地脚本 / DBX / 客户端通过 127.0.0.1:13306(MySQL)、127.0.0.1:16379(Redis) 访问阿里云 +# 说明:不需要在服务器上开放任何数据库端口(已移除 nginx stream 转发) +# +# 用法:$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'; pwsh tools/dev-tunnel.ps1 +# 常驻自愈:tools/dev-tunnel-run.ps1(含口令、已 gitignore、登录即起的快捷方式 PMS-DEV-TUNNEL.lnk) +# 换机/重装后重建:把上面两行跑一次即可;要开机自启就在启动文件夹放一个指向 dev-tunnel-run.ps1 的快捷方式。 +param( + [string]$Server = '118.31.119.206', + [string]$User = 'root', + [string]$MySqlHost = 'rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com', + [string]$RedisHost = 'r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com', + [int]$LocalMySqlPort = 13306, + [int]$LocalRedisPort = 16379 +) +# ⚠️ 日志骗人的历史坑(2026-09-20 已修,勿退回): +# 原日志行写成 `"... -> $MySqlHost:3306 ..."`,而 PowerShell 会把 `$MySqlHost:3306` +# 解析成**带作用域限定的变量名**(形如 `$env:PATH` 的 `$作用域:名字`)—— 该变量不存在, +# 于是**主机名连端口一起渲染成空串**(日志出现 `127.0.0.1:13306 -> ,`), +# 而隧道本身完全正常(下面 plink 的参数本来就写了花括号)⇒ 属于"日志与事实不符"。 +# ✅ 规则:变量后面紧跟 `:` 时必须写成 `"${MySqlHost}:3306"`。 +# ❌ 这不是 param 默认值的问题(默认值一直生效,已实测 plink 命令行带真实主机名); +# 默认值**只在本 param 块定义一份**,别在别处再写第二套(两套默认值必然漂移)。 + +$pw = $env:PMS_SSH_PASSWORD +if (-not $pw) { Write-Error '请先设置环境变量 PMS_SSH_PASSWORD(服务器 SSH 口令)'; exit 2 } +Write-Host "建立隧道:127.0.0.1:${LocalMySqlPort} -> ${MySqlHost}:3306 , 127.0.0.1:${LocalRedisPort} -> ${RedisHost}:6379 (经 ${User}@${Server})" +plink -batch -pw $pw -N -L "${LocalMySqlPort}:${MySqlHost}:3306" -L "${LocalRedisPort}:${RedisHost}:6379" "$User@$Server" diff --git a/tools/mock_redis.py b/tools/mock_redis.py index 47fa2a2..0c6d5c1 100644 --- a/tools/mock_redis.py +++ b/tools/mock_redis.py @@ -9,21 +9,23 @@ Redis 模拟数据填充工具 python tools/mock_redis.py # 填充模拟数据 python tools/mock_redis.py --clear # 清除所有模拟数据 """ +import os import sys import json import random import redis import pymysql +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 # Redis 配置 -REDIS_HOST = "10.8.0.252" -REDIS_PORT = 6379 +REDIS_HOST = os.environ.get("PMS_REDIS_HOST", "127.0.0.1") +REDIS_PORT = int(os.environ.get("PMS_REDIS_PORT", "16379")) REDIS_PASSWORD = "Hbhyg731024@" REDIS_DB = 5 # MySQL 配置 -MYSQL_HOST = "10.8.0.252" -MYSQL_PORT = 3306 +MYSQL_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1") +MYSQL_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306")) MYSQL_USER = "root" MYSQL_PASSWORD = "Hbhyg731024@" MYSQL_DB = "pms" diff --git a/tools/update_cabinets.py b/tools/update_cabinets.py index 297dbc2..a4f562b 100644 --- a/tools/update_cabinets.py +++ b/tools/update_cabinets.py @@ -1,7 +1,13 @@ +import os import pymysql import random -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +conn = pymysql.connect( + host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + port=int(os.environ.get('PMS_MYSQL_PORT', '13306')), + user='root', password='Hbhyg731024@', database='pms', +) cur = conn.cursor() # 1. 添加address字段 diff --git a/tools/update_iccid.py b/tools/update_iccid.py index 4f0109e..b3dabdb 100644 --- a/tools/update_iccid.py +++ b/tools/update_iccid.py @@ -1,7 +1,13 @@ +import os import pymysql import random -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +conn = pymysql.connect( + host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + port=int(os.environ.get('PMS_MYSQL_PORT', '13306')), + user='root', password='Hbhyg731024@', database='pms', +) cur = conn.cursor() # ICCID规则: diff --git a/tools/update_imei_iccid.py b/tools/update_imei_iccid.py index 74819bc..f3344ff 100644 --- a/tools/update_imei_iccid.py +++ b/tools/update_imei_iccid.py @@ -1,7 +1,13 @@ +import os import pymysql import random -conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发 +conn = pymysql.connect( + host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'), + port=int(os.environ.get('PMS_MYSQL_PORT', '13306')), + user='root', password='Hbhyg731024@', database='pms', +) cur = conn.cursor() # 添加ICCID字段