fix(qa): isolate legacy database clients with owned transport guard

This commit is contained in:
wangzhibiao committed 2026-10-15 02:54:00 +08:00
1 parent 65c567152e
commit 01e949093a
9 files changed
+577 -23

No files matched your search

+1 -1
View File
@@ -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]
+33 -17
View File
@@ -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
+3
View File
@@ -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. 三项独立结论
| 标识 | 结论 | 范围 |
+122
View File
@@ -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并另行设计权限隔离。
+3 -1
View File
@@ -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'
+20 -3
View File
@@ -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, [])
+260
View File
@@ -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)
+132
View File
@@ -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()
+3 -1
View File
@@ -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)