diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c3e305a5e..404fac679 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ build_backend: - test "$(javac -version 2>&1 | cut -d' ' -f2 | cut -d. -f1)" = 21 - bash scripts/bootstrap/check-kingbase-jdbc.sh - mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package - - python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v + - python3 -m unittest discover -s scripts/qa -p 'test_qa*.py' -v artifacts: expire_in: 2 days paths: [RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar] diff --git a/DEMO_RUNBOOK.md b/DEMO_RUNBOOK.md index ea58d65b5..5196170ea 100644 --- a/DEMO_RUNBOOK.md +++ b/DEMO_RUNBOOK.md @@ -5,14 +5,15 @@ ## 当前验收边界 -截至 2026-10-15 Round 5V-6,三项结论必须分开: -`SOFTWARE_FUNCTIONAL_QA_PASSED=YES`;`SOFTWARE_FINAL_SIGNOFF=PENDING_OPERATING_ISOLATION`; +当前分项结论(2026-10-15,Round 5V-7),三项必须分开: +`SOFTWARE_FUNCTIONAL_QA_PASSED=YES`;Round 5V-6的`SOFTWARE_FINAL_SIGNOFF=PENDING_OPERATING_ISOLATION`已在 +Round 5V-7完成受管QA运行/退出路径验证,现为`PASS_MANAGED_QA_SCOPE`; `REAL_HARDWARE_VERIFIED=NO`。 Round 5V-5 已经通过 Tailscale 跳板恢复访问,并对新融合后端完成登录、培训页、IoT页和既有Mock状态冒烟。 此前的断网不是撤销已通过的功能验收。两条旧授权的同步时间现已定位到旧实验进程的补偿写入, 不是新融合后端的正常Mock回执,也不能再标成“原因未知”。 -完整时间线、脱敏证据、运行隔离阻塞和安全归档方案见 -[Round 5V-6 验收收口](docs/iot/ROUND5V6_FINAL_SIGNOFF.md)。 +完整时间线和脱敏归因见[Round 5V-6](docs/iot/ROUND5V6_FINAL_SIGNOFF.md); +实际隔离、观察窗口、故障退出和签署范围见[Round 5V-7](docs/iot/ROUND5V7_RUNTIME_ISOLATION.md)。 Round 5V-4 已在获准的新建离线 Kingbase V9 容器中实际恢复原生备份,完成 005 的提交、重复执行、异常/断连/锁超时回滚及原备份恢复演练,然后才对 @@ -44,7 +45,7 @@ export KINGBASE_JDBC_JAR='/absolute/path/to/authorized/kingbase8-9.0.0.jar' bash scripts/bootstrap/install-kingbase-jdbc.sh bash scripts/bootstrap/check-kingbase-jdbc.sh mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package -python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v +python3 -m unittest discover -s scripts/qa -p 'test_qa*.py' -v mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am -Dtest=Round5v4MissingResourceTest -Dsurefire.failIfNoSpecifiedTests=false test ``` @@ -95,23 +96,38 @@ Round 5V-4 已另获明确授权,复用服务器现有 V9 镜像创建专用 ## 3. 启停隔离 QA(目标库 005 已完成) -### 经跳板访问时的强制运行隔离前置 +### 经跳板访问时的受管运行隔离(强制) 旧实验进程曾配置 `DB_HOST=127.0.0.1 / DB_PORT=14321`。端口没有监听者,**不代表没有旧客户端等着重连**。 Round 5V-5 在该端口建立隧道时,旧实验进程重新访问同一QA库,补偿任务写了两条旧授权的同步时间。 -不得再次复用14321,也不能只检查新后端/Redis/前端端口就声称客户端隔离。 +不要手工开放14321或用普通SSH转发替代下面的守卫;单纯更换端口不算隔离。 -未来启动前,先只读核对现有Java进程身份、数据源目标及环境变量中的非秘密Host/Port, -确认所选隧道端口没有其他客户端依赖。可考虑任务专用14326,但它只是候选值,仍须现场核验。 -仅绑定127.0.0.1,例如经 `ssh -J wzbmatebook ... sict-218` 转发 -`127.0.0.1:14326 -> 127.0.0.1:4321`;不修改路由、Tailscale、SSH持久配置或全局代理。 -在Git外0600的本轮专用配置中设置 -`FUSION_QA_DB_URL=jdbc:kingbase8://127.0.0.1:14326/railwaysystem_fusion_qa_20261013`。 -原始Kingbase测试驱动仍锁定原地址,不应直接拿该隧道配置重跑完整矩阵。 +先只读核对现有Java进程、SSH监听者和非秘密数据源Host/Port。从本工作树根目录,终端A运行: -`qa.py` 当前不检查其他进程的出站数据库目标;上述是人工强制前置,不声称已实现自动防冲突。 -如需停止旧实验进程、调整旧运行配置或修订自动预检,须在对应授权下单独实施。 -未证明旧客户端不能借用新链路前,不开放数据库转发、不启动写入演示。 +```bash +python3 scripts/qa/qa_isolation.py +# 默认选择临时空闲端口;需固定端口时可显式 --port 14327,但仍会检查客户端依赖。 +``` + +工具首先持有14321的**拒绝连接专用socket**,不读写数据库协议、不连接任何上游; +已有未知监听者会导致安全失败,不停止它。新版SSH仅监听127.0.0.1的独占端口, +通过既有wzbmatebook链路访问目标4321;不继承SSH配置中的额外转发/本地命令,不复用共享ControlMaster。 +身份认证只走既有合法密钥/Agent或交互密码提示,不将密码放参数、配置或日志。 + +必须看到`QA_ISOLATION_READY`后,才在终端B执行下面的启动命令。 +Git外0600配置可不设置`FUSION_QA_DB_URL`,启动器会采用当前受管账本的确切URL; +如显式设置,必须与该账本的127.0.0.1端口和固定QA库完全匹配,否则拒绝。 +只读inspect/snapshot在存在活动账本时也采用同一默认端点;无账本时允许其原有只读预检,但不允许应用start。 + +启动器核对守卫/SSH的PID、出生时间、cwd、命令、loopback监听归属及新端点Java客户端冲突。 +看门狗持续核验并更新心跳;隧道退出或核验失败会清理**同一租约**的前端/后端/Redis, +再关自己的SSH,最后释放拒绝端口。账本/退出证据在`.qa-runtime/`,不进Git。 +正常停止可先执行`qa.py stop`,再在终端A按Ctrl+C;只停止本任务所属资源,不按名字pkill。 + +旧实验进程不被停止、旧配置/账号不被修改;受管工作流以外的管理员手动重开14321仍可恢复旧链路, +这不是永久数据库权限撤销或系统级网络隔离。不得绕过受管入口进行QA写入演示。 +若需要永久停用旧服务或调整账号,须另获对应批准。原始Kingbase全矩阵驱动仍锁定旧原地址, +不要拿当前隧道配置重跑它;本轮只做启动/登录/查询,不重做005或完整业务矩阵。 ```bash python3 scripts/qa/qa.py start --config /protected/round5v.env diff --git a/docs/iot/ROUND5V6_FINAL_SIGNOFF.md b/docs/iot/ROUND5V6_FINAL_SIGNOFF.md index 8912ebf10..2b95c2f5d 100644 --- a/docs/iot/ROUND5V6_FINAL_SIGNOFF.md +++ b/docs/iot/ROUND5V6_FINAL_SIGNOFF.md @@ -5,6 +5,9 @@ 本轮没有启动应用、创建数据库转发、重跑测试矩阵、迁移、恢复或业务写入。 SSH使用既有 `wzbmatebook -> sict-218`;仅临时loopback SOCKS供Git只读访问,不修改持久代理或网络配置。 +后续追补:Round 5V-7 已实现并实测受管隧道、14321拒绝守卫、数据稳定观察及故障退出清理。 +以下PENDING是本轮当时的结论,现行受管QA范围签署及剩余边界见[Round 5V-7](ROUND5V7_RUNTIME_ISOLATION.md)。 + ## 1. 三项独立结论 | 标识 | 结论 | 范围 | diff --git a/docs/iot/ROUND5V7_RUNTIME_ISOLATION.md b/docs/iot/ROUND5V7_RUNTIME_ISOLATION.md new file mode 100644 index 000000000..010ffb57c --- /dev/null +++ b/docs/iot/ROUND5V7_RUNTIME_ISOLATION.md @@ -0,0 +1,122 @@ +# Round 5V-7 — 旧客户端路径阻断与受管QA签署 + +日期:2026-10-15(Asia/Shanghai)。输入提交:`65c567152e4f57bd25af03da52cc7078ea264984`。 +分支:`fix/iot-safety-hardening`。本轮只改归档工程的本地QA工具、工具测试、CI验证发现范围及文档。 +没有修改原工程、实验工程、旧进程配置、数据库账号权限、路由、Tailscale或共享服务。 +没有迁移、恢复、纠正sync_time、创建授权、发Mock业务控制命令或真实设备控制。 +登录会正常产生自身认证审计;它不是零数据库写入,但没有授权/命令变更。 + +## 1. 结论与边界 + +| 标识 | 结论 | 实际范围 | +| --- | --- | --- | +| SOFTWARE_FUNCTIONAL_QA_PASSED | YES | 保留11项真实Kingbase、86项选定回归、12项工具、Chrome15路由及住宿Mock闭环的历史通过;未重跑完整矩阵 | +| SOFTWARE_FINAL_SIGNOFF | PASS_MANAGED_QA_SCOPE | 本轮受管隧道、旧端点拒绝守卫、新后端最小读冒烟、数据稳定观察和故障退出通过 | +| REAL_HARDWARE_VERIFIED | NO | 真实门锁/MQTT/人脸/DMS均未放行,未做现场硬件验收 | + +此签署是当前受管QA运行/退出工作流的可交付候选,不是生产上线、永久数据库访问控制或硬件签署。 +脱离工具手工重开14321、修改旧进程配置或共享同一账号,仍可能恢复旧访问;这些不在通过范围。 +不得把手工SSH隧道或未绑定租约的应用启动替代本流程。 + +## 2. 旧进程和原访问路径的只读复核 + +- 旧Java PID51901,出生时间`2026-10-13 15:35:08`,cwd=`boot4-iot-fusion-test`; + 实际JAR位于该工程的`RuoYi-Vue-master/ruoyi-admin/target/ruoyi-admin.jar`,不是归档工作树。 +- 从进程环境只输出非秘密字段:DB_HOST=127.0.0.1、DB_PORT=14321、QA_DB_USER=fusion_qa_app。 + 实际运行JAR内application-druid.yml固定数据库`railwaysystem_fusion_qa_20261013`,默认Host/Port与环境一致。 +- 另一Java PID53305,出生时间`2026-10-12 12:46:15`,属于另一工程;其JAR数据源模板是localhost:54321/iotlocks, + 不是上述旧端点。本轮两个Java进程均未停止或修改。 +- 开始时14321没有监听者,不存在可归属/可关闭的现有该端口隧道。其他既有SSH进程未发现14321转发参数,未操作它们。 +- 旧任务`initialDelay=30000ms / fixedDelay=60000ms`,允许补偿时会按失败/待发状态扫描;失败分支可写sync_time。 + 实际调度间隔还包含数据库连接等待/失败处理,不能把60秒当作每分钟一定完成一次。 +- 原归档`qa.py`不自动建14321隧道,但允许任意同名QA URL,且只检查新应用监听端口,未防止旧客户端借用新转发。 + 已证明的两条旧记录来源仍见[Round 5V-6](ROUND5V6_FINAL_SIGNOFF.md),不是正常Mock回执。 + +## 3. 实际采用的最小隔离 + +新增`scripts/qa/qa_isolation.py`,正常前台运行,认证只走既有合法SSH入口、Agent/密钥或交互提示。 +它不接受或保存SSH密码,不改SSH/Tailscale/系统代理配置,不依赖停止旧进程或修改账号。 + +| 客户端/审计 | 本轮端点 | 隔离行为 | +| --- | --- | --- | +| 旧PID51901及其他14321客户端 | 127.0.0.1:14321 | 本任务守卫独占,连接立即关闭;没有数据库协议响应、没有任何上游 | +| 新归档后端PID35238 | 127.0.0.1:14327 | 仅归本任务SSH PID34187所有的loopback转发,经wzbmatebook到目标127.0.0.1:4321 | +| 独立只读指纹核验 | 经诊断SSH执行容器本地ksql | 只读事务/ROLLBACK,不通过14321,不恢复备份、不执行DDL | + +守卫owner PID34164;同一租约绑定Redis35236、后端35238、前端35269。 +其PID/出生时间/cwd/命令、监听IP与归属、固定QA库、心跳及新端点Java客户端依赖都被核验。 +未发现其他Java客户端依赖14327;实际TCP连接只见新后端与自建SSH,旧PID51901未连接它。 +14321已有未知监听者、新端点冲突、PID重用、心跳过期或所有权不匹配均安全拒绝,不清理未知资源。 + +不是只换端口:旧14321被拒绝守卫占住,实际bind测试得到占用错误,实际连接不返回数据库响应; +新启动器同时拒绝14321 URL、没有活动租约的start以及与租约不匹配的数据源。 +前端也必须绑定同租约后端及对应端口,不能用旧8096后端冒充本版本。 +看门狗持续检查;临时隧道失效时仅清理同租约应用,先关自己的SSH,最后释放拒绝守卫。 +退出后的账本保留审计,不自动重用旧账本或信号未知PID。 + +### SSH继承配置的额外发现和修复 + +现有SSH目标和跳板配置均含与QA无关的RemoteForward17897。 +首次受管试运行因该继承转发冲突失败,未启动应用;对应STOPPED账本保留。 +本轮诊断会话也意外继承同一转发;单独cancel不足,因为后续mux命令客户端可能再次继承并申请转发。 +已按确切归属退出本轮诊断ControlMaster,再以ClearAllForwardings和禁用ControlMaster的单次只读SSH复核:17897监听数为0。 +没有修改持久SSH配置或取消其他会话的转发。 +最终工具用独立目标配置和显式ProxyCommand复用既有跳板,跳板清除继承转发/本地命令; +目标只设置批准的HostName/User/22端口及唯一-L,StrictHostKeyChecking=yes,不复用ControlMaster,不发布远程端口。 +此后正式运行没有继承无关转发。不要将失败的首次尝试隐去或算作成功启动。 + +## 4. 实际验证及数据证据 + +证据根:受保护且Git忽略的`.qa-runtime/round5v7/`;所有业务行仅在数据库内计算排序完整行指纹,未导出人员资料。 +浏览器截图位于`output/playwright/round5v7-*-headers.png`,数据行和头像遮罩。 + +- 新工具的29项定向本机测试通过(原12项工具测试及17项隔离测试),不连接真实服务或数据库。 + 覆盖无租约拒绝、禁止旧URL、PID/监听者/心跳/来源端点拒绝、非本租约进程不被停止、真实本机拒绝socket等。 + 其中28项在启动前通过,补充前端错误端口防护后最终29项通过;不假称重跑86项后端测试。 +- 正式受管租约创建于2026-10-15 01:52:49;应用在01:53:12启动。 + 实测Java21.0.2 / Boot4.1.0,fusion_qa_app,数据源仅目标QA库,独立Redis16389,前端18088/后端18096均loopback。 + 强制CLI保持MQTT enabled=false/mock=true、real-device-write-enabled=false、空白名单、自动补偿关闭、 + Face HTTP/OpenAPI/QA危险入口/DMS/Quartz关闭;实际日志出现MOCK-MQTT,未见08001/SQL错误。 +- 实际Chrome155最小冒烟:管理员登录/getInfo/getRouters成功;Student查询与Auth查询均HTTP200/业务200,JS错误0。 + Auth页实际组件和45条总数正确,不是登录页/404;没有点击授权、撤权、入住或回执操作。 +- 隔离前、运行中、退出前后共四次真实Kingbase只读采样。前后窗口1432秒(约23分52秒), + 是名义60秒补偿周期的多倍;两表完整数据指纹均不变: + lock_auth45条:`af7e6a797bebef91a7d5570697265b5e`;lock_command83条:`271d15e2d0721015d3b020433c549c7c`。 + 包含所有字段,而不只是排除sync_time后的指纹。两表REAL行数均0。 +- 两条脱敏旧记录sync_time分别保持`2026-10-14 16:51:29`与`16:51:30`,仍LEGACY/QUARANTINED/version0, + sync_status3、status0,没有纠正性UPDATE;其余原始记录也包含在完整指纹中。 +- 正式守卫累计拒绝507次连接(含一次明确的本机负向验证,不把全部次数硬归给单个PID)。 + 旧日志完整追加窗口读取约1.1MB,发现2个补偿失败事件、0个成功updateSyncStatus UPDATE;未复制原日志正文。 + 这证明旧任务仍可能运行,但在受管窗口内无法通过旧端点获得QA连接,不是把任务静默禁用。 + +### 自建SSH异常退出演练 + +验证租约与PID身份后,仅向本轮自建SSH PID34187发送SIGTERM。 +看门狗检测到监听者丢失,自动停止同租约前端/后端/Redis,随后关闭自己的资源; +终态STOPPED、cleanup_ok=true,故障检测按预期非零退出,退出证据保留。 +14321、14327、18096、18088、16389均不再监听,旧两个Java的PID/出生时间不变。 +退出后源QA只剩本次ksql只读会话,无fusion_qa_app残留连接,完整指纹仍一致。 +随后实际`qa.py start`在无租约时拒绝,未连接数据库或再启动进程。 +没有测试SIGKILL、主机断电或恶意绕过,也不把本测试写成这些保证。 + +## 5. 归档状态和剩余风险 + +共享Kingbase/GitLab容器ID、2026-10-06启动时间、restart0/OOM=false均未变;GitLab healthy。 +临时恢复容器仍停止,本轮未启动或修改它。没有共享部署或真实硬件操作。 + +本轮只读远端仍为`feat/boot4-iot-fusion@373921447fa4727c20aed7b28c5d1519fe26fa78`,修复分支远端不存在。 +该融合基线是修复分支祖先;本地保留此前七笔提交,本轮再追加工具/文档提交,不改写历史。 +GitLab使用默认CI文件,项目Hook=0、非项目Hook=0、相关活跃Integration=0。 +CI仍仅显式web+RUN_FUSION_VALIDATION=true可运行,只有验证无部署;新增隔离测试已纳入工具测试发现范围。 +没有Push、MR、Merge或触发Runner;不把静态配置审核写成已执行远程CI。 + +本轮对已有七笔提交和新增暂存范围合计142个blob/路径版本完成扫描,未检出已知本机凭据值、 +私钥正文、Token形态高风险项或新增备份/日志/照片/二进制文件;通用赋值命中已区分环境变量、占位符、 +合成测试及禁用账号哨兵,不将扫描写成对继承历史的绝对无秘密保证。 +原始备份、0600凭据、运行账本/日志/截图未被跟踪,扫描摘要保存在受保护忽略目录。 +继承的测试密钥素材、历史凭据轮换、既有账号过宽授权、Vue2依赖及长期Kingbase许可仍保留风险,未自动处置。 + +在**必须使用受管入口**的前提下,当前软件候选不再因旧端点隔离而挂起;本轮最后仅需用户决定GitLab归档。 +可另行批准后推送`fix/iot-safety-hardening`的精确本地HEAD,不Force Push、不覆盖融合基线,再经MR评审合入。 +这不放行部署/生产/真实硬件;若要永久关闭旧服务、改旧配置、撤销账号权限或允许手工转发,仍须对应批准。 +对恶意同用户进程/管理员主动绕过不提供系统级隔离保证;如运行场景要求该等级,应维持该场景PENDING并另行设计权限隔离。 diff --git a/qa-bootstrap/round5v.env.example b/qa-bootstrap/round5v.env.example index 1bb2d3cbc..2609b8648 100644 --- a/qa-bootstrap/round5v.env.example +++ b/qa-bootstrap/round5v.env.example @@ -1,6 +1,8 @@ # COPY outside Git, chmod 600. Literal KEY=value only, no shell expansion. # Never reuse the old QA_DB_URL from another database. -FUSION_QA_DB_URL='jdbc:kingbase8://192.168.139.218:4321/railwaysystem_fusion_qa_20261013?connectTimeout=5&socketTimeout=15' +# For start: qa.py takes the URL from a live qa_isolation.py lease when this key is absent. +# Do not use the legacy 14321 endpoint or a raw direct connection for application start. +# If explicitly supplied, FUSION_QA_DB_URL must match the owned loopback port/database exactly. FUSION_QA_APP_PASSWORD='REPLACE_WITH_APPROVED_APP_PASSWORD' TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY='REPLACE_WITH_APPROVED_QA_ENCRYPTION_KEY' TRAIN_STUDENT_IDENTITY_HASH_KEY='REPLACE_WITH_APPROVED_QA_HASH_KEY' diff --git a/scripts/qa/qa.py b/scripts/qa/qa.py index 4b199fc26..9fc56f3d4 100644 --- a/scripts/qa/qa.py +++ b/scripts/qa/qa.py @@ -17,6 +17,7 @@ import sys import time import urllib.parse import xml.etree.ElementTree as ET +import qa_isolation ROOT = Path(__file__).resolve().parents[2] DATABASE = 'railwaysystem_fusion_qa_20261013' @@ -50,6 +51,8 @@ def environment(config): raise ValueError('Refusing non-target database URL') if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)): raise ValueError('URL credentials forbidden') + if parsed.port == qa_isolation.LEGACY_PORT: + raise ValueError('Legacy database endpoint is forbidden') password = config.get('FUSION_QA_APP_PASSWORD', '') if not password: raise ValueError('Missing FUSION_QA_APP_PASSWORD') @@ -115,11 +118,14 @@ def launch(name, command, env): child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) log.close() started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip() - ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started})) + ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started, + 'isolation_id': env.get('FUSION_QA_ISOLATION_ID')})) print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log'))) def start(config, env, backend_port, redis_port): + lease = qa_isolation.require(env.get('QA_DB_URL')) + env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id'] if inspect(env, ['--require-migrated']): raise ValueError('Backend not started: QA migration preflight failed') available(backend_port) @@ -189,13 +195,15 @@ def wait_for_exit(pid): raise ValueError('Task-owned process did not stop; ledger retained, no forced kill') -def stop(): +def stop(isolation_id=None): private_directory() for name in ['frontend', 'backend', 'redis']: path = RUNTIME / (name + '.json') if not path.exists(): continue item = json.loads(path.read_text()) + if isolation_id is not None and item.get('isolation_id') != isolation_id: + raise ValueError('Other isolation lease owns this runtime ledger; refusing to signal it') actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip() if actual: # macOS Python wrappers exec Python.app; Redis rewrites its process title. @@ -233,11 +241,20 @@ def main(): stop() return 0 if args.action == 'frontend': + lease = qa_isolation.require() + backend = json.loads((RUNTIME / 'backend.json').read_text()) + if backend.get('isolation_id') != lease['isolation_id'] or '--server.port=' + str(args.backend_port) not in backend.get('command', []): + raise ValueError('Frontend backend belongs to another isolation lease') available(args.frontend_port) + env = os.environ.copy() + env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id'] launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'), - '--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], os.environ.copy()) + '--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], env) return 0 config = load_config(args.config) + if args.action == 'start' or (RUNTIME / qa_isolation.LEDGER).exists(): + lease = qa_isolation.require() + config.setdefault('FUSION_QA_DB_URL', qa_isolation.url_for(lease)) env = environment(config) if args.action == 'inspect': return inspect(env, []) diff --git a/scripts/qa/qa_isolation.py b/scripts/qa/qa_isolation.py new file mode 100644 index 000000000..d48869508 --- /dev/null +++ b/scripts/qa/qa_isolation.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Owned, temporary QA transport. Never revive the legacy database endpoint. + +The legacy port is a deny-only socket: no protocol response and no upstream. +SSH authentication stays interactive; no password is accepted in arguments/files. +This is runtime isolation, not database credential revocation or a host firewall. +""" +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import selectors +import signal +import socket +import stat +import subprocess +import tempfile +import time +import urllib.parse + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME = ROOT / '.qa-runtime' +LEDGER = 'isolation.json' +DATABASE = 'railwaysystem_fusion_qa_20261013' +LEGACY_PORT = 14321 + + +def run(args): + return subprocess.run(args, text=True, capture_output=True, timeout=8) + + +def identity(pid): + born = run(['ps', '-p', str(pid), '-o', 'lstart=']).stdout.strip() + command = run(['ps', '-p', str(pid), '-o', 'command=']).stdout.strip() + cwd = run(['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn']).stdout.splitlines() + return {'pid': pid, 'started': born, 'command': command, + 'cwd': next((s[1:] for s in cwd if s.startswith('n')), '')} + + +def same_process(item): + return bool(item.get('started')) and identity(item['pid']) == item + + +def listener_owned(port, pid): + lines = run(['lsof', '-nP', '-iTCP:' + str(port), '-sTCP:LISTEN', '-FpFn']).stdout.splitlines() + owners = {int(s[1:]) for s in lines if s.startswith('p')} + addresses = {s[1:] for s in lines if s.startswith('n')} + return owners == {pid} and addresses == {'127.0.0.1:' + str(port)} + + +def conflicting_clients(port, isolation_id): + # Read locally, return only PIDs. Never print/store the captured environments. + conflicts = [] + for line in run(['ps', '-axo', 'pid=,comm=']).stdout.splitlines(): + fields = line.strip().split(None, 1) + if len(fields) != 2 or Path(fields[1]).name != 'java': + continue + pid = int(fields[0]) + result = run(['ps', 'eww', '-p', str(pid), '-o', 'command=']) + if result.returncode or not result.stdout.strip(): + if run(['ps', '-p', str(pid), '-o', 'pid=']).stdout.strip(): + raise ValueError('Cannot inspect a running Java client safely') + continue + text = result.stdout + owns_lease = re.search(r'(?:^|\s)FUSION_QA_ISOLATION_ID=' + re.escape(isolation_id) + r'(?:\s|$)', text) + if owns_lease and identity(pid)['cwd'] == str(ROOT): + continue + target = re.search(r'(?:127\.0\.0\.1|localhost|\[::1\]):' + str(port) + r'(?:[/\s?]|$)', text) + env_port = re.search(r'(?:^|\s)(?:DB_PORT|QA_DB_PORT)=' + str(port) + r'(?:\s|$)', text) + if target or env_port: + conflicts.append(pid) + return conflicts + + +def private_runtime(): + RUNTIME.mkdir(mode=0o700, exist_ok=True) + info = RUNTIME.stat() + if RUNTIME.is_symlink() or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise ValueError('Unsafe isolation directory') + + +def read_lease(): + private_runtime() + path = RUNTIME / LEDGER + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise ValueError('Unsafe isolation ledger') + return json.loads(path.read_text()) + + +def write_lease(item): + fd, name = tempfile.mkstemp(prefix='isolation-', suffix='.tmp', dir=RUNTIME) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(item, stream) + os.replace(name, RUNTIME / LEDGER) + finally: + if Path(name).exists(): + Path(name).unlink() + + +def validate(item, url=None, scan_clients=True): + if item.get('state') != 'READY' or item.get('database') != DATABASE: + raise ValueError('No ready QA isolation lease') + port = item.get('port') + if not isinstance(port, int) or not 1024 <= port <= 65535 or port == LEGACY_PORT: + raise ValueError('Legacy or invalid database port refused') + if not 0 <= time.time() - item.get('heartbeat', 0) < 10: + raise ValueError('Isolation watchdog heartbeat expired') + if not same_process(item['owner']) or not same_process(item['ssh']): + raise ValueError('Isolation process identity mismatch') + if not listener_owned(LEGACY_PORT, item['owner']['pid']) or not listener_owned(port, item['ssh']['pid']): + raise ValueError('Isolation listener ownership mismatch') + if url: + parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:')) + if parsed.scheme != 'kingbase8' or parsed.hostname != '127.0.0.1' or parsed.port != port or parsed.path != '/' + DATABASE or parsed.username or parsed.password: + raise ValueError('Application must use its owned QA transport') + if scan_clients and conflicting_clients(port, item['isolation_id']): + raise ValueError('Another Java client depends on the new database endpoint') + return item + + +def require(url=None): + try: + return validate(read_lease(), url) + except (OSError, KeyError, TypeError, json.JSONDecodeError) as error: + raise ValueError('Managed QA isolation is unavailable') from error + + +def url_for(item): + return 'jdbc:kingbase8://127.0.0.1:' + str(item['port']) + '/' + DATABASE + + +def deny_socket(): + guard = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + # No reuse options: an existing listener/forward must cause refusal. + guard.bind(('127.0.0.1', LEGACY_PORT)) + guard.listen(64) + guard.setblocking(False) + return guard + except BaseException: + guard.close() + raise + + +def ssh_command(port): + # The user's Host * may contain unrelated RemoteForward directives. Read the + # jump alias normally but clear its forwards; do not inherit target config. + proxy = ('ssh -o ClearAllForwardings=yes -o PermitLocalCommand=no -o ControlMaster=no ' + '-o ControlPath=none -o ControlPersist=no -o StrictHostKeyChecking=yes ' + '-W %h:%p wzbmatebook') + return ['ssh', '-F', '/dev/null', '-o', 'HostName=192.168.139.218', '-o', 'User=root', '-p', '22', + '-o', 'ProxyCommand=' + proxy, '-o', 'StrictHostKeyChecking=yes', + '-N', '-o', 'ControlMaster=no', '-o', 'ControlPath=none', + '-o', 'ControlPersist=no', '-o', 'ExitOnForwardFailure=yes', '-o', 'ConnectTimeout=8', + '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', + '-L127.0.0.1:' + str(port) + ':127.0.0.1:4321', 'sict-218'] + + +def serve(port): + private_runtime() + if (RUNTIME / LEDGER).exists(): + raise ValueError('Isolation ledger already exists; inspect its owner, never overwrite') + if port == LEGACY_PORT or port < 0 or (port and not 1024 <= port <= 65535): + raise ValueError('Legacy or invalid database port refused') + if not port: + with socket.socket() as candidate: + candidate.bind(('127.0.0.1', 0)) + port = candidate.getsockname()[1] + lease_id = secrets.token_hex(16) + if conflicting_clients(port, lease_id): + raise ValueError('New database port is already configured by another Java client') + target = run(['ssh', '-G', 'sict-218']) + if target.returncode or not all(re.search(pattern, target.stdout, re.M) for pattern in + [r'^hostname 192\.168\.139\.218$', r'^user root$', r'^port 22$']): + raise ValueError('SSH target alias does not resolve to the approved host') + owner = identity(os.getpid()) + if owner['cwd'] != str(ROOT): + raise ValueError('Start the isolation owner from this worktree root') + guard = deny_socket() + selector = selectors.DefaultSelector() + selector.register(guard, selectors.EVENT_READ) + child = None + item = None + def stopped(*_): + raise KeyboardInterrupt + previous = {s: signal.signal(s, stopped) for s in [signal.SIGTERM, signal.SIGINT]} + try: + child = subprocess.Popen(ssh_command(port), cwd=ROOT) + deadline = time.monotonic() + 90 + refused = 0 + while True: + if child.poll() is not None: + raise ValueError('Owned SSH transport exited; QA isolation refused') + for _, _ in selector.select(timeout=0.4): + try: + connection, _address = guard.accept() + except BlockingIOError: + continue + else: + connection.close() # Deliberately no read, write, proxy or DB authentication. + refused += 1 + newly_ready = item is None + if newly_ready: + if not listener_owned(port, child.pid): + if time.monotonic() > deadline: + raise ValueError('Owned SSH transport readiness timed out') + continue + item = {'isolation_id': lease_id, 'state': 'READY', 'database': DATABASE, 'port': port, + 'target': 'sict-218:127.0.0.1:4321', 'owner': owner, 'ssh': identity(child.pid), + 'created': time.time(), 'heartbeat': time.time(), 'denied_legacy_connections': refused} + item.update(heartbeat=time.time(), denied_legacy_connections=refused) + # Guards and client dependencies are checked on each heartbeat, not just once at launch. + validate(item) + write_lease(item) + if newly_ready: + print('QA_ISOLATION_READY port=' + str(port) + ' legacy_port=DENY_ONLY', flush=True) + except KeyboardInterrupt: + pass + finally: + # Stop only matching lease-owned applications before removing their transport and guard. + cleanup_ok = False + try: + import qa + qa.stop(isolation_id=lease_id) + cleanup_ok = True + finally: + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(timeout=12) + except subprocess.TimeoutExpired: + # Exact child Popen object, never a name/port-selected process. + child.kill() + child.wait(timeout=5) + guard.close() + selector.close() + for sig, handler in previous.items(): + signal.signal(sig, handler) + if item: + item.update(state='STOPPED' if cleanup_ok else 'CLEANUP_FAILED', heartbeat=time.time(), + denied_legacy_connections=refused, cleanup_ok=cleanup_ok) + write_lease(item) + history = RUNTIME / ('isolation-' + lease_id + '-final.json') + os.replace(RUNTIME / LEDGER, history) + print('QA_ISOLATION_CLOSED evidence=' + str(history), flush=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', type=int, default=0, help='0 selects an unused ephemeral port; legacy port is always refused') + args = parser.parse_args() + try: + serve(args.port) + except (ValueError, OSError, subprocess.TimeoutExpired) as error: + print('QA_ISOLATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__)) + raise SystemExit(2) diff --git a/scripts/qa/test_qa_isolation.py b/scripts/qa/test_qa_isolation.py new file mode 100644 index 000000000..56b131241 --- /dev/null +++ b/scripts/qa/test_qa_isolation.py @@ -0,0 +1,132 @@ +"""Targeted local tool tests. No server connection, credentials, database or device writes.""" +import json +from pathlib import Path +import socket +import select +import tempfile +import time +import unittest +from types import SimpleNamespace +from unittest.mock import patch +import qa +import qa_isolation as isolation + + +class QaIsolationTest(unittest.TestCase): + def item(self): + return {'state':'READY','database':isolation.DATABASE,'port':15432,'heartbeat':time.time(), + 'isolation_id':'synthetic','owner':{'pid':101},'ssh':{'pid':102}} + + def test_legacy_url_is_refused_before_database_access(self): + for host in ['127.0.0.1','localhost','192.168.139.218']: + with self.assertRaises(ValueError): + qa.environment({'FUSION_QA_DB_URL':'jdbc:kingbase8://'+host+':14321/'+qa.DATABASE, + 'FUSION_QA_APP_PASSWORD':'synthetic'}) + + def test_no_lease_prevents_even_database_preflight(self): + with patch.object(isolation,'require',side_effect=ValueError('synthetic refusal')), \ + patch.object(qa,'inspect') as inspect, patch.object(qa,'launch') as launch: + with self.assertRaises(ValueError):qa.start({}, {}, 18096, 16389) + inspect.assert_not_called();launch.assert_not_called() + + def test_ready_owned_transport_allowed(self): + item=self.item() + with patch.object(isolation,'same_process',return_value=True), \ + patch.object(isolation,'listener_owned',return_value=True), \ + patch.object(isolation,'conflicting_clients',return_value=[]): + self.assertEqual(item,isolation.validate(item,isolation.url_for(item))) + + def test_expired_or_future_heartbeat_refused(self): + for delta in [-11,60]: + item=self.item();item['heartbeat']=time.time()+delta + with self.assertRaises(ValueError):isolation.validate(item) + + def test_legacy_and_invalid_ports_refused(self): + for port in [14321,0,-1,65536,'15432']: + item=self.item();item['port']=port + with self.assertRaises(ValueError):isolation.validate(item) + + def test_wrong_state_or_database_refused(self): + for key,value in [('state','STOPPED'),('database','training')]: + item=self.item();item[key]=value + with self.assertRaises(ValueError):isolation.validate(item) + + def test_reused_pid_refused(self): + with patch.object(isolation,'same_process',return_value=False): + with self.assertRaises(ValueError):isolation.validate(self.item()) + + def test_unknown_listener_refused(self): + with patch.object(isolation,'same_process',return_value=True),patch.object(isolation,'listener_owned',return_value=False): + with self.assertRaises(ValueError):isolation.validate(self.item()) + + def test_wrong_application_endpoint_refused(self): + for url in ['jdbc:kingbase8://127.0.0.1:14321/'+qa.DATABASE, + 'jdbc:kingbase8://192.168.139.218:4321/'+qa.DATABASE, + 'jdbc:kingbase8://127.0.0.1:15432/training']: + with patch.object(isolation,'same_process',return_value=True),patch.object(isolation,'listener_owned',return_value=True): + with self.assertRaises(ValueError):isolation.validate(self.item(),url) + + def test_another_java_client_is_not_allowed_to_share_endpoint(self): + with patch.object(isolation,'same_process',return_value=True), \ + patch.object(isolation,'listener_owned',return_value=True), \ + patch.object(isolation,'conflicting_clients',return_value=[999]): + with self.assertRaises(ValueError):isolation.validate(self.item()) + + def test_deny_socket_has_no_upstream_and_rejects_data(self): + # Use an OS-selected temporary port, never touch the actual legacy port in unit tests. + with patch.object(isolation,'LEGACY_PORT',0):guard=isolation.deny_socket() + try: + with socket.create_connection(guard.getsockname(),timeout=1) as client: + self.assertTrue(select.select([guard],[],[],2)[0]) + peer,_=guard.accept();peer.close() + self.assertEqual(b'',client.recv(1)) + finally:guard.close() + + def test_existing_listener_not_replaced(self): + with socket.socket() as listener: + listener.bind(('127.0.0.1',0));listener.listen(1) + with patch.object(isolation,'LEGACY_PORT',listener.getsockname()[1]): + with self.assertRaises(OSError):isolation.deny_socket() + self.assertNotEqual(-1,listener.fileno()) + + def test_tunnel_never_reuses_a_controlmaster_or_exposes_password(self): + command=isolation.ssh_command(15432) + self.assertIn('ControlMaster=no',command);self.assertIn('ControlPath=none',command) + self.assertEqual('/dev/null',command[command.index('-F')+1]) + self.assertTrue(any(p.startswith('ProxyCommand=') and 'ClearAllForwardings=yes' in p for p in command)) + self.assertIn('ExitOnForwardFailure=yes',command) + self.assertIn('-L127.0.0.1:15432:127.0.0.1:4321',command) + self.assertNotIn('-f',command);self.assertNotIn('-g',command) + self.assertFalse(any('password=' in p.lower() for p in command)) + + def test_lease_file_is_private_and_symlink_refused(self): + with tempfile.TemporaryDirectory() as directory,patch.object(isolation,'RUNTIME',Path(directory)): + isolation.write_lease({'synthetic':True});path=Path(directory)/isolation.LEDGER + self.assertEqual(0o600,path.stat().st_mode & 0o777) + self.assertEqual({'synthetic':True},isolation.read_lease()) + path.unlink();path.symlink_to(Path(directory)/'other') + with self.assertRaises((ValueError,OSError)):isolation.read_lease() + + def test_stop_cannot_signal_another_lease(self): + with tempfile.TemporaryDirectory() as directory,patch.object(qa,'RUNTIME',Path(directory)),patch.object(qa.os,'kill') as kill: + (Path(directory)/'backend.json').write_text(json.dumps({'pid':123,'isolation_id':'other'})) + with self.assertRaises(ValueError):qa.stop(isolation_id='synthetic') + kill.assert_not_called() + + def test_foreign_client_scan_does_not_report_environment_secrets(self): + def fake(args): + if args[:2]==['ps','-axo']:return SimpleNamespace(stdout='123 /usr/bin/java\n',returncode=0) + return SimpleNamespace(stdout='java DB_PORT=15432 SOME_PASSWORD=synthetic_private_value',returncode=0) + with patch.object(isolation,'run',side_effect=fake): + self.assertEqual([123],isolation.conflicting_clients(15432,'synthetic')) + + def test_frontend_cannot_proxy_an_old_backend_with_matching_lease_label(self): + with tempfile.TemporaryDirectory() as directory,patch.object(qa,'RUNTIME',Path(directory)), \ + patch.object(isolation,'require',return_value={'isolation_id':'synthetic'}), \ + patch.object(qa,'launch') as launch,patch('sys.argv',['qa.py','frontend','--backend-port','8096']): + (Path(directory)/'backend.json').write_text(json.dumps({'isolation_id':'synthetic','command':['java','--server.port=18096']})) + with self.assertRaises(ValueError):qa.main() + launch.assert_not_called() + + +if __name__=='__main__':unittest.main() diff --git a/scripts/qa/test_qa_tools.py b/scripts/qa/test_qa_tools.py index c34875dd7..8a9de8c23 100644 --- a/scripts/qa/test_qa_tools.py +++ b/scripts/qa/test_qa_tools.py @@ -63,13 +63,15 @@ class QaToolsTest(unittest.TestCase): self.assertEqual('$(touch should-not-exist)', qa.load_config(path)['KEY']) def test_migration_preflight_prevents_all_processes(self): - with patch.object(qa, 'inspect', return_value=2), patch.object(qa, 'launch') as launch: + with patch.object(qa.qa_isolation, 'require', return_value={'isolation_id':'synthetic'}), \ + patch.object(qa, 'inspect', return_value=2), patch.object(qa, 'launch') as launch: with self.assertRaises(ValueError): qa.start({}, {}, 18096, 16389) launch.assert_not_called() def test_start_flags_and_secret_handling(self): with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \ + patch.object(qa.qa_isolation, 'require', return_value={'isolation_id':'synthetic'}), \ patch.object(qa, 'inspect', return_value=0), patch.object(qa, 'available'), patch.object(qa, 'launch') as launch: config = {'FUSION_QA_APP_PASSWORD':'synthetic', 'TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY':'synthetic', 'TRAIN_STUDENT_IDENTITY_HASH_KEY':'synthetic'} qa.start(config, qa.environment(config), 18096, 16389)