From 936a7b3ddfcc04918f29e0761a012d5228312697 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 13 Oct 2026 20:47:45 +0800 Subject: [PATCH 01/25] Accept current Cloudflare token lengths with safe characters --- scripts/gateway-admin-proxy/legacy-dns-hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gateway-admin-proxy/legacy-dns-hook.py b/scripts/gateway-admin-proxy/legacy-dns-hook.py index d57c475..31020a7 100644 --- a/scripts/gateway-admin-proxy/legacy-dns-hook.py +++ b/scripts/gateway-admin-proxy/legacy-dns-hook.py @@ -32,7 +32,7 @@ def api(method, suffix, payload=None): if not re.fullmatch(r'[a-f0-9]{32}', zone) or cfg['target'] != TARGET: raise RuntimeError('Unapproved DNS configuration') token = TOKEN.read_text().strip() - if not re.fullmatch(r'[A-Za-z0-9_-]{40}', token): + if not re.fullmatch(r'[A-Za-z0-9_-]{20,128}', token): raise RuntimeError('Invalid token format') with tempfile.TemporaryDirectory(prefix='acme-api-', dir=STATE) as folder: body = pathlib.Path(folder) / 'request.json' From 4ad89b196cc5f03486ad9feea39a40c988a5608a Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 13 Oct 2026 21:08:35 +0800 Subject: [PATCH 02/25] Keep proxy failure telemetry without sensitive request URLs --- .../gateway-admin-cert.service | 1 + scripts/gateway-admin-proxy/render.py | 23 +++++++++++++++---- .../renew-gateway-admin.sh | 2 +- .../gateway-admin-proxy/renew-white-legacy.sh | 2 +- .../white-proxy-cert.service | 1 + 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/scripts/gateway-admin-proxy/gateway-admin-cert.service b/scripts/gateway-admin-proxy/gateway-admin-cert.service index 1637cca..3afc4db 100644 --- a/scripts/gateway-admin-proxy/gateway-admin-cert.service +++ b/scripts/gateway-admin-proxy/gateway-admin-cert.service @@ -2,4 +2,5 @@ Description=Renew only the two gateway administration host certificates [Service] Type=oneshot +TimeoutStartSec=15min ExecStart=/bin/sh /opt/gateway-admin-proxy/provision/renew-gateway-admin.sh diff --git a/scripts/gateway-admin-proxy/render.py b/scripts/gateway-admin-proxy/render.py index fb3e6c8..098663e 100644 --- a/scripts/gateway-admin-proxy/render.py +++ b/scripts/gateway-admin-proxy/render.py @@ -9,6 +9,20 @@ import re CA = '/etc/ssl/certs/ca-certificates.crt' ACME = '/var/lib/gateway-admin-proxy/acme' CF_TRUST = '/etc/nginx/white-proxy-cloudflare-realip.conf' +HEALTH_MAP = '''log_format gateway_admin_proxy_health '$time_iso8601 host=$host status=$status upstream_status=$upstream_status duration=$request_time'; +map $status $gateway_admin_proxy_log_failure { + ~^[45] 1; + default 0; +} +''' + + +def private_logs(): + # Nginx error lines include the entire request URI, including subscription tokens. + # Keep critical diagnostics plus status-only failure records for these new vhosts. + return ''' access_log /var/log/nginx/gateway-admin-proxy.health.log gateway_admin_proxy_health if=$gateway_admin_proxy_log_failure; + error_log /var/log/nginx/gateway-admin-proxy.error.log crit; +''' def proxy_lines(upstream, tls_name, host, alias=None): @@ -48,7 +62,7 @@ def http_server(names, redirect=True): listen 80; listen [::]:80; server_name {names}; - access_log off; +{private_logs()} location ^~ /.well-known/acme-challenge/ {{ root {ACME}; try_files $uri =404; @@ -95,15 +109,14 @@ def https_server(names, certificate, content, cloudflare=False, white_ip=None, k ssl_certificate_key {certificate}/{key_name}; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 20m; - access_log off; - error_log /var/log/nginx/gateway-admin-proxy.error.log warn; +{private_logs()} {trusted}{content} }} ''' def render_gateway(stage, legacy_path, white_ip): - files = {'00-gateway-admin-proxy-maps.conf': '''map $http_upgrade $gateway_admin_proxy_connection { + files = {'00-gateway-admin-proxy-maps.conf': HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection { default upgrade; '' close; } @@ -133,7 +146,7 @@ def render_gateway(stage, legacy_path, white_ip): def render_white(stage, legacy_certificate, skip_legacy=False): files = {} - maps = '''map $http_upgrade $gateway_admin_proxy_connection { + maps = HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection { default upgrade; '' close; } diff --git a/scripts/gateway-admin-proxy/renew-gateway-admin.sh b/scripts/gateway-admin-proxy/renew-gateway-admin.sh index d5b901e..98cbd62 100644 --- a/scripts/gateway-admin-proxy/renew-gateway-admin.sh +++ b/scripts/gateway-admin-proxy/renew-gateway-admin.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu umask 077 -exec certbot renew --quiet \ +exec certbot renew --quiet --no-random-sleep-on-renew \ --config-dir /etc/gateway-admin-letsencrypt \ --work-dir /var/lib/gateway-admin-proxy/certbot \ --logs-dir /var/log/gateway-admin-certbot \ diff --git a/scripts/gateway-admin-proxy/renew-white-legacy.sh b/scripts/gateway-admin-proxy/renew-white-legacy.sh index 517bc95..b64a75c 100644 --- a/scripts/gateway-admin-proxy/renew-white-legacy.sh +++ b/scripts/gateway-admin-proxy/renew-white-legacy.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu umask 077 -exec certbot renew --quiet \ +exec certbot renew --quiet --no-random-sleep-on-renew \ --config-dir /etc/white-proxy-letsencrypt \ --work-dir /var/lib/white-proxy-certbot \ --logs-dir /var/log/white-proxy-certbot \ diff --git a/scripts/gateway-admin-proxy/white-proxy-cert.service b/scripts/gateway-admin-proxy/white-proxy-cert.service index 773c8bc..cde05e0 100644 --- a/scripts/gateway-admin-proxy/white-proxy-cert.service +++ b/scripts/gateway-admin-proxy/white-proxy-cert.service @@ -2,4 +2,5 @@ Description=Renew isolated public wildcard certificate for legacy white proxy [Service] Type=oneshot +TimeoutStartSec=30min ExecStart=/bin/sh /opt/gateway-admin-proxy/provision/renew-white-legacy.sh From 26f84bda60cb5e9ee9cc51198cef7ef7275bbf89 Mon Sep 17 00:00:00 2001 From: hellcat Date: Wed, 14 Oct 2026 10:20:51 +0800 Subject: [PATCH 03/25] Document accepted gateway domains, proxy boundaries and SSH verification --- docs/maintenance/GATEWAY_ADMIN_PROXY.md | 41 +++++++++++++++++++------ docs/maintenance/README.md | 1 + docs/maintenance/STATUS.md | 10 ++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/maintenance/GATEWAY_ADMIN_PROXY.md b/docs/maintenance/GATEWAY_ADMIN_PROXY.md index eca1820..b21ed31 100644 --- a/docs/maintenance/GATEWAY_ADMIN_PROXY.md +++ b/docs/maintenance/GATEWAY_ADMIN_PROXY.md @@ -2,18 +2,25 @@ 用户指定日期:2026-10-13。范围为两套既有网关管理容器的新域名,以及白色机的新版/旧版域名区域映射和两机场主站入口。源码与配置生成工具在 `scripts/gateway-admin-proxy/`;跨项目域名映射的唯一登记正文在 CoDocs `20-基础设施/访问与反代.md`。 -## 当前阶段:配置准备,未完成上线验收 +## 当前状态:已部署并完成指定入口验收 -- 两条新管理 A 记录已在 Namecheap 保存:getway-fuc.gemcook.net、getway-dpl.gemcook.net 指向 gateway-01;原记录和 NS 保留。 -- 新 CF 账户可访问。tomshell.online、tomtojapan.online 的根 A 与泛域 CNAME 均指向白色机、橙云,本轮已读取页面确认。 -- 两个新区域的 Cloudflare Origin CA 证书均已签发,覆盖各自根域和一级泛域,有效至 2041-10-09。私钥在白色机独立目录生成,没有发送到 CF 或写入 Git。证书安装与 Full (strict) 复验尚未完成。 -- 映射/请求头逻辑通过 81 项真实 Nginx 的回环集成检查,没有访问线上源站。服务器部署、后台登录和旧入口回归尚未完成。 -- 旧 tomshell.com.de 的根证书已过期,旧泛域匹配用的是仅覆盖 umami 的证书。Hostinger 已可访问;用户批准的 `_acme-challenge` CNAME 已保存并经权威 DNS 确认,指向 `old-com-de.acme.tomshell.online`。限定一个区域、白色机 IPv4 的 CF DNS 编辑令牌尚未成功创建;不关闭证书校验替代。 +2026-10-13 完成 DNS、HTTPS、独立续期和浏览器验收;2026-10-14 完成日志隐私收尾及 SSH 核验。两机运维目录均为 `/opt/gateway-admin-proxy/provision`,已推送并检出运维提交 `8ed9e195b84019f39c3ee9d9999bfd03efed5062`(根仓库实现提交 `4ad89b196cc5f03486ad9feea39a40c988a5608a`)。最终文档另随根仓库提交,不改变此运维版本。 -后续每一阶段按实际结果替换本节,不把配置生成、DNS 保存或证书签发当成业务上线完成。 +- Namecheap 两条管理 A 指向 gateway-01,权威 DNS 与页面已确认;原邮件记录和 NS 保留。 +- 两个新 CF 区域的根域/泛域解析到白色机且橙云,Full (strict) 已保存复核。各自 CF Origin CA 证书已安装,根域和一级泛域有效至 2041-10-09;私钥只在白色机生成、保存。 +- gateway-01 两个管理源域使用隔离的公开 LE 证书;旧 tomshell.com.de 使用根域加一级泛域的公开 LE 证书,两者有效至 2027-01-11。旧 `fu.tomshell.com.de` 的过期入站证书指针已更新,上游与专用入口保留。 +- 已批准的 Hostinger ACME CNAME 与仅 tomshell.online DNS 编辑、仅白色机 IPv4 可用的 CF 令牌已配置。两套隔离续期 dry-run 通过;两个证书定时器和白色机官方 CF IP 同步定时器 active,信任列表含官方 22 段。 +- 两个新管理入口均使用原有效账号完成登录、设置页切换和退出,没有保存业务配置。临时凭据复制获得具体批准,服务器和本机临时副本均已删除。 +- dpl 主站浏览器确认 DP-LINK.NET 品牌及 API/静态资源;fuc 登录页确认“登录—福云”及资源。只重写 URL 域名,保留品牌文案。两个新区域均无 Cache Rule/缓存响应覆盖,管理响应 private/no-store、CF DYNAMIC;未新增 BIC/WAF 例外。 +- 真实 Nginx 回环检查 93 个断言通过;最终重载后管理登录页、两主站入口复验成功,两机 `nginx -t`、受管文件哈希通过。四个原网关容器的启动时间和重启计数与本轮开始前相同。 +- 白色机实际 SSH 登录成功,IPv4/IPv6 TCP 22 监听,SSH active;UFW inactive、INPUT ACCEPT。本轮没有修改 SSH、防火墙或安全组。 + +验收范围不含业务用户登录、购买/支付、全站操作、多地区或所有泛域子站;未重新执行订阅/节点全链路测试。截图与忽略的执行产物在 `codex/output/gateway-admin-domain-20261013/`。 ## 用户指定的边界 +2026-10-13 用户追加确认:gemcook.net 同时承担邮件业务,属于保护资源。此次仅新增两个 getway 子域 A;原 NS、MX、SPF、DKIM、DMARC 和邮件子域记录不变,不迁移 DNS 托管,不以网站根站配置覆盖邮件用途。用户已明确将 gemcook.net 根站延后,本轮完成指定的子域区域即可。DNS 保留/读取不代表实发邮件验收,任务没有发测试邮件。 + | 用途 | 源站标识 | 白色机入口标识 | | --- | --- | --- | | fuc 网关管理 | getway-fuc.gemcook.net | getway-fuc.tomshell.online | @@ -25,7 +32,9 @@ 主域名映射不会自行完成子域 DNS、证书或 Nginx 匹配。本次泛映射按同一前缀对应源站,拒绝不属于指定区域和多级子域的转发;多级主机需额外证书和登记。现有专用匹配优先保留作为兼容入口,不能把已有 TTC、Git、日语服务或旧网关入口送往错误上游。 -gemcook.net 根域本次 DNS 查询没有 A/AAAA,根反代配置不能据此声称有可访问的根站点。泛映射也不证明任意源站子域已经部署;逐实际入口验收。 +gemcook.net 根域本次 DNS 查询没有 A/AAAA,根站按用户确认延期。diaodu.us 根域和 nasa.diaodu.us 在 source Nginx 中没有对应 HTTPS vhost/证书,返回默认 Gitea 单主机证书,严格代理因名称不匹配返回 502;用户明确本轮不新建这两个源站。不能关闭 TLS 校验、猜 IP 或指向 master/Gitea 代替。泛映射不证明任意源站子域已经部署;逐实际入口验收。 + +旧专用入口的有限回归:fu、dpl、Gitea、TTC 返回 200;master 根路径返回既有 Laravel 404,未验收其管理路由。这些专用入口优先于新通用泛映射;原证书和其他配置不一并替换。 本地访问只经登记的白色机入口,包含资源、重定向、API 和 WebSocket。没有通过源站 IP、Host 覆盖、hosts 或另一机器代抓取补本地验证。 @@ -35,8 +44,12 @@ gemcook.net 根域本次 DNS 查询没有 A/AAAA,根反代配置不能据此 旧管理入口的私有路径在服务器运行时从既有容器配置提取,并与旧 vhost 比对,不写入 Git、CoDocs 或本文。源站只信任白色机传来的清洗后 X-Forwarded-For;直接请求的伪造标头不受信任。新增源域名证书使用独立 Certbot 目录和续期钩子,仅检查并重载宿主 Nginx,不运行原订阅网关的全局证书钩子。 +注意旧 SubSieve 管理账号:持久化 JSON 中的有效配置优先于 Docker ADMIN_USER/ADMIN_PASS 环境变量。本轮首次按环境变量登录被拒,随后只读核对有效配置并以现有账号验收成功;没有重置账号。以后取配置沿实际加载顺序核对,不把环境变量当作唯一事实。fuc 的既有私有管理路径仍须保留在 URL 上,dpl 从根路径进入。 + 白色机新橙云 vhost 单独引入官方 CF IP 信任文件,再向上游发送清洗后的真实 IP。新代理保留严格上游证书校验、路径、查询参数、WebSocket;Origin/Referer 只在与当前请求主机一致时转换,跨站值保留,避免放宽 CSRF 判断。Cookie、重定向和正文资源域名按实际入口复核;不盲目移除 CSP、CORS 或其他安全标头。 +新生成 vhost 的访问日志只记失败状态、主机、上游状态和耗时,路径、查询参数、客户端 IP 不进入 `/var/log/nginx/gateway-admin-proxy.health.log`;错误日志为独立文件、crit 级别。原订阅过滤/安全日志和保留的旧专用 vhost 未批量调整,不能据此宣称全部历史日志都无敏感信息。 + Cloudflare Origin CA 只适用于两个保持橙云的代理区域。用户浏览器使用 CF 边缘的公开可信证书;关闭橙云后直连白色机会不受浏览器信任。旧 com.de 不依赖此证书,须用公开可信证书。源站 CA 到期日须登记,不能假称它由 Certbot 自动续期。 ## 隔离证书与运维入口 @@ -45,7 +58,7 @@ Cloudflare Origin CA 只适用于两个保持橙云的代理区域。用户浏 - 白色机:`install-origin-cert.py` 校验公开证书 SAN、有效期和服务器本地私钥公钥匹配,再安装两个 CF 源站证书;`white-proxy-cloudflare-ips.timer` 只更新新橙云站点引用的官方 IP 信任文件。 - 旧 com.de:`issue-white-legacy.sh` 使用隔离目录 `/etc/white-proxy-letsencrypt`;`legacy-dns-hook.py` 只接受旧根域/一级泛域的 ACME 挑战,仅写委托目标 TXT,确认权威 DNS 传播后再继续,清理只删除自身创建且内容未改变的 ID。`white-proxy-cert.timer` 每日检查。 - DNS 私有配置仅在白色机:`/etc/white-proxy-dns/config.json` 保存 zone_id/target,`cf-token` 保存令牌,均 root 所有、0600;Git 和普通文档不得保存令牌。CF 权限本身是整个 tomshell.online 区域 DNS 编辑,并非单 TXT 权限,另有白色机 IPv4 限制。用户于本轮明确批准创建与保存这一凭据。 -- 旧 com.de 证书续期依赖该 CNAME、CF 令牌和白色机出口 IP。变更这些资源先更新私有配置,再验证续期;不能删除其他 TXT、改变 NS 或创建无限制账户令牌。正式上线后必须执行隔离 `certbot renew --dry-run --no-directory-hooks` 验证自动续期。 +- 旧 com.de 证书续期依赖该 CNAME、CF 令牌和白色机出口 IP。变更这些资源先更新私有配置,再验证续期;不能删除其他 TXT、改变 NS 或创建无限制账户令牌。两套隔离续期已以 `certbot renew --dry-run --no-directory-hooks --no-random-sleep-on-renew` 验证,部署钩子只检查/重载 Nginx;挑战 TXT 已清理。证书服务超时分别 15/30 分钟,timer 自带错峰,Certbot 不再额外随机长等待。 ## Git 发布与回退 @@ -54,3 +67,13 @@ Cloudflare Origin CA 只适用于两个保持橙云的代理区域。用户浏 deploy.py 只管理列出的独立 Nginx 文件;原文件有精确备份与前后哈希,手工修改的管理文件或未被接管的同名文件会阻止覆盖。旧泛域配置只有在公开可信泛域证书到位并提供当前文件精确哈希时才撤回;保留原精确 master 匹配和其他专用配置。Nginx 检查/重载失败自动恢复本次文件并重新校验,不能操作原 Compose 的 down/update 或重启原容器。 回退时先核对当前哈希仍为本轮结果,按服务器私有备份仅恢复本轮文件。新证书账户、目录、定时器与旧实例隔离;撤销新域名入口不能撤销原订阅域名。回退或尚未完成的项目同任务同步 CoDocs、本文和 STATUS。 + +本次关键恢复点在两机 `/root/codex-backups/gateway-admin-proxy/`: + +- gateway HTTPS 初装:`20261013-121013-829807-gateway`;最终日志调整:`20261014-021439-027004-gateway`。 +- 白色机旧泛域撤回/旧 fu 证书更换前:`20261013-125258-241302-white`;最终日志调整:`20261014-021430-957452-white`。 +- `preflight-20261013/` 保留变更前配置哈希和容器启动记录。每个部署备份有 manifest.json、原文件/模式;目录 root 私有。最后恢复点只是前一阶段,完整撤销须选择对应初装/旧泛域阶段,不能盲目回退所有共享配置。 + +后续受管脚本更新:只提交本任务 → `git subtree split -q --prefix=scripts/gateway-admin-proxy --branch ops/gateway-admin-proxy` → 推送 main 与 ops 分支 → 使用已推送提交的 Git bundle fetch → 两机在干净运维目录检出明确 SHA → 执行 `python3 deploy.py gateway --stage https` / `python3 deploy.py white --stage https`。旧文件已接管后仍按 state.json 哈希阻止覆盖并行修改;首次接管须提供当前旧 master/fu 的精确哈希。不要修改 SSH/防火墙以解决 Web 反代问题。 + +域名前缀更换须同步 DNS、SAN 覆盖、Host/SNI、白色机登记及实际登录/资源验收。此处新主站/后台区域没有全区绕过缓存,不能把此前独立订阅区域的调优直接照搬;管理会话和 API 的 private/no-store 应继续验收。 diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index 99f5721..3194dd2 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -22,6 +22,7 @@ | STATUS.md | 最近验证基线、已知限制与待验收项目 | | CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 | | NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 | +| GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 | 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index cd0e435..e85df59 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,16 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-13/14 管理入口与白色机区域反代 + +指定的两网关管理源域名及白色机新旧区域映射、两个主站入口已部署。两机精确运维 Git 版本为 `8ed9e195b84019f39c3ee9d9999bfd03efed5062`,先提交/推送再 Git fetch/检出;最终受管配置哈希、Nginx 语法与重载通过。两管理入口以既有有效账号完成登录、设置页切换及退出,未保存业务配置;两主站浏览器品牌/登录页、API/资源验收通过。真实 Nginx 回环检查 93 个断言通过。恢复点、证书与日后更新方法见 [管理反代专题](GATEWAY_ADMIN_PROXY.md),跨项目完整映射只在 CoDocs 反代表维护。 + +两个新 CF 区域保持橙云、Full (strict),Origin CA 根域/一级泛域证书已安装;旧 com.de 和两新管理源域使用隔离公开 LE 证书,签发与续期 dry-run 通过。两证书 timer 和新橙云官方 IP 同步 timer active,CF 信任文件 22 段。新生成 vhost 只记录失败状态/主机/耗时,避免查询令牌进日志;原专用入口/订阅安全日志未批量改变。CF 令牌仅存白色机 root 私有文件,批准的管理验收临时凭据副本均已删除。 + +受保护资源:gemcook.net 仅新增两个 getway A,原 NS、MX、SPF、DKIM、DMARC 和邮件记录保留,未发测试邮件。四个原网关容器的启动时间/重启计数与本轮开始前相同;未改用户、套餐、订阅上游或业务代码。2026-10-14 实际 SSH 登录白色机成功,IPv4/IPv6 22 监听、SSH active,UFW inactive、INPUT ACCEPT;本轮没有改 SSH、防火墙或安全组。此结果不抹掉下文 2026-10-11 首次签发曾重启旧订阅网关的事故。 + +用户明确延期:gemcook.net 无根站 A/AAAA;diaodu.us 根域与 nasa 无匹配源站 HTTPS vhost/证书,严格代理仍为 502,本轮不新建、不关闭证书校验或借用 master/Gitea。旧 fu/dpl/Gitea/TTC 仅做根页 200 回归,master 根页既有 Laravel 404;不代表全部管理路由业务验收。业务用户登录、购买/支付、全站、多地区以及订阅/节点全链路未在本轮重测,既有本机节点连通跳过决定保留。 + ## 最新代码基线 - 邮件页签实现版本 13117587c86aa49dafad3467b04d90469f4de2ab;本地提交推送、生产 Git fast-forward 完成。 From 54c5a5ddd9b977f2fed307a8f15a609b1089ce8d Mon Sep 17 00:00:00 2001 From: hellcat Date: Fri, 16 Oct 2026 16:28:41 +0800 Subject: [PATCH 04/25] Add weiwei relay administration reverse proxy --- scripts/gateway-admin-proxy/render.py | 5 +++++ scripts/gateway-admin-proxy/verify-maps.py | 1 + 2 files changed, 6 insertions(+) diff --git a/scripts/gateway-admin-proxy/render.py b/scripts/gateway-admin-proxy/render.py index 098663e..533a599 100644 --- a/scripts/gateway-admin-proxy/render.py +++ b/scripts/gateway-admin-proxy/render.py @@ -155,6 +155,7 @@ map $host $gateway_admin_origin_host { ~^(?[a-z0-9-]+)\\.tomshell\\.online$ $gnew.gemcook.net; tomshell.com.de diaodu.us; ~^(?[a-z0-9-]+)\\.tomshell\\.com\\.de$ $gold.diaodu.us; + weiwei.tomtojapan.online hw.lshan.eu.org; dpl.tomtojapan.online dp-link.net; fuc.tomtojapan.online xn--9kqy16h.com; default ''; @@ -174,6 +175,8 @@ map "$host|$http_referer" $gateway_admin_referer { 'gemcook.net', 'tomshell.online', True), ('old', 'tomshell.com.de *.tomshell.com.de', legacy_certificate, 'diaodu.us', 'tomshell.com.de', False), + ('weiwei', 'weiwei.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online', + 'hw.lshan.eu.org', 'weiwei.tomtojapan.online', True), ('dpl-site', 'dpl.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online', 'dp-link.net', 'dpl.tomtojapan.online', True), ('fuc-site', 'fuc.tomtojapan.online', '/etc/white-proxy-origin/tomtojapan.online', @@ -199,6 +202,8 @@ map "$host|$http_referer" $gateway_admin_referer { {body_rewrites(source, alias, key)} }} ''' + if key == 'weiwei': + location += ' add_header Cache-Control "private, no-store" always;\n' data += https_server(names, cert, location, cloudflare=cf, key_name='privatekey.pem' if cf else 'privkey.pem') files[f'white-{key}-proxy.conf'] = data diff --git a/scripts/gateway-admin-proxy/verify-maps.py b/scripts/gateway-admin-proxy/verify-maps.py index 4aaca4c..e6435f8 100644 --- a/scripts/gateway-admin-proxy/verify-maps.py +++ b/scripts/gateway-admin-proxy/verify-maps.py @@ -23,6 +23,7 @@ with tempfile.TemporaryDirectory(prefix='gateway-proxy-maps-') as folder: port = sock.getsockname()[1] maps = render.render_white('https', '/unused')['00-gateway-admin-proxy-maps.conf'] bodies = [ + ('weiwei', 'weiwei.tomtojapan.online', 'hw.lshan.eu.org', 'weiwei.tomtojapan.online', 'weiwei', '海外转发'), ('dpl', 'dpl.tomtojapan.online', 'dp-link.net', 'dpl.tomtojapan.online', 'dpl-site', 'DP-LINK.NET'), ('fuc', 'fuc.tomtojapan.online', 'xn--9kqy16h.com', 'fuc.tomtojapan.online', 'fuc-site', '福云.com'), ('new', 'getway-dpl.tomshell.online', 'gemcook.net', 'tomshell.online', 'new', 'GEMCOOK.NET'), From 76d9e8d77a6c3de6b1083751c51f25a0b1de6785 Mon Sep 17 00:00:00 2001 From: hellcat Date: Fri, 16 Oct 2026 16:34:01 +0800 Subject: [PATCH 05/25] Document weiwei proxy deployment and acceptance scope --- docs/maintenance/GATEWAY_ADMIN_PROXY.md | 8 ++++++++ docs/maintenance/STATUS.md | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/docs/maintenance/GATEWAY_ADMIN_PROXY.md b/docs/maintenance/GATEWAY_ADMIN_PROXY.md index b21ed31..27dce8e 100644 --- a/docs/maintenance/GATEWAY_ADMIN_PROXY.md +++ b/docs/maintenance/GATEWAY_ADMIN_PROXY.md @@ -2,6 +2,14 @@ 用户指定日期:2026-10-13。范围为两套既有网关管理容器的新域名,以及白色机的新版/旧版域名区域映射和两机场主站入口。源码与配置生成工具在 `scripts/gateway-admin-proxy/`;跨项目域名映射的唯一登记正文在 CoDocs `20-基础设施/访问与反代.md`。 +## 2026-10-16 节点中转后台入口 + +按用户确认,新增 `weiwei.tomtojapan.online` 精确入口,跨项目映射唯一登记在 CoDocs `PRX-020`。复用既有橙云泛域 DNS、Origin CA 和当前 Full (strict),没有新增 DNS、证书或放宽安全规则。白色机检出已推送运维版本 `f8aeb48d9ab8ee97cb89cd7e7f8a3b617aec7532` 后执行已有部署工具,Nginx 检查和重载成功;共享主机 map 新增一项,新建 `/etc/nginx/conf.d/white-weiwei-proxy.conf`,其他受管配置保持原内容。gateway-01 没有更新,本页旧两机相同版本记录仅适用于原验收时点。 + +`/dashboard` 经入口返回 HTTP 200、NEXV HTML;6个明确引用的 JS/CSS 均200且无源站主机残留。浏览器最终留在代理根路径,显示“海外转发”用户名/密码登录表单;可见截图已核对。响应含 private/no-store、CF DYNAMIC;仅 DYNAMIC 不作为缓存验证依据。96项真实Nginx回环检查通过,覆盖新增正文域名重写;原有跨站Origin/Referer隔离检查继续通过。上游保持HTTPS证书验证和Host/SNI,保留路径/参数、WebSocket配置及Cookie/重定向转换;没有执行真实WebSocket握手或登录后业务操作。一次Python urllib资源请求403,随后curl同资源全为200,未为此调整WAF。 + +回滚点:白色机 `/root/codex-backups/gateway-admin-proxy/20261016-083022-959962-white`,恢复前先核对manifest当前哈希,只撤回本轮map变化和新weiwei文件,再执行Nginx检查/重载、恢复state登记并同步PRX。未改后台账号、节点转发规则、流量端口、SSH或防火墙。登录与业务验收留待有效账号操作,不将登录页验收当作登录成功。 + ## 当前状态:已部署并完成指定入口验收 2026-10-13 完成 DNS、HTTPS、独立续期和浏览器验收;2026-10-14 完成日志隐私收尾及 SSH 核验。两机运维目录均为 `/opt/gateway-admin-proxy/provision`,已推送并检出运维提交 `8ed9e195b84019f39c3ee9d9999bfd03efed5062`(根仓库实现提交 `4ad89b196cc5f03486ad9feea39a40c988a5608a`)。最终文档另随根仓库提交,不改变此运维版本。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index e85df59..f423811 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,10 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-16 中转后台反代 + +weiwei精确反代已按Git流程部署到白色机运维 `f8aeb48`,96项回环检查、Nginx检查/重载、HTTPS入口及6个JS/CSS资源200通过。浏览器“海外转发”登录表单显示正常。CoDocs `PRX-020` 是唯一映射登记;恢复点与细节见 [反代专题](GATEWAY_ADMIN_PROXY.md#2026-10-16-节点中转后台入口)。本次没有登录后台或变更节点流量链路,登录后业务及真实WebSocket尚未验收。 + ## 2026-10-13/14 管理入口与白色机区域反代 指定的两网关管理源域名及白色机新旧区域映射、两个主站入口已部署。两机精确运维 Git 版本为 `8ed9e195b84019f39c3ee9d9999bfd03efed5062`,先提交/推送再 Git fetch/检出;最终受管配置哈希、Nginx 语法与重载通过。两管理入口以既有有效账号完成登录、设置页切换及退出,未保存业务配置;两主站浏览器品牌/登录页、API/资源验收通过。真实 Nginx 回环检查 93 个断言通过。恢复点、证书与日后更新方法见 [管理反代专题](GATEWAY_ADMIN_PROXY.md),跨项目完整映射只在 CoDocs 反代表维护。 From 910d3cfe01fa58dc97e8c05521f1279ad7457b86 Mon Sep 17 00:00:00 2001 From: hellcat Date: Fri, 16 Oct 2026 17:27:20 +0800 Subject: [PATCH 06/25] Document verified Japan node onboarding and administrator access --- docs/maintenance/NODE_ONBOARDING.md | 20 +++++++++++++++++++- docs/maintenance/STATUS.md | 10 +++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index 27e10af..edd819b 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -1,6 +1,23 @@ # 节点与订阅网关接入 -核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 + +## 2026-10-16 日本节点接入 + +本轮选择 `node-jp02`;现场确认 IPv4 出口地区 JP、24443 空闲、无既有 DPL 服务,生产原来只有美国节点,故未重复接入。新节点运行接入及正式开放完成,管理面板浏览器验收已通过;下文首节点历史验收范围不自动扩大到本轮。 + +- 新节点/机器 ID 均为 2,名称 `🇯🇵 日本 JP - 02`(数据库 UTF-8 字节核对正确);AnyTLS 独立 `dpl-node`,24443。中转新增 `dpl-jp02-anytls`,入口 `haiwi.lshan.eu.org:47202`,目标 `193.111.31.52:24443`;原 `jp02` 的 46202 → 443、原美国规则均保留,规则总数由 24 变为 25,收发 Proxy Protocol 均禁用。 +- `jp02.deeplink-subscribe.com` 新增 A=193.111.31.52、DNS only;证书有效至 2027-01-14。沿用节点既有 Snap Certbot 续期调度,新证书独立 renewal 配置,deploy hook 以 `/bin/sh /opt/dpl-node/provision/renew-node.sh` 调用,只重启新服务。dry-run 加 `--no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks` 后通过,原 XrayR PID 1419132、启动时间保持原值。首次演练因随机等待超出 SSH 超时而 BrokenPipe,不记为通过;随后无随机等待重试成功。 +- 网关上以隔离测试账号的完整 sing-box 订阅实际联网通过:严格 TLS、日本出口 193.111.31.52/JP、目标 HTTPS TLSv1.3,完整 DNS/路由启动;订阅 200、gzip JSON、private/no-store、DYNAMIC,无 Age 或挑战头。面板收到上下行 11354/125812 字节。中转后台入口至目标诊断成功。 +- 按既有套餐范围 1/6/7/8,将节点加入基础节点组 1;没有新增套餐/现有用户分组关联,没有改价格、流量、周期、设备限制。测试用户 ID 16 已禁用、订阅 403。正式有效用户订阅复核同时包含美国 47101、日本 47202,二者严格 TLS。 +- 运维代码通过已推送 Git 提交 `b05c3215505c6ea70042aed0b102c682406528be` 的 bundle 检出到 `/opt/dpl-node/provision`;独立二进制 v1.13 的 SHA256 与本文固定值一致,面板运行代码未改。节点缺 Git,原 Debian11 源包下载 404,任务临时使用 Debian 官方 HTTPS 归档索引安装 Git 及必要依赖;未改 `/etc/apt/sources.list`,没有全量升级系统。 +- 机器配置/令牌及 QA 参数通过严格主机密钥校验、经白色机的 SSH 流式传送,未生成本地秘密副本。节点配置及网关独立 QA 文件均 600、父目录 700。备份:面板和节点各自 `/root/codex-backups/dpl/node-onboarding-20261016-jp02`,正式关联回退使用面板 `before-promotion.json` 精确恢复,不恢复整库。 + +管理界面经已登记主站反代实际登录、节点列表及服务器列表切换通过;两台服务器均在线,日本节点名称、基础节点组、47202/24443、SNI 和严格校验开关回读一致,编辑表单只检查后取消,未额外提交默认表单值。 + +最终回归:原美国节点关键配置和套餐全部字段与本轮前快照一致;面板实际下发用户集合不包含 QA 用户 16。共享网关四容器的 ID、启动时间、重启计数均保持原值。内核使用内存配置,磁盘 kernel 目录只见节点证书;没有把未找到 JSON 配置误判为服务故障。 + +后台访问按 CoDocs DPL 档案 → PRX-020 到已登记反代;账号凭据只在 CoDocs 私有凭据正文,不写入项目。证据截图位于 `codex/output/node-jp02-20261016/`。未执行本机节点直连、手机 GUI、TUN、多地区或并发验收;不把服务器侧通过写成这些项目通过。 ## 当前连接关系 @@ -141,6 +158,7 @@ php scripts/node-onboarding/promote.php \ | 机场 | 节点 | 协议 | 落地 | 中转 | 面板 ID | 状态 | | --- | --- | --- | --- | --- | --- | --- | | dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443 | haiwi.lshan.eu.org:47101 | 1 | 已上线;基础节点组,正式套餐 1/6/7/8 | +| dpl | 🇯🇵 日本 JP - 02 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 | 已完成:独立服务、节点 TLS、新中转、新网关、DNS/橙云、真实 IP 比对、新旧订阅 vhost 官方 IP 自动同步、两张证书续期演练。官方 sing-box 1.14.3 的完整实际订阅(仅把入站替换为临时本机 SOCKS)经中转联网,出口 64.44.115.18、US、HTTPS TLSv1.3,严格节点证书校验;完整 DNS 和远程分流规则实际启动通过,面板已记录上下行流量。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index f423811..b1e693c 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,9 +2,17 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-16 日本节点 + +`node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 02`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。 + +按用户最新管理员约定,生产确认默认管理员不存在后新增并回读核实(账号 ID 17),原有两个管理员属性及密码未改。实际凭据独立登记在 CoDocs,浏览器登录通过;没有发送验证邮件。 + +中转后台已通过 CoDocs PRX-020 登录、保存新增规则并完成诊断;这补充下文反代部署阶段仅登录页的验收范围,不代表真实 WebSocket 或其他业务全部验收。 + ## 2026-10-16 中转后台反代 -weiwei精确反代已按Git流程部署到白色机运维 `f8aeb48`,96项回环检查、Nginx检查/重载、HTTPS入口及6个JS/CSS资源200通过。浏览器“海外转发”登录表单显示正常。CoDocs `PRX-020` 是唯一映射登记;恢复点与细节见 [反代专题](GATEWAY_ADMIN_PROXY.md#2026-10-16-节点中转后台入口)。本次没有登录后台或变更节点流量链路,登录后业务及真实WebSocket尚未验收。 +weiwei精确反代已按Git流程部署到白色机运维 `f8aeb48`,96项回环检查、Nginx检查/重载、HTTPS入口及6个JS/CSS资源200通过。浏览器“海外转发”登录表单显示正常。CoDocs `PRX-020` 是唯一映射登记;恢复点与细节见 [反代专题](GATEWAY_ADMIN_PROXY.md#2026-10-16-节点中转后台入口)。反代部署阶段未登录后台或变更节点流量链路;本轮节点任务的登录后验收范围见上节,真实WebSocket仍未验收。 ## 2026-10-13/14 管理入口与白色机区域反代 From 522666c33915dbde521f5db0a2e877b72b824185 Mon Sep 17 00:00:00 2001 From: hellcat Date: Fri, 16 Oct 2026 17:48:29 +0800 Subject: [PATCH 07/25] Define node numbering independently from VPS identifiers --- docs/maintenance/BUSINESS_RULES.md | 4 ++++ docs/maintenance/NODE_ONBOARDING.md | 15 ++++++++++++--- docs/maintenance/STATUS.md | 4 +++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/maintenance/BUSINESS_RULES.md b/docs/maintenance/BUSINESS_RULES.md index 2c2d28a..34529ff 100644 --- a/docs/maintenance/BUSINESS_RULES.md +++ b/docs/maintenance/BUSINESS_RULES.md @@ -1,5 +1,9 @@ # 业务约束 +## 节点命名 + +节点展示编号与 VPS 代号、数据库 ID 和部署 key 的关系,统一遵循[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号)。新增、迁移或改名时必须先核对生产清单;具体格式和分配方法只在该节维护。 + ## 邮件渠道 - 验证码、登录链接、工单回应邮件通知优先 Resend;工单通知须有有效用户邮箱。额度耗尽或明确无法使用时走常规邮箱。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index edd819b..b518f14 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -6,7 +6,7 @@ 本轮选择 `node-jp02`;现场确认 IPv4 出口地区 JP、24443 空闲、无既有 DPL 服务,生产原来只有美国节点,故未重复接入。新节点运行接入及正式开放完成,管理面板浏览器验收已通过;下文首节点历史验收范围不自动扩大到本轮。 -- 新节点/机器 ID 均为 2,名称 `🇯🇵 日本 JP - 02`(数据库 UTF-8 字节核对正确);AnyTLS 独立 `dpl-node`,24443。中转新增 `dpl-jp02-anytls`,入口 `haiwi.lshan.eu.org:47202`,目标 `193.111.31.52:24443`;原 `jp02` 的 46202 → 443、原美国规则均保留,规则总数由 24 变为 25,收发 Proxy Protocol 均禁用。 +- 新节点/机器 ID 均为 2,名称 `🇯🇵 日本 JP - 01`(同日按独立编号规则修正;数据库 ID 2 和部署 key `jp02` 保留);AnyTLS 独立 `dpl-node`,24443。中转新增 `dpl-jp02-anytls`,入口 `haiwi.lshan.eu.org:47202`,目标 `193.111.31.52:24443`;原 `jp02` 的 46202 → 443、原美国规则均保留,规则总数由 24 变为 25,收发 Proxy Protocol 均禁用。 - `jp02.deeplink-subscribe.com` 新增 A=193.111.31.52、DNS only;证书有效至 2027-01-14。沿用节点既有 Snap Certbot 续期调度,新证书独立 renewal 配置,deploy hook 以 `/bin/sh /opt/dpl-node/provision/renew-node.sh` 调用,只重启新服务。dry-run 加 `--no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks` 后通过,原 XrayR PID 1419132、启动时间保持原值。首次演练因随机等待超出 SSH 超时而 BrokenPipe,不记为通过;随后无随机等待重试成功。 - 网关上以隔离测试账号的完整 sing-box 订阅实际联网通过:严格 TLS、日本出口 193.111.31.52/JP、目标 HTTPS TLSv1.3,完整 DNS/路由启动;订阅 200、gzip JSON、private/no-store、DYNAMIC,无 Age 或挑战头。面板收到上下行 11354/125812 字节。中转后台入口至目标诊断成功。 - 按既有套餐范围 1/6/7/8,将节点加入基础节点组 1;没有新增套餐/现有用户分组关联,没有改价格、流量、周期、设备限制。测试用户 ID 16 已禁用、订阅 403。正式有效用户订阅复核同时包含美国 47101、日本 47202,二者严格 TLS。 @@ -80,11 +80,20 @@ BIC 是用户获知请求头筛查减少的风险后具体批准的兼容调整 这两个专用订阅区域已经按用户要求使用全域 Cache Rule;在同一区域换前缀无需改缓存范围,跨到新购区域必须重新配置。规则只负责绕过边缘缓存,不能自动使新域名可用。更换时逐项核对 DNS/代理、宿主 Nginx vhost、证书及续期、网关允许的 Host/上游、面板订阅 URL、精确主机 BIC/TLS 配置,并验收有效订阅及原机场回归。新增静态站点需重新考虑区域级缓存范围,不能盲目套用。 +## 节点命名与编号 + +本节是 DPL 节点命名的唯一规则正文。展示编号按国家分别从 `01` 起递增,采用“旗帜 中文国家 国家代码 - 两位序号”的格式,例如 `🇺🇸 美国 US - 01`、`🇯🇵 日本 JP - 01`。接入前先查询生产节点清单,按该国家已有编号分配下一号,核对名称和编号均不冲突;不得从 VPS 代号截取数字作为节点展示编号。 + +VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立。部署 key 是唯一且稳定的运维标识,不承担展示序号含义;选择服务器或迁移宿主不自动改变节点编号。接入参数单必须明确记录展示名称、部署 key、实际 VPS 和面板 ID 的对应关系。 + +例如,DPL 第一条日本节点部署在 `node-jp02` 上,展示名应为 `🇯🇵 日本 JP - 01`。本轮已有部署 key `jp02`、机器名 `dpl-jp02`、数据库 ID 2 均保留;修正展示名不连带重命名 DNS、证书、中转、服务、备份目录或其他内部资源。 + ## 新节点参数单 | 参数 | 首次实例 | 后续填写规则 | | --- | --- | --- | -| key / name | us01 / 🇺🇸 美国 US - 01 | 唯一 key;旗帜、中文国家、国家代码、两位序号 | +| key | us01 | 唯一、稳定的部署标识;不用于推导展示序号 | +| name | 🇺🇸 美国 US - 01 | 按[节点命名与编号](#节点命名与编号)从生产清单分配,按国家从01递增,不沿用VPS序号 | | server | node-us01 | 从 CoDocs 项目关系及连接资料确认 | | listen-port | 24443 | 当场核对 TCP/UDP 和防火墙,不复用过期判断 | | relay-host / relay-port | haiwi.lshan.eu.org / 47101 | 新增规则,不改原规则 | @@ -158,7 +167,7 @@ php scripts/node-onboarding/promote.php \ | 机场 | 节点 | 协议 | 落地 | 中转 | 面板 ID | 状态 | | --- | --- | --- | --- | --- | --- | --- | | dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443 | haiwi.lshan.eu.org:47101 | 1 | 已上线;基础节点组,正式套餐 1/6/7/8 | -| dpl | 🇯🇵 日本 JP - 02 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 | +| dpl | 🇯🇵 日本 JP - 01 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 | 已完成:独立服务、节点 TLS、新中转、新网关、DNS/橙云、真实 IP 比对、新旧订阅 vhost 官方 IP 自动同步、两张证书续期演练。官方 sing-box 1.14.3 的完整实际订阅(仅把入站替换为临时本机 SOCKS)经中转联网,出口 64.44.115.18、US、HTTPS TLSv1.3,严格节点证书校验;完整 DNS 和远程分流规则实际启动通过,面板已记录上下行流量。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index b1e693c..2ff4fe7 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -4,10 +4,12 @@ ## 2026-10-16 日本节点 -`node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 02`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。 +`node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 01`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。 按用户最新管理员约定,生产确认默认管理员不存在后新增并回读核实(账号 ID 17),原有两个管理员属性及密码未改。实际凭据独立登记在 CoDocs,浏览器登录通过;没有发送验证邮件。 +同日按用户要求落实[独立节点编号规则](NODE_ONBOARDING.md#节点命名与编号):日本第一节点展示名修正为 `🇯🇵 日本 JP - 01`,只改节点 ID 2 的名称;VPS `node-jp02`、部署 key、机器 ID、中转、端口、证书和权限组保留。私有旧值备份在生产 `/root/codex-backups/dpl/node-name-20261016-jp01/`。 + 中转后台已通过 CoDocs PRX-020 登录、保存新增规则并完成诊断;这补充下文反代部署阶段仅登录页的验收范围,不代表真实 WebSocket 或其他业务全部验收。 ## 2026-10-16 中转后台反代 From 6af676421b81ed700b5f078e5121636983a672a7 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 08:29:05 +0800 Subject: [PATCH 08/25] Add append-only batch node onboarding and recovery checks --- AGENTS.md | 8 +- docs/maintenance/DEPLOYMENT.md | 4 +- docs/maintenance/NODE_BATCH.md | 149 +++++++ docs/maintenance/NODE_ONBOARDING.md | 74 +--- docs/maintenance/NODE_RUNBOOK.md | 254 ++++++++++++ docs/maintenance/README.md | 8 +- docs/maintenance/STATUS.md | 22 ++ scripts/node-onboarding/batch.py | 368 ++++++++++++++++++ scripts/node-onboarding/command-adapter.py | 66 ++++ scripts/node-onboarding/operations.php | 332 ++++++++++++++++ scripts/node-onboarding/promote.php | 66 ++-- scripts/node-onboarding/readiness.php | 46 ++- scripts/node-onboarding/rollback.php | 16 + scripts/node-onboarding/stage.php | 179 +++++---- scripts/node-onboarding/verify-node.py | 284 +++++++++++--- .../PanelChangeIsolatedTest.php | 116 ++++++ tests/node-onboarding/test_batch.py | 202 ++++++++++ 17 files changed, 1941 insertions(+), 253 deletions(-) create mode 100644 docs/maintenance/NODE_BATCH.md create mode 100644 docs/maintenance/NODE_RUNBOOK.md create mode 100644 scripts/node-onboarding/batch.py create mode 100644 scripts/node-onboarding/command-adapter.py create mode 100644 scripts/node-onboarding/operations.php create mode 100644 scripts/node-onboarding/rollback.php create mode 100644 tests/node-onboarding/PanelChangeIsolatedTest.php create mode 100644 tests/node-onboarding/test_batch.py diff --git a/AGENTS.md b/AGENTS.md index 725217c..8e672f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # dpl 项目维护 -开始维护前阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。 +先读 [CoDocs 项目总览](/Users/hellcat/Documents/CoDocs/项目总览.md) 与首次会话的 [持续维护协议](/Users/hellcat/Documents/CoDocs/00-协作规范/维护协议.md),再阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。 - 保留用户及其他任务既有修改,提交只包含本次范围;不要重置或清理无关文件。 - 默认 Git 发布:提交、推送,再让服务器更新到明确版本并验证;未经明确要求不得打包覆盖。 @@ -10,4 +10,10 @@ - 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。 - 后台变更实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。 +## 批量节点接入:默认启动一个子助手 + +用户要求执行批量节点接入(明确说批量,或同批接入两个及以上节点)时,主协调者必须默认启动一个子助手,无需再次询问是否使用助手:核查验收助手负责候选服务器、端口、中转与已有资源冲突核查,并独立审查脚本、测试并发/查重/恢复及执行接入验收。主协调者负责统一参数、展示编号、端口、唯一账本及生产写入。完整分工、执行关卡与消耗控制见 [批量接入协作流程](docs/maintenance/NODE_BATCH.md#多智能体协作流程)。 + +这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。 + 本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。 diff --git a/docs/maintenance/DEPLOYMENT.md b/docs/maintenance/DEPLOYMENT.md index 98ac08a..ae095ec 100644 --- a/docs/maintenance/DEPLOYMENT.md +++ b/docs/maintenance/DEPLOYMENT.md @@ -2,11 +2,11 @@ ## 目标与连接 -本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;具体连接资料参见本机 /Users/hellcat/Documents/CoDocs/项目关系.md 与 vps连接资料.md,不把它们的内容提交到公开仓库。 +本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;项目与连接入口参见 [dpl 档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md) 和 [私有资源索引](/Users/hellcat/Documents/CoDocs/30-私有资源/README.md),不把它们的内容提交到公开仓库。 本地 http://dpl.loc/dove 已配置到当前 Laravel public/。本机 Nginx 配置 /usr/local/etc/nginx/servers/dpl.loc.conf,PHP-FPM 使用现有 9000 端口。系统级服务重载可能需要 Mac 管理员授权;不持久保存系统密码。 -本地开发与联调访问规则(用户于 2026-10-11 指定):本地不访问 tp-link.net,需要访问远端站点时使用 https://dpl.tomshell.com.de 代替;本地站点仍使用 http://dpl.loc。该访问约定不自动修改生产支付回调域名或生产配置。 +本地开发与联调访问规则:本地站点仍使用 `http://dpl.loc`;本地访问线上项目域名必须查询 [CoDocs 反代表](/Users/hellcat/Documents/CoDocs/20-基础设施/访问与反代.md) 并走明确登记的白色机反代,dpl 主站入口为 `https://dpl.tomshell.com.de`。缺映射/用途不符时询问用户,不直接或变相访问源站。该访问约定不自动修改生产支付回调域名或生产配置。 ## Git 发布顺序 diff --git a/docs/maintenance/NODE_BATCH.md b/docs/maintenance/NODE_BATCH.md new file mode 100644 index 0000000..c33e477 --- /dev/null +++ b/docs/maintenance/NODE_BATCH.md @@ -0,0 +1,149 @@ +# 节点批量接入 + +本专题说明批量调度;单节点操作、append 的保护范围与验收仍以 [执行手册](NODE_RUNBOOK.md) 为准,展示编号以 [命名规则](NODE_ONBOARDING.md#节点命名与编号) 为准。 + +2026-10-16:新增标准库 Python 调度器、命令适配器和离线进程测试。没有部署、连接服务器或增加生产节点。调度器可以运行经过审阅的任务适配器;**没有内置 Cloudflare/中转后台 API,也没有替使用者实现各站点的 SSH 登录与现场检查。**生产任务必须先按下述协议配置命令,不能将离线测试通过视为生产全链路可用。 + +## 文件与运行方式 + +| 文件 | 用途 | +| --- | --- | +| `scripts/node-onboarding/batch.py` | 参数校验、资源预留、并发、进程间锁、逐阶段账本与恢复 | +| `scripts/node-onboarding/command-adapter.py` | 调用经过摘要固定的任务命令;不经过 shell,缺命令返回等待 | +| `tests/node-onboarding/test_batch.py` | 临时目录模拟远端,验证并发、重复运行和回包丢失;不加载 Laravel/.env | + +在仓库根目录运行,计划文件与状态目录用绝对路径,放在受保护任务目录。计划只含非秘密参数和引用;不要加入密码、令牌、私钥内容、含订阅令牌的完整 URL 或命令行凭据。 + +```sh +python3 scripts/node-onboarding/batch.py plan --plan /private/task/batch.json --state-root /private/task/ledger +python3 scripts/node-onboarding/batch.py run --plan /private/task/batch.json --state-root /private/task/ledger --jobs 4 --timeout 900 --execute +python3 scripts/node-onboarding/batch.py status --plan /private/task/batch.json --state-root /private/task/ledger +``` + +`plan` 仅做本地参数校验,不预留资源、不访问网络、不生成状态目录。`status` 只读账本,不触发命令或创建账本。`run --execute` 才可能执行写入。退出码 0 表示本次要求完成;2 表示校验失败、等待、冲突、未知或未完成,不应自动当成临时网络错误无限重试。默认并发 4,上限 16;timeout 是每次适配器调用的超时秒数,不是整个批次的时限。 + +## 参数格式 + +以下使用保留示例地址,不能直接运行生产任务。`provision_commit` 填已审阅、已推送的完整 Git 提交,bundle 与客户端二进制路径必须由前检核验。VPS ID 与实际地址从 CoDocs 对应资源读取,不能从展示编号推断。所有节点放在同一个 `nodes` 数组,SOCKS 端口各不相同。 + +```json +{ + "version": 1, + "mode": "append", + "batch_id": "jp-batch-20261016", + "context": { + "panel_vps": "VPS-001", + "gateway_vps": "VPS-002", + "jump_vps": "VPS-003", + "app_root": "/absolute/panel/app", + "panel_url": "https://panel.example.invalid", + "subscription_url": "https://subscribe.example.invalid", + "provision_commit": "0000000000000000000000000000000000000000", + "bundle": "/private/task/reviewed.bundle", + "client_binary": "/absolute/gateway/sing-box" + }, + "adapter": ["/absolute/python3", "/absolute/repo/scripts/node-onboarding/command-adapter.py"], + "command_profile": { + "files": {"/private/task/handler.py": "填写该文件的SHA256"}, + "phases": { + "preflight": { + "probe": ["/absolute/python3", "/private/task/handler.py", "--plan", "{plan}", "--node", "{node}", "--phase", "{phase}", "--action", "{action}", "--state-dir", "{state_dir}"] + } + } + }, + "nodes": [{ + "key": "jp-new-a", + "country": "JP", "sequence": 2, "flag": "🇯🇵", "country_name": "日本", + "name": "🇯🇵 日本 JP - 02", + "vps_id": "VPS-011", "node_address": "192.0.2.11", "listen_port": 24443, + "relay_host": "relay.example.invalid", "relay_port": 47011, + "tls_domain": "jp-new-a.example.invalid", "formal_group_id": 1, + "expected_exit": "192.0.2.11", "socks_port": 19011 + }] +} +``` + +示例只声明 preflight 命令,其他阶段必然等待。实际任务可为八个阶段逐一登记 `probe`,并在 stage/dns/provision/relay/publish 登记有权限执行的 `apply`。缺少 API 的阶段可以只提供读取实际状态的 probe,由操作者完成外部操作后续跑。不要使用“手工勾选成功”或静态回执替代现场 probe。 + +`files` 必须覆盖本地适配器、handler 及其加载的操作配置/脚本;不要只固定 Python 解释器。任一摘要变化停止,不接受在原批次中改命令后直接续跑。远端代码、安装包、二进制也由前检按明确 Git 提交/摘要核验。通用调度器不会自动检查命令是否遵守这些语义,也不会替命令构造 SSH 配置。`adapter` 亦可指向符合协议的专用程序,不必使用命令适配器。 + +## 调度与操作协议 + +所有候选节点先通过 preflight,才能开始任何 stage。每个节点依次执行以下流程;独立节点同时推进,共享写入由锁串行执行。 + +| 阶段 | 并发边界 | 任务命令必须核验的事实 | +| --- | --- | --- | +| preflight | 按 VPS 并发,全批屏障 | 当前代码/工具/系统依赖;VPS 与地址;空闲端口;现有机器/节点/组/名称/域名/中转冲突;目标正式组;保留资源基线 | +| stage | 面板串行 | 参数与 manifest 完整匹配;隔离 QA 和新节点确属本任务;创建后回读对象及私有传输文件 | +| dns | 全局串行 | 本次域名实际 DNS 指向;有权限的 API 才能 apply;无 API 则等待 | +| provision | 按 VPS 并发 | 已推送提交、安装包摘要、独立服务/配置归属、证书/SNI、旧服务保持;不能仅按文件存在判定完成 | +| relay | 全局串行 | 独立规则的入口和目标完全匹配;网关侧严格 TLS/SNI 连通;不得占用旧规则 | +| verify | 按 VPS 并发 | 从隔离网关获取真实完整 QA 订阅,用 `verify-node.py --run` 验证预期出口/国家并核对实际流量上报;报告绑定本请求 | +| publish | 面板串行 | `promote.php --mode=append` 先预览后应用;只开放本次新节点到指定已有组并禁用 QA;不能重写套餐、用户组或订阅地址 | +| audit | 按 VPS 并发 | 正式多节点订阅完整、旧节点/网关/套餐/用户关联/订阅地址符合基线、QA 禁用且订阅拒绝/不下发;新节点仍可用 | + +preflight/verify/audit 仅调用 `probe`,没有 apply。verify 的 probe 会产生隔离测试流量,可以启动/停止本次临时客户端;不能修改正式配置。已经完成 audit 的节点,重跑只做 preflight 与完整 audit,避免重新激活 QA。尚未完成的节点会从第一阶段重新逐项 probe,确认后才越过。 + +适配器接受 `--plan FILE --node FILE --phase PHASE --action probe|apply --state-dir DIR`。node 文件包含 `request_sha256`,它绑定 context 与完整节点参数。标准输出必须只含一条 JSON,字段如下: + +```json +{"key":"jp-new-a","phase":"stage","request_sha256":"从node文件原样读取","status":"absent","retry_safe":true} +``` + +| status | 含义与调度行为 | +| --- | --- | +| ready | 当前现场满足本阶段所有要求,可以继续;不能只依据上次成功日志 | +| absent | 已权威确认本阶段尚未执行;只有同时明确 `retry_safe: true` 才能调用 apply | +| waiting | 外部依赖未就绪或需人工处理,本节点停下;不占工作线程反复轮询 | +| unknown | 现场无法确认,不写入,不自动重试 apply | +| conflict | 资源占用、参数漂移或归属冲突;阻止该节点,通知其余任务停止继续写入 | + +apply 返回成功后仍必须 probe 确认。退出码非零、输出损坏、身份不符、超时或 Ctrl-C 后,远端结果一律按未知处理;重跑仍从现场读取。apply 命令必须支持安全处理部分完成的现场;不能把“有进程/有目录但未完成”随意返回 absent。stage 的现有对象没有匹配 manifest、或事务结果与 manifest 不一致时应 conflict/unknown,人工核实,不能自动创建第二套对象。 + +命令适配器的占位符仅支持 plan/node/state_dir/key/phase/action;每个元素作为独立 argv 传递,不解释 shell。操作脚本需要参数时自行读取受保护的 JSON。禁止在 argv/标准输出加入凭据;密码与令牌由 handler 按 CoDocs 私有引用按需读取,SSH 保持经白色机跳转;本地 HTTP 继续使用明确登记的反代。不要为了批量化改变既有访问规则。 + +## 查重、锁与断点恢复 + +计划内拒绝重复的部署 key、展示名、国家编号、VPS ID、实际落地 IP、TLS 域名、中转入口域名+端口与网关 SOCKS 端口。展示名格式与旗帜/国家/两位编号必须匹配。**这些是本地候选查重,已在线资源的查重必须由 preflight/stage 实际查询。**同一物理地址使用不同 VPS 别名也会被拦下。 + +状态目录是唯一协调入口:SQLite 事务原子预留身份,同目录 fcntl 锁协调进程。VPS/IP/TLS 与中转入口预留跨面板;名称/编号/key 按面板应用划分;SOCKS 按网关划分。相同节点参数可重跑;不同参数不能借用同一批次或占用已有预留。预留不自动释放,不提供强制接管命令。 + +所有参与聊天/进程必须使用**同一台协调机、同一个状态目录**。另建账本、复制状态目录、在另一台主机启动独立协调器都不能获得跨机锁保证。对共享面板的非批量手工操作也必须协调暂停,不能指望本地锁约束后台人工修改。每次写入前的远端归属/参数检查仍必要。 + +中断后保留原计划、账本、远端 manifest 和操作日志。先用 status 查看,再以同一计划、同一状态目录运行相同 run 命令;不要删除账本、改 batch_id 或清除 manifest 来“解除报错”。冲突需先核对原始请求与现场,不能通过修改参数静默接管。未知结果宁可停住;恢复不保证所有断点都能无人干预推进。 + +SIGINT/SIGTERM 会停止派发后续写入,终止当前本地适配器进程组;**这不保证已发出的远端命令停止,也不自动回滚**。其他已开始的节点可能已完成步骤。已完成节点保留成果;未完成节点留在其最后安全阶段。需要回退时按执行手册逐字段预览与回退,不做全批删除。 + +## 多智能体协作流程 + +用户于 2026-10-16 明确授权:以后执行批量节点接入,默认启动一个子助手,按本节分工执行,不再重复询问是否需要助手。触发入口见项目根 [AGENTS.md](../../AGENTS.md)。这里的“启动”由主协调者使用子助手工具完成;`batch.py` 本身不调用模型或创建助手。明确要求批量,或同批接入两个及以上节点时触发;仅讨论、修改脚本/文档、单节点接入不自动触发。用户当次明确调整角色或数量时,以最新要求为准。 + +| 角色 | 固定职责 | 写入与协作边界 | +| --- | --- | --- | +| 主协调者 | 明确本批范围,统一参数、展示编号与端口,固定计划,持有唯一账本,安排生产写入并汇总结果 | 只有主协调者启动本批正式调度和共享资源写入;不以助手已派发代替工作完成 | +| 核查验收助手 | 接入前核查候选服务器身份、既有节点/机器、端口、中转、DNS 和归属冲突;独立审查脚本与参数、测试并发/查重/恢复;开放前后核对 QA、正式订阅和旧资源基线 | 分阶段完成两类职责,返回每节点结论、证据及缺口;不自行分配最终编号、创建节点、改后台或另起调度。真实联网验收的对象、端口和时机由主协调者统一安排 | + +执行顺序: + +1. 主协调者在生产写入前启动/复用一个核查验收助手,给出当前任务范围、必要资料入口、受保护资源、分阶段输出和完成标准。当前批次已存在对应子助手时继续使用,不在每个节点或每次续跑时重新创建。 +2. 助手分阶段核查资源与审查方案;主协调者并行准备参数与调度。助手完成冲突核查和相关隔离测试/审查后,主协调者解决影响写入的缺口,固定同一参数单,再运行全批 preflight。检查发现变更时重新核对受影响项,不把旧回执沿用到不同参数。 +3. 独立节点由脚本按 `--jobs` 并发,面板/DNS/中转按共享锁执行。正式开放前,核查验收助手核对本节点隔离 QA、严格 TLS、实际出口与上报证据;主协调者确认后执行 publish。助手不得另起同批调度、重复 stage、共用浏览器会话切换账户或绕开账本。 +4. 正式开放后,核查验收助手检查正式订阅、QA 禁用/拒绝、旧资源基线并报告通过或具体缺口。主协调者核实后逐节点报告完成、等待或需用户操作;批次全部完成必须有对应实际验收,不能以子助手结束作为完成证明。 +5. 子助手不可用、并发名额不足或失败时,主协调者明确报告,复用/恢复角色或等待名额;不自动追加更多助手、跳过必需角色或把未知写成通过。暂停和停止指令必须传达给子助手,并停止派发后续写入;已发出的远端操作仍按断点恢复规则回读。 + +主协调者与一个子助手分别消耗处理其输入和输出所需的 token,总量不是固定倍数。主协调者默认仅传必要背景、资料路径、节点参数与任务边界,避免复制整段历史;每项检查明确归属,汇报用结论、证据位置与缺口,不反复粘贴完整日志。独立验收保留必要的交叉核对,不为节省 token 省略验收。没有固定 token 配额或价格承诺。 + +该常驻授权仅允许启动当前任务的子助手;不自动新建/分叉侧边栏普通聊天,向已有聊天派消息仍需用户明确授权。节点并发数由脚本控制,助手数保持一个;不会为十个节点启动十个助手。项目规则不扩大实际生产变更、凭据、访问路线或后台自动化授权。 + +## 本地验证 + +本地测试: + +```sh +python3 -m unittest discover -s tests/node-onboarding -p 'test_batch.py' -v +``` + +本次 11 项测试通过,覆盖:plan/status 无副作用,计划内重复与命名约束,全批前检屏障,独立节点重叠执行/面板串行,完整重跑不重复写,模拟写成后回包丢失,SIGTERM 中断后续跑,跨批预留/参数漂移,两进程竞争、错误身份回执、命令摘要变更及配置完整的适配器流程。测试没有验证生产命令的正确性、DNS/中转 API、真实 SSH/网络、现场负载或十节点耗时。生产批次的候选参数、命令 profile 与现场验收须在具体获授权的接入任务中另行核实。 + + +2026-10-17追加面板隔离测试:`php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php`,9 tests / 65 assertions通过。真实Eloquent与SQLite内存库,无.env/Redis/队列/网络;覆盖append保护范围、QA禁用、幂等、事务失败/通知失败恢复和冲突拒绝。NodeSync为测试替身,生产通知与用户下发仍需现场验收。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index b518f14..e63f117 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -1,5 +1,7 @@ # 节点与订阅网关接入 +后续接入的参数与命令唯一入口:[节点接入执行手册](NODE_RUNBOOK.md)。首次初始化与追加节点分别执行,不自动改订阅地址、套餐或用户关联。 + 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 ## 2026-10-16 日本节点接入 @@ -23,11 +25,19 @@ 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 -流量:客户端 → haiwi.lshan.eu.org:47101 → node-us01 64.44.115.18:24443 → 目标网络。AnyTLS 使用 us01.deeplink-subscribe.com 的有效证书,订阅要求严格校验。普通 Cloudflare 橙云只用于订阅 HTTP,不用于原生 AnyTLS 流量。 +流量链路按项目现有接入记录分列(2026-10-11/16历史现场结果,本次未联网复验): + +| 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | +| --- | --- | --- | --- | --- | --- | +| us01 | node-us01 | haiwi.lshan.eu.org:47101 | 64.44.115.18:24443 | us01.deeplink-subscribe.com | 2026-10-11 首节点验收 | +| jp02 | node-jp02 | haiwi.lshan.eu.org:47202 | 193.111.31.52:24443 | jp02.deeplink-subscribe.com | 本文2026-10-16日本节点接入 | + +客户端 → 对应中转入口 → 对应落地 → 目标网络。两条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 | 资源 | 本次新增 | 必须保留的原有资源 | | --- | --- | --- | -| node-us01 | dpl-node,TCP 24443,机器/节点 ID 1 | XrayR,TCP 443 | +| node-us01 | dpl-node,TCP 24443,机器/节点 ID 1(2026-10-11) | XrayR,TCP 443 | +| node-jp02 | dpl-node,TCP 24443,机器/节点 ID 2(2026-10-16) | XrayR,TCP 443;原中转 46202 → 443 | | 中转 | dpl-us01-anytls,47101 → 24443 | us01,46101 → 443;其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | @@ -90,70 +100,20 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立 ## 新节点参数单 -| 参数 | 首次实例 | 后续填写规则 | -| --- | --- | --- | -| key | us01 | 唯一、稳定的部署标识;不用于推导展示序号 | -| name | 🇺🇸 美国 US - 01 | 按[节点命名与编号](#节点命名与编号)从生产清单分配,按国家从01递增,不沿用VPS序号 | -| server | node-us01 | 从 CoDocs 项目关系及连接资料确认 | -| listen-port | 24443 | 当场核对 TCP/UDP 和防火墙,不复用过期判断 | -| relay-host / relay-port | haiwi.lshan.eu.org / 47101 | 新增规则,不改原规则 | -| tls-domain | us01.deeplink-subscribe.com | DNS only,有效证书 | -| group / plans | 首次隔离组;套餐 1、6、7、8 | 当前真实 ID 和授权集合 | -| backup | /root/codex-backups/dpl/node-onboarding-20261011-us01 | 每节点独立时间/key 目录 | +参数、冲突检查与模式选择统一见[执行手册](NODE_RUNBOOK.md#统一参数单与冲突检查),不沿用首节点示例作为后续默认值。命名仍以本文前一节为准。 ## 重复执行步骤 -1. 读取根 AGENTS、维护入口和本文。经 CoDocs 白色机中转访问服务器;本地网页使用指定访问域名。记录原服务 PID/启动时间/端口、中转规则、Docker ID/启动时间、原订阅结果;审计资源、防火墙、所有证书钩子。受保护备份本次将修改的配置和 Git 差异,不输出私密内容。 -2. 非敏感脚本和模板先 Git 提交/推送,再更新服务器到明确提交,保留生产原有差异。无法直接拉取时可传输已推送提交的 Git bundle 后 fetch/fast-forward,不能用压缩包覆盖。scripts/node-onboarding 可 subtree split 为独立运维仓库;面板 .env 不送到节点。 -3. 在生产应用根目录按参数单运行 stage.php,创建机器、AnyTLS 节点、隔离组和 100MB/一天的临时用户。节点虽启用显示,正式用户没有隔离组权限。相同 manifest 重跑只报告已创建;同名资源存在却缺少 manifest 时停止,不自动接管。 +按[统一接入顺序](NODE_RUNBOOK.md#统一接入顺序)执行;先区分首次 initialize 与已有组 append。新本地promote.php必须显式mode;append只修改新节点及本次QA,默认预览、apply才写入。该版本尚未测试或发布,线上旧脚本不能直接按新语义调用。 -```sh -php scripts/node-onboarding/stage.php \ - --key=us01 --name='🇺🇸 美国 US - 01' \ - --relay-host=haiwi.lshan.eu.org --relay-port=47101 \ - --listen-port=24443 --tls-domain=us01.deeplink-subscribe.com \ - --backup=/root/codex-backups/dpl/node-onboarding-20261011-us01 -``` - -4. 将受保护备份的 node-config.yml、node-secret.env 经白机安装到节点 /etc/dpl-node/config.yml、secret.env,均 600;运维仓库 checkout 明确 Git 版本到 /opt/dpl-node/provision。首次安装使用下面命令。脚本检查参数、配置、端口、已有后端及 XrayR 状态,校验官方二进制并安装独立 unit,不自动启动。已有 dpl-node 时不能重跑覆盖;升级须单独复核。 - -```sh -bash /opt/dpl-node/provision/install-node.sh \ - --listen-port=24443 --tls-domain=us01.deeplink-subscribe.com -``` - -5. 配置 DNS only、有效证书并审计/安装仅匹配新域名的续期钩子,再启用 dpl-node。在中转后台只新增 dpl--anytls,核对保存后的入口/目标和诊断结果。本次未启用收发 Proxy Protocol,以后启用前须同时确认入口和后端支持。 -6. 后续 dpl 节点复用 c2 及当前网关,不再次更换 NS。另一个机场需要不同上游时才新增独立实例;不得改已有上游、运行原项目 setup/update/down 或复用旧卷。 -7. 在网关创建本次验收目录(700),以 600 保存 stage.php 生成的 QA 私密文件和实际订阅;准备官方客户端并校验官方发布摘要。首轮已校验的 Linux 1.14.3 客户端保留于 /opt/dpl-gateway/acceptance/,下述命令可复用它。拉取临时用户完整订阅到私密文件;sing-box 使用正常版本 UA(如 sing-box/1.14.3),不要使用覆盖版本识别的裸 flag=sing-box。核验名称、入口、SNI、严格 TLS、缓存头和真实 IP;先官方客户端 check,再完整配置实际联网,核对出口、DNS、上下行计费与上报。 - -```sh -python3 /opt/dpl-gateway/provision/verify-node.py \ - --qa /var/lib/dpl-sgw/qa/qa.json \ - --binary /opt/dpl-gateway/acceptance/sing-box-1.14.3-linux-amd64/sing-box \ - --subscription /var/lib/dpl-sgw/qa/subscription.json \ - --expected-exit 64.44.115.18 -``` - -helper 仅替换 TUN 等入站为临时本机 SOCKS,保留订阅 DNS、分流及出站,退出时停止客户端;失败日志留在同一私密目录。这不等于手机 GUI、系统 TUN、多地区、超额或多设备并发验收。某环境到入口不通时分段诊断并换环境核验,HTTP 200 不能替代联网。 - -8. 上述验收通过后,在生产根目录运行 readiness.php 和 promote.php,显式指定当前套餐集合。已有正式组时额外传 --group=。只关联所选尚无组的套餐及这些套餐现有无组用户,不改价格、周期、流量或设备限制。 - -```sh -php scripts/node-onboarding/readiness.php \ - --backup=/root/codex-backups/dpl/node-onboarding-20261011-us01 -php scripts/node-onboarding/promote.php \ - --backup=/root/codex-backups/dpl/node-onboarding-20261011-us01 \ - --plans=1,6,7,8 -``` - -首次隔离组改为基础节点、订阅域名设置为 c2,以后复用正式组。脚本要求节点在线且临时用户已有流量,保存 before-promotion.json,最后禁用临时用户并写 promoted.json。清理本次客户端和私密传输副本,保留服务器备份、禁用用户与审计记录。 - -9. 回归原机场服务/规则/订阅,实际核对正式组权限、面板节点及订阅域名保存,确认测试用户禁用且不再下发。更新本文及 STATUS,分别记录提交、发布、真实验收及未完成项。 +依赖检查在创建面板资源前完成;证书演练、完整订阅验收、精确回退与现有脚本限制均在执行手册维护,不在多处复制命令。本地新增面板正式开放/精确回退与多节点验证入口,均尚未测试;细节与奶油接手测试清单见执行手册。远程服务/DNS/中转仍按专项恢复。 数据库默认 sing-box 模板已迁移新 DNS/路由格式,保留原分流和选择逻辑;现代本地 DoH 使用默认 dialer,不绕到空 direct 出站。仓库原默认资源和其他机场模板不改。apply-template.php 仅接受原默认模板,或与显式 expected-current-sha256 完全匹配的旧版本,逐次备份后只更新该模板/缓存。用户定制或未知模板须先审查,不能强行覆盖。 ## 回退与排障 +节点变更先按[精确回退入口与证据契约](NODE_RUNBOOK.md#精确回退入口与证据契约)确定本次对象及 before/after。下列专项恢复说明继续适用;历史 before-promotion 不保证包含所有 after 值,不能盲目恢复。 + - 单节点:停用本次节点/新增中转,停止新 dpl-node;保留 XrayR、原规则和审计数据。 - 正式关联:依据 before-promotion.json 精确恢复本次套餐/用户组及订阅域名,先确认当前值仍是本次写入值,避免覆盖后续业务。不恢复整库。 - 模板:从对应私密模板备份恢复该条内容并清理模板缓存,不替换整个设置表。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md new file mode 100644 index 0000000..b70fc5b --- /dev/null +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -0,0 +1,254 @@ +# DPL 节点接入执行手册 + +入口:[节点关系与历史验收](NODE_ONBOARDING.md)。本手册定义后续授权任务的步骤。2026-10-16 已补齐本地脚本草稿;用户要求测试交给奶油,本轮未运行脚本、语法检查或功能测试,未触及线上。**新脚本必须先在隔离环境测试,再按Git流程审阅发布;本地存在不代表线上已更新。**已有节点展示命名按原入口的[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号),此处不另建规则。 + +## 先选择变更模式 + +| 模式 | 适用情形 | 允许修改的范围 | +| --- | --- | --- | +| initialize | 首次建立 DPL 网关、基础节点组及明确授权的套餐可见范围 | 本次参数单与授权清单逐项列明;订阅地址、套餐/用户关联必须分别确认 | +| append | 已有网关/正式节点组上增加节点 | 新机器、新节点、新独立中转、节点 DNS/证书和 QA 隔离资源;验收后只把新节点加入指定已有组并禁用 QA | + +默认判断依据是现有资源与目标,不把“新节点”当作首次初始化。append 保持现有订阅地址、网关、套餐全部字段、正式用户分组及旧节点不变。扩展套餐覆盖另列变更任务。未知已有组/节点状态时先解决该依赖,不能通过重跑初始化猜测。 + +## 统一参数单与冲突检查 + +在本次服务器受保护任务目录保存参数,目录 700、文件 600;非秘密参数也不进公开日志。以下是待填写模板,没有可直接用于新节点的默认值。文件只使用赋值,不含执行命令;人工审阅后才加载。 + +```sh +MODE='' # initialize 或 append +KEY='' # 稳定部署 key +NODE_NAME='' # 按既有命名规则确定;与 VPS 代号独立 +VPS_ID='' # CoDocs 稳定 ID +NODE_ADDRESS='' # 实际落地地址,按私有资源读取 +LISTEN_PORT='' +RELAY_HOST='' +RELAY_PORT='' +TLS_DOMAIN='' +FORMAL_GROUP_ID='' # append 必须填已有正式组 +PLAN_IDS='' # 仅 initialize 或另有明确套餐变更授权时填 +BACKUP='' # 绝对路径,每次任务独立,不复用其他节点目录 +QA_DIR='' # 网关本次独立验收目录 +CLIENT_BINARY='' # 已校验摘要的官方客户端绝对路径 +EXPECTED_EXIT='' +EXPECTED_COUNTRY='' +LOCAL_SOCKS_PORT='' # 网关上本次隔离客户端空闲端口 +APP_ROOT='' # 面板应用路径,从项目档案核实 +PROVISION_ROOT='' # 面板上本次已确认脚本路径 +NODE_PROVISION='' # 节点上已确认的运维 Git 路径 +GIT_COMMIT='' # 已推送且经过审阅的明确提交 +PANEL_URL='' # 节点回连面板的明确HTTPS地址 +SUBSCRIBE_URL='' # 仅initialize明确修改的订阅地址 +QA_REPORT='' # 本次隔离QA的私有实时验收报告 +FORMAL_EXPECTATIONS='' # 正式多节点预期的私有JSON文件 +FORMAL_REPORT='' # 正式多节点验证报告 +``` + +填写后逐项核对:key/展示名、机器名、面板 ID、VPS、落地监听端口、中转入口的域名+端口、源站目标、SNI、正式组、DNS 现值、备份路径和 QA 端口。相同 key 有 manifest 时,只回读已有对象并确认参数完全一致;新 stage.php 重跑要求 manifest 记录的完整参数一致,并核对节点、机器和中转端点;旧 manifest 没有 parameters 时停止,不能自动采纳。相同资源无 manifest、冲突端口或未授权的覆盖均停止,不自动接管。 + +机密字段单独沿用 stage.php 生成的私密文件;不把机器令牌、QA UUID、订阅令牌写进上述参数单、文档或命令行。参数按角色分发,不把面板 .env 复制到节点。 + +可在审阅参数文件后运行以下本地字段检查;它不验证线上冲突或资源归属: + +```sh +set -eu +: "${MODE:?}" "${KEY:?}" "${NODE_NAME:?}" "${VPS_ID:?}" "${NODE_ADDRESS:?}" +: "${LISTEN_PORT:?}" "${RELAY_HOST:?}" "${RELAY_PORT:?}" "${TLS_DOMAIN:?}" +: "${BACKUP:?}" "${QA_DIR:?}" "${CLIENT_BINARY:?}" "${EXPECTED_EXIT:?}" "${EXPECTED_COUNTRY:?}" +: "${LOCAL_SOCKS_PORT:?}" "${APP_ROOT:?}" "${PROVISION_ROOT:?}" "${NODE_PROVISION:?}" "${GIT_COMMIT:?}" +: "${PANEL_URL:?}" "${QA_REPORT:?}" +case "$MODE" in + append) : "${FORMAL_GROUP_ID:?}"; test -z "$PLAN_IDS" ;; + initialize) : "${PLAN_IDS:?}" "${SUBSCRIBE_URL:?}" ;; + *) exit 1 ;; +esac +``` + +## 创建面板记录之前完成前置检查 + +只在具体接入任务授权后,通过 CoDocs 白色机跳板及登记的后台反代执行以下检查。本次文档维护没有执行这些动作。 + +| 位置 | 必须确认 | 未通过时 | +| --- | --- | --- | +| 面板 | Git 提交、当前组和节点清单、现有订阅设置、套餐/用户分组快照、应用运行环境、脚本实际版本 | 先补证据与备份,不创建机器/节点/QA 记录 | +| 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 | +| 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 | +| 证书 | 已有 renewal 配置、pre/post/deploy hooks、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | +| 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 | +| 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 | +| 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 | + +历史 jp02 接入曾因 Debian11 源包 404 临时使用 Debian 官方 HTTPS 归档安装 Git;这是该次处理记录,不是以后默认改源或安装命令。先核对目标当下发行版与来源,不在本手册固化过期包 URL。 + +前置完成后,保存本次允许改变的对象、每个对象的 before 值、预期 after 值和精确回退依据。不要打印整库快照或秘密。 + +## 本地脚本能力与边界(尚未测试/发布) + +| 工具 | 新行为 | 边界 | +| --- | --- | --- | +| stage.php | 明确panel-url、完整参数/对象重跑核对、端点冲突检查;受保护manifest可恢复传输文件 | 旧manifest不自动采纳;依赖与实际资源前检仍由接入任务完成 | +| promote.php | 强制mode;默认只预览;append只写新节点group_ids及QA banned;initialize显式plans/subscribe-url/可选group-name | 真正写入需apply及15分钟内匹配该QA、端点与SNI的实时验证报告;不根据节点名推导身份 | +| readiness.php | 只统计显式plans,缺省不统计套餐;可检查给定group的冲突套餐 | 只读快照,不代替真实订阅/计费/界面验收 | +| verify-node.py | 默认离线结构核对;显式run才启动客户端/联网;可按预期逐一验证多节点、SNI、密码、出口IP及国家 | 正式多节点需要完整预期清单;只证明实际执行的探针,不代替业务/手机/TUN/全路由验收 | +| rollback.php | 默认预览;基于新promotion-journal逐字段比较after再恢复before;保持QA禁用,隔离本次节点 | 冲突整次停止,不覆盖后续变化;只覆盖面板变更,不停止远程服务或修改DNS/中转;旧before-promotion需专项人工核对 | +| operations.php | 私有文件锁、原子写日志、事务内记录写前意图、提交后完成标记;中断后按before/after识别已应用或冲突 | 文件与数据库不是同一个事务;状态未知需依据日志恢复,不盲目重新stage | +| install-node.sh / renew-node.sh | 沿用已登记的独立安装与专用续期hook | 本次未更改,不代表前置工具/安装源/实际timer已经验证 | + +新脚本不修改节点展示名、不分配编号。操作日志不包含密码/UUID/机器令牌;脚本不在错误输出中打印数据库异常全文或客户端配置。首次运行前必须由奶油进行隔离测试,特别核对事务、缓存/节点通知、重复执行和崩溃恢复。 + +## 统一接入顺序 + +1. 完成参数单、模式确认、前检和恢复点;非秘密运维代码先提交/推送,再更新目标至明确 Git 提交。已有线上差异保留。拉取受限时只能传输已推送提交的 Git bundle 后 fetch/fast-forward,不用归档覆盖。 +2. 在确认的面板应用根目录,用下列统一参数创建隔离资源;创建后把实际 node_id、machine_id、group_id、test_user_id 与 key/VPS 对应写回本次任务 manifest,不能从服务器名推导面板 ID。 + +```sh +cd "$APP_ROOT" +php "$PROVISION_ROOT/stage.php" \ + --key="$KEY" --name="$NODE_NAME" \ + --relay-host="$RELAY_HOST" --relay-port="$RELAY_PORT" \ + --listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN" \ + --panel-url="$PANEL_URL" --backup="$BACKUP" +``` + +3. 按既有受保护传输方式安装 node-config.yml 和 node-secret.env 至节点的独立目录,文件 600、父目录 700;实际运维 Git 路径使用 NODE_PROVISION,保留二进制固定版本/摘要核验。节点未安装本服务时才运行: + +```sh +bash "$NODE_PROVISION/install-node.sh" \ + --listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN" +``` + +4. 配置本次 DNS only、证书和隔离续期策略,再启动新服务;中转只新增本次规则,保存后回读入口、目标及诊断结果。Proxy Protocol 默认保持本次参数单记录,不能单端打开。已有网关按 append 复用,不重建网关、NS、旧卷或订阅配置。 +5. 按下节执行隔离验收;不因 service active、内核磁盘无 JSON 或 HTTP 200 单独判断成功/故障。Xboard-Node 可使用内存配置,须按实际版本、启动日志、证书和请求链路判断。 +6. 验收通过后按下方“面板变更命令”预览,再对同一组参数明确apply。append只改新节点与QA;initialize才允许修改明确列出的套餐/用户分组及订阅地址。保存前后日志并回读。未通过隔离测试的新版本不能直接带到生产执行。 +7. 回读正式订阅与原资源,检查 QA 禁用、订阅拒绝、下发集合无 QA,确认旧节点、旧中转、套餐/用户和订阅设置保持预期。同步 CoDocs 对象关系、节点登记及 STATUS;提交/推送/部署/现场验收分别报告。 + +## 证书钩子与演练 + +新证书只挂本项目专用 hook。历史已验证的调用方式为 `/bin/sh /opt/dpl-node/provision/renew-node.sh`;不能假定脚本有执行权限后直接执行。路径实际不同时按当前运维检出路径填写。renew-node.sh 依据 `/etc/dpl-node/certificate-domain` 和 RENEWED_LINEAGE 精确匹配,只操作 dpl-node。 + +目标实际 Certbot 命令路径记为 CERTBOT_BIN;先审计目录钩子、renewal 配置及 Snap/apt timer 的调用方式。经确认的演练入口: + +```sh +: "${CERTBOT_BIN:?}" +"$CERTBOT_BIN" renew --cert-name "$TLS_DOMAIN" --dry-run \ + --no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks +``` + +`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。 + +续期完成条件包括:证书配置正确、调度 enabled/下一次触发可见、调度参数确实覆盖新证书及专用 hook、dry-run 退出状态和旧服务保护证据齐全。证书到期时间引用证书/域名对应记录,不把一次演练日期当作长期验收。 + +## 统一验收入口 + +每次在 QA_DIR 保存 700/600 的 QA 私密文件与完整实际订阅;文件名统一为 qa.json、subscription.json、验收结果和受保护日志,参数与 stage manifest 对照,不使用首节点默认路径。本地浏览器、服务器请求均按各自授权路线执行。 + +在已授权的网关环境,先用本次参数里的官方客户端检查完整配置,再运行完整隔离订阅: + +```sh +"$CLIENT_BINARY" check -c "$QA_DIR/subscription.json" +python3 /opt/dpl-gateway/provision/verify-node.py \ + --qa "$QA_DIR/qa.json" --subscription "$QA_DIR/subscription.json" \ + --binary "$CLIENT_BINARY" --port "$LOCAL_SOCKS_PORT" \ + --expected-exit "$EXPECTED_EXIT" --expected-country "$EXPECTED_COUNTRY" \ + --report "$QA_REPORT" --run +``` + +上面脚本路径是既有网关检出约定,执行前核对实际 Git 版本与路径,若不同则使用本次确认路径,不复制临时代码替代。QA可按host/port唯一匹配本次节点,冲突时显式target-tag;正式多节点使用下方expectations。验证保留DNS/分流及全部出站,替换临时SOCKS入站,并仅为api.ipify.org与www.cloudflare.com两个诊断目的地添加指定出站规则,从而逐节点验证;不是原订阅所有路由均已验收。 + +| 验收层 | 必须保留的结果 | +| --- | --- | +| 参数一致 | 订阅包含本次入口/端口/SNI、严格 TLS 开启;机器/节点与 VPS 对应正确 | +| 协议/联网 | 官方客户端配置检查与完整启动结果、严格 TLS、目标 HTTPS、出口 IP 与 EXPECTED_EXIT 一致;脚本强制比对 EXPECTED_COUNTRY;仅结构核对不产生live_verified报告 | +| 订阅 | 有效用户标准/短路径按本次范围检查,JSON/gzip、private/no-store、无 HIT/Age/挑战;不要在日志保存令牌 | +| 上报 | readiness.php回读节点在线、last_check/last_push及QA上下行;按显式plans/group统计,缺省不选任何套餐 | +| 正式开放 | 正式有效用户订阅与完整expectations逐一对应,并逐节点验证;不把单节点QA结果扩大成全部可用 | +| QA 清理 | QA banned、其订阅拒绝、实际下发用户集合无 QA;客户端退出、临时本地秘密副本清理,服务器审计文件保留 | +| 回归 | 原节点/中转、套餐字段/用户组、订阅 URL、网关容器 ID/启动时间/重启计数与 before 快照对照 | +| 浏览器 | 正确反代入口,管理列表与保存结果回读;只查看后取消不算提交验收 | + +面板回读命令入口如下;它只覆盖该脚本实际返回的节点/QA 字段,其他行须另留证据: + +```sh +cd "$APP_ROOT" +php "$PROVISION_ROOT/readiness.php" --backup="$BACKUP" --group="$FORMAL_GROUP_ID" +``` + +GUI/手机、TUN、多地区、并发及用户明确跳过的测试分别标为未执行或跳过。未确认结果不补成通过;统一表格不替代真实执行。 + +### 正式多节点验证 + +在私有文件 FORMAL_EXPECTATIONS 保存每个AnyTLS出站的明确预期,完整覆盖订阅中的全部AnyTLS标签。每条结构:`tag`、`host`、整数`port`、`sni`、`uuid`、`expected_exit`、`expected_country`;uuid是该正式用户的实际密码,不能复制进普通参数单或文档。标签必须与订阅精确一致;不重命名节点。 + +```sh +python3 /opt/dpl-gateway/provision/verify-node.py \ + --subscription "$QA_DIR/formal-subscription.json" \ + --expectations "$FORMAL_EXPECTATIONS" --binary "$CLIENT_BINARY" \ + --port "$LOCAL_SOCKS_PORT" --report "$FORMAL_REPORT" --run +``` + +不加run时只读取私有JSON并核对结构,不启动客户端、不发网络请求;run每次会先将旧报告失效,避免失败后误用旧成功结果。实时报告记录具体结果与时间,面板正式开放仅接受匹配manifest QA及端点、15分钟内的实时QA报告,正式多节点报告不能代替QA报告。 + +### 面板变更命令 + +以下命令默认只预览数据库字段变化;确认输出范围后,对同一命令追加 `--verification="$QA_REPORT" --apply` 才写入。append禁止传plans、subscribe-url和group-name;它不会修改现有套餐/正式用户/订阅地址。initialize对应已有套餐中的无组用户关联也属于实际写入范围,必须先看预览。 + +```sh +cd "$APP_ROOT" +php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=append \ + --group="$FORMAL_GROUP_ID" +``` + +首次初始化另用以下命令,按任务需要显式传group/group-name;没有这些参数时不自动改组名: + +```sh +cd "$APP_ROOT" +php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=initialize \ + --plans="$PLAN_IDS" --subscribe-url="$SUBSCRIBE_URL" +``` + +promotion-journal.json记录允许写入字段、before/after与manifest。相同日志/参数重跑会比较实际字段,已应用则只确认;参数变化、已启动回退、旧promoted.json无新日志或对象冲突时停止。prepared可能意味着数据库未提交、已提交但后续通知/缓存/落盘失败;只有完整before或完整after匹配才恢复,中间/未知状态先人工处理。不要删除日志重新执行。 + +## 精确回退入口与证据契约 + +触发失败先停止正式开放,隔离新节点。回退按本次 manifest 指定对象执行;不恢复整库、不删除原卷、不停止旧节点。执行前取回当前值,只有当前值仍等于本次 after 才恢复 before;存在后续变化时停止本次自动回退,不写任何字段;专项人工恢复另行核对。 + +| 对象 | 必需恢复证据 | 动作及回读 | +| --- | --- | --- | +| append 新节点正式组 | 该 node_id 的 before/after group_ids、enabled/show 及实际变动字段 | 新rollback.php按日志恢复本次字段并禁用/隐藏节点;不动已有套餐/用户/订阅地址 | +| initialize 正式关联 | 新promotion-journal.json、manifest.json;旧before-promotion/promoted仅用于历史专项回退 | 按 ID 逐条恢复本次变动的套餐/用户组、节点组、组名、订阅 URL;缺 after 不盲目回填,以实际变更证据核对 | +| QA | test_user_id 与实际禁用状态 | 始终保持本次测试账号禁用、订阅拒绝、不下发;不因回退恢复 QA 活跃 | +| 中转 | 新增规则的真实 ID、入口和目标 before/after | 只停用本次新增规则,再回读;原规则保持 | +| 节点服务 | 新服务/unit/配置及安装前状态、原服务 PID/启动时间 | 在已授权节点上执行下方停止入口,只停止新 dpl-node,回查旧服务;不删证书/审计目录 | +| DNS/证书续期 | 本次记录 ID/旧值/新值、renewal/hook/timer 原文件及权限 | 仅撤回本次增改;已共用证书/调度不能直接删除,恢复后核对下一次调度及原服务 | +| 网关/模板/CF | 对应专项恢复点 | append 不应修改;initialize 或专项变更按原[恢复专题](NODE_ONBOARDING.md#回退与排障)逐项执行 | + +节点上的停止入口(确认本服务确为本次新增后): + +```sh +systemctl stop dpl-node +systemctl is-active dpl-node || test "$?" -eq 3 +``` + +新日志的面板回退统一入口(默认预览,加apply才执行): + +```sh +cd "$APP_ROOT" +php "$PROVISION_ROOT/rollback.php" --backup="$BACKUP" +``` + +回退逐字段核对当前值仍等于promotion记录的after,任何冲突整体停止;写回本次before、保持QA禁用,并禁用/隐藏本次节点。rollback-journal.json保存回退本身的意图与完成状态,重复执行也需匹配完整结果。旧日志不能自动补造after或升级成新日志。DNS、中转、远程服务、证书、网关/CF属于其他对象,仍按对应专项步骤和恢复证据处理;该面板脚本不包含跨服务器/服务商变更。 + +## 本次交付与交接测试范围 + +2026-10-16,本地已编写mode隔离的正式开放、参数化回读、多节点验证及面板字段回退,并补stage参数与重跑保护。**按用户要求尚未测试,包括未运行php -l、Python结构/联网验证或任何应用命令;未提交/推送/部署。** 原有运行结果仍引用接入历史,不替代新代码验收。 + +奶油应在全新SQLite内存库/隔离应用容器中测试,禁止加载项目本地真实.env、默认数据库/Redis/队列或使用正式凭据: + +1. append仅改变本次node.group_ids、QA.banned,现有订阅设置/全部套餐/正式用户/旧节点逐字段不变;拒绝append夹带plans/url/group-name。 +2. initialize只修改明确套餐及其无组用户、指定订阅地址/可选组名;拒绝不存在ID、其他组冲突,验证原null订阅设置的创建及回退。 +3. 默认预览无数据库写入;apply要求近期真实QA报告且QA身份、端点/SNI正确;结构报告、过期/错误QA报告不能通过。 +4. 回读仅使用本次显式plans;无plans时不查询/统计隐含套餐集合。 +5. 应用/回退幂等;数据库提交前失败、提交后通知/缓存/日志失败、prepared+完整before/after与混合冲突,不能重复创建或覆盖后续变化;QA始终不复活。 +6. 多节点JSON用虚构身份;缺失/重复标签、端点/SNI/密码/严格TLS/国家/IP错误均失败。模拟子进程和传输,不发公网请求;默认无run绝不启动进程/开socket,失败后不能留下旧成功报告。 +7. stage相同完整参数恢复缺失传输文件;参数变化、旧manifest、现有同名/同中转端点资源或已promote时拒绝接管;检查所有秘密文件权限。 +8. 通过隔离测试之后才另行申请或接受具体线上发布/验收任务,本次授权不包含这些动作。节点命名仍由原维护任务处理,不因测试改名或分配编号。 diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index 3194dd2..b0fd3de 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -4,7 +4,7 @@ ## 接手顺序 -1. 阅读全局及项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。 +1. 按全局规则先读 CoDocs 项目总览、首次会话的持续维护协议与 dpl 档案,再读项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。 2. 确认所在仓库、分支、工作区差异与远端;分别识别用户既有修改、本次任务与其他聊天修改。不要覆盖、清理或混入其他任务。 3. 按 [架构与配置](ARCHITECTURE.md) 沿实际调用链检查问题;按 [业务约束](BUSINESS_RULES.md) 判断影响。 4. 修改后运行与影响范围相关的检查;后台界面必须浏览器验收,包括页面显示、保存、切换、权限。HTTP 200 不代表前端可用。 @@ -21,9 +21,13 @@ | TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 | | STATUS.md | 最近验证基线、已知限制与待验收项目 | | CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 | -| NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 | +| NODE_ONBOARDING.md | 两节点链路、命名规则、共享资源与历史验收 | +| NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;新本地代码未测试,含奶油测试清单 | +| NODE_BATCH.md | 批量接入默认一个子助手的分工与关卡;append并发锁/查重/断点恢复、命令协议与测试边界 | | GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 | 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 正式维护文档放在 docs/maintenance/,纳入 Git 并随相关代码一起更新。项目根目录 AGENTS.md 是 AI 自动接手入口;运行日志、截图、临时脚本与任务产物放在 codex/output/,继续默认忽略。受保护备份和私密配置按运行环境保存,不提交 Git。 + +本机共享 wwwroot 权限已于 2026-10-13 收紧;策略、服务身份、限制和恢复入口见 [CoDocs 本地目录权限](/Users/hellcat/Documents/CoDocs/20-基础设施/本地目录权限.md)。仅本机适用,未变更本项目业务或生产权限。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 2ff4fe7..504bfd0 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,28 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-17 三节点批次前检(执行中) + +用户授权追加3节点,暂定node-jp03/04/05,展示编号JP - 02/03/04;经用户批准的精确SSH指纹及白色机跳转登录,现场确认root、Debian11、x86_64、JP出口、旧XrayR active、无dpl-node、24443空闲。面板现场只有US - 01与JP - 01,未创建新节点。新增PanelChangeIsolatedTest.php:9 tests / 65 assertions通过;批量进程11 tests重跑通过。面板测试使用真实Eloquent/SQLite内存库与通知替身,不覆盖真实Redis/observer/联网;生产追加尚未完成。 + +## 2026-10-16 批量接入默认单子助手规则 + +按用户明确要求,在项目 AGENTS.md 登记批量接入触发规则,在 [批量专题](NODE_BATCH.md#多智能体协作流程) 维护唯一完整流程:主协调者统一参数/编号/端口/账本与写入;按用户为节省 token 的最新要求,只启动一个核查验收助手,分阶段核查候选及冲突、审查脚本、测试恢复并独立验收。规定复用、失败/暂停处理、写入及正式开放关卡、最小上下文与避免重复检查。此次仅更新文档,没有启动新接入任务或工作助手,没有生产变更;先前单个显示测试助手不算本批分工验收。规则本地落盘不代表自动执行或生产验证;尚未提交/推送。 + +## 2026-10-16 批量调度本地验证 + +新增 `batch.py`、`command-adapter.py` 与[批量专题](NODE_BATCH.md):append 模式,全批前检屏障,独立 VPS 并发,共享面板/DNS/中转锁,跨批资源预留,固定计划与逐阶段现场回读恢复。11 项独立进程离线测试通过,覆盖并发/串行边界、双进程去重、回包丢失与 SIGTERM 中断恢复、参数冲突、错误回执和命令摘要变更;没有加载 Laravel/.env 或访问网络。CoDocs 离线结构检查通过。该测试结果只覆盖新调度器与命令适配器,不覆盖下节其他聊天新写的 PHP 面板脚本及真实订阅验证器。 + +当前未提交/推送/部署,未新增线上节点。生产操作通过经过审阅的任务命令 profile 接入;没有内置 DNS/中转后台自动写入或 SSH 登录工具,缺少 handler 的阶段返回等待。真实生产命令、十节点耗时、远端现场及业务验收尚未验证,不能宣称已经支持现网一键接入。 + +## 2026-10-16 节点脚本本地草稿(未测试/未发布) + +已在本地补promote.php的append/initialize模式隔离和默认预览,新增operations.php字段日志及rollback.php面板精确回退;readiness.php按显式套餐回读,verify-node.py支持完整多节点预期及IP/国家断言、默认不联网,stage.php移除固定面板地址并核对完整重跑参数。使用与限制见[NODE_RUNBOOK](NODE_RUNBOOK.md)。用户要求测试交给奶油,本次未运行测试/语法检查或应用命令,未改线上,未提交/推送/部署,不能视为已验证可用版本。节点命名未改;本次代码不更改展示名或分配编号。 + +## 2026-10-16 节点文档核对(仅离线) + +核对两条既有节点链路并补 CoDocs 美国节点关联;新[执行手册](NODE_RUNBOOK.md)统一参数/前检/证书钩子与演练/验收及精确回退,首次初始化与追加模式分开。现有 promote.php 会写固定订阅地址并关联无组套餐/用户,不适合作为追加默认入口;readiness.php 套餐统计硬编码,verify-node.py 只接受单个 AnyTLS 出站,自动追加与统一回退工具仍缺失。文档已明确限制与现有管理界面操作入口,本次未改脚本或线上。以下运行结果均引用原任务证据,不是本轮复验;节点命名继续由原维护任务处理。 + ## 2026-10-16 日本节点 `node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 01`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。 diff --git a/scripts/node-onboarding/batch.py b/scripts/node-onboarding/batch.py new file mode 100644 index 0000000..baeb5de --- /dev/null +++ b/scripts/node-onboarding/batch.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""DPL append-only batch coordinator. Adapters must probe before and after writes.""" +import argparse +import concurrent.futures +import contextlib +import fcntl +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import signal +import sqlite3 +import subprocess +import sys +import threading +import time + +PHASES = ('stage', 'dns', 'provision', 'relay', 'verify', 'publish', 'audit') +SHARED = {'stage': 'panel', 'dns': 'dns', 'relay': 'relay', 'publish': 'panel'} +STOP = threading.Event() + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(',', ':')).encode()).hexdigest() + + +def read_json(path): + return json.loads(Path(path).read_text()) + + +def atomic_json(path, value): + path = Path(path) + tmp = path.with_name(path.name + '.tmp-' + str(os.getpid()) + '-' + str(threading.get_ident())) + with tmp.open('x') as f: + os.chmod(tmp, 0o600) + json.dump(value, f, ensure_ascii=False, sort_keys=True) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +def validate(plan): + if plan.get('version') != 1 or plan.get('mode') != 'append': + raise ValueError('Only version=1, mode=append is supported') + if not re.fullmatch(r'[a-z0-9-]{1,64}', plan.get('batch_id', '')): + raise ValueError('Invalid batch_id') + context = plan.get('context', {}) + required = ('panel_vps', 'gateway_vps', 'jump_vps', 'app_root', 'panel_url', + 'subscription_url', 'provision_commit', 'bundle', 'client_binary') + if any(not context.get(k) for k in required): + raise ValueError('Missing explicit context fields') + if not re.fullmatch(r'[0-9a-f]{40}', context['provision_commit']): + raise ValueError('A full reviewed, pushed Git commit is required') + for k in ('panel_url', 'subscription_url'): + if not re.fullmatch(r'https://[a-z0-9.-]+', context[k]): + raise ValueError('HTTPS origin without token/path required: ' + k) + adapter = plan.get('adapter') + if not isinstance(adapter, list) or not adapter or not all(isinstance(x, str) for x in adapter): + raise ValueError('adapter must be an argv array, never a shell string') + if not plan.get('nodes') or not isinstance(plan['nodes'], list): + raise ValueError('A nonempty node list is required') + seen = {k: set() for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'relay', 'socks_port', 'number')} + for n in plan['nodes']: + if any(k not in n for k in ('key', 'country', 'sequence', 'flag', 'country_name', 'name', + 'vps_id', 'node_address', 'listen_port', 'relay_host', + 'relay_port', 'tls_domain', 'formal_group_id', + 'expected_exit', 'socks_port')): + raise ValueError('Incomplete node specification') + if not re.fullmatch(r'[a-z0-9-]{1,48}', n['key']): + raise ValueError('Invalid deployment key') + if not re.fullmatch(r'[A-Z]{2}', n['country']): + raise ValueError('Country must be a two-letter code') + if type(n['sequence']) is not int or not 1 <= n['sequence'] <= 99: + raise ValueError('Display sequence must be 01..99') + expected = f"{n['flag']} {n['country_name']} {n['country']} - {n['sequence']:02d}" + flag = ''.join(chr(0x1F1E6 + ord(x) - ord('A')) for x in n['country']) + if n['name'] != expected or n['flag'] != flag: + raise ValueError('Display name/flag does not match country and sequence') + for k in ('listen_port', 'relay_port', 'socks_port'): + if type(n[k]) is not int or not 1024 <= n[k] <= 65535: + raise ValueError('Invalid port: ' + k) + if type(n['formal_group_id']) is not int or n['formal_group_id'] < 1: + raise ValueError('An existing formal group is required') + ipaddress.ip_address(n['node_address']) + ipaddress.ip_address(n['expected_exit']) + for k in ('relay_host', 'tls_domain'): + if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}', n[k]): + raise ValueError('Invalid DNS name: ' + k) + for k in ('vps_id',): + if not re.fullmatch(r'VPS-[0-9]{3}', n[k]): + raise ValueError('Use the exact CoDocs VPS ID') + identities = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'socks_port')} + identities.update(relay=(n['relay_host'], n['relay_port']), number=(n['country'], n['sequence'])) + for k, value in identities.items(): + if value in seen[k]: + raise ValueError('Duplicate batch identity: ' + k) + seen[k].add(value) + # The plan carries references, never secret values. Adapters load credentials themselves. + def check(value): + if isinstance(value, dict): + for k, v in value.items(): + if re.search(r'password|secret|token|private_key', k, re.I): + raise ValueError('Secret fields are not allowed in a batch plan') + check(v) + elif isinstance(value, list): + for v in value: + check(v) + check(plan) + return plan + + +@contextlib.contextmanager +def lock(root, name): + path = root / ('lock-' + hashlib.sha256(name.encode()).hexdigest()) + with path.open('a+') as f: + os.chmod(path, 0o600) + while True: + try: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if STOP.wait(.1): + raise RuntimeError('Stopped while waiting for resource lock') + try: + yield + finally: + fcntl.flock(f, fcntl.LOCK_UN) + + +class Ledger: + def __init__(self, root): + self.root = Path(root).resolve() + if self.root == Path('/'): + raise ValueError('Invalid state root') + self.root.mkdir(parents=True, mode=0o700, exist_ok=True) + os.chmod(self.root, 0o700) + self.path = self.root / 'registry.sqlite3' + with self.db() as c: + c.executescript(''' + CREATE TABLE IF NOT EXISTS batches(id TEXT PRIMARY KEY, digest TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS claims(namespace TEXT, kind TEXT, value TEXT, + owner TEXT NOT NULL, PRIMARY KEY(namespace,kind,value)); + CREATE TABLE IF NOT EXISTS phases(batch TEXT, node TEXT, phase TEXT, + status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + updated REAL NOT NULL, PRIMARY KEY(batch,node,phase)); + ''') + os.chmod(self.path, 0o600) + + def db(self): + c = sqlite3.connect(self.path, timeout=30) + c.execute('PRAGMA synchronous=FULL') + return c + + def reserve(self, plan): + namespace = plan['context']['panel_vps'] + ':' + plan['context']['app_root'] + with self.db() as c: + c.execute('BEGIN IMMEDIATE') + prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone() + if prior and prior[0] != digest(plan): + raise ValueError('Batch plan changed; resume requires the original exact plan') + for n in plan['nodes']: + owner = digest({'context': plan['context'], 'node': n}) + claims = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain')} + claims['relay'] = [n['relay_host'], n['relay_port']] + claims['number'] = [n['country'], n['sequence']] + for kind, value in claims.items(): + scope = namespace + if kind == 'relay': + scope = 'relay:' + n['relay_host'] + elif kind in ('vps_id', 'node_address', 'tls_domain'): + scope = 'infrastructure' + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True) + existing = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?', + (scope, kind, encoded)).fetchone() + if existing and existing[0] != owner: + raise ValueError('Another batch reserved identity: ' + kind) + c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, kind, encoded, owner)) + # SOCKS listeners are shared by all batches using this gateway. + scope, value = 'gateway:' + plan['context']['gateway_vps'], str(n['socks_port']) + old = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?', + (scope, 'socks_port', value)).fetchone() + if old and old[0] != owner: + raise ValueError('Another batch reserved the gateway SOCKS port') + c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, 'socks_port', value, owner)) + c.execute('INSERT OR IGNORE INTO batches VALUES(?,?)', (plan['batch_id'], digest(plan))) + + def state(self, batch, key, phase): + with self.db() as c: + row = c.execute('SELECT status FROM phases WHERE batch=? AND node=? AND phase=?', + (batch, key, phase)).fetchone() + return row[0] if row else 'pending' + + def mark(self, batch, key, phase, status, attempt=False): + with self.db() as c: + c.execute('''INSERT INTO phases VALUES(?,?,?,?,?,?) ON CONFLICT(batch,node,phase) + DO UPDATE SET status=excluded.status,attempts=phases.attempts+?,updated=excluded.updated''', + (batch, key, phase, status, int(attempt), time.time(), int(attempt))) + + +class Coordinator: + def __init__(self, plan, root, jobs=4, timeout=900): + self.plan = validate(plan) + self.ledger = Ledger(root) + self.jobs = jobs + self.timeout = timeout + self.ledger.reserve(plan) + self.dir = self.ledger.root / plan['batch_id'] + self.dir.mkdir(mode=0o700, exist_ok=True) + self.plan_file = self.dir / 'plan.json' + atomic_json(self.plan_file, plan) + + def call(self, n, phase, action): + fingerprint = digest({'context': self.plan['context'], 'node': n}) + path = self.dir / (n['key'] + '.json') + atomic_json(path, dict(n, request_sha256=fingerprint)) + argv = self.plan['adapter'] + ['--plan', str(self.plan_file), '--node', str(path), + '--phase', phase, '--action', action, '--state-dir', str(self.dir)] + with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=True) as p: + deadline = time.monotonic() + self.timeout + while True: + try: + output, _ = p.communicate(timeout=.2) + break + except subprocess.TimeoutExpired: + if STOP.is_set() or time.monotonic() >= deadline: + os.killpg(p.pid, signal.SIGTERM) + try: + p.communicate(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(p.pid, signal.SIGKILL) + p.communicate() + raise RuntimeError('Adapter interrupted; remote outcome must be probed') + if p.returncode or len(output) > 65536: + raise RuntimeError('Adapter failed; no stdout/stderr persisted') + r = json.loads(output) + if (r.get('key'), r.get('phase'), r.get('request_sha256')) != (n['key'], phase, fingerprint): + raise RuntimeError('Adapter receipt identity mismatch') + if r.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'): + raise RuntimeError('Invalid adapter status') + return r + + def phase(self, n, phase): + try: + return self._phase(n, phase) + except Exception: + self.ledger.mark(self.plan['batch_id'], n['key'], phase, 'unknown') + return False + + def _phase(self, n, phase): + batch, key = self.plan['batch_id'], n['key'] + resource = SHARED.get(phase, n['vps_id']) + # Common panel/dns/relay lock names across batches and processes. + namespace = self.plan['context']['panel_vps'] + if resource in ('dns', 'relay'): + namespace = 'dpl-external' + with lock(self.ledger.root, namespace + ':' + resource): + r = self.call(n, phase, 'probe') + if r['status'] == 'ready': + self.ledger.mark(batch, key, phase, 'done') + return True + if r['status'] == 'conflict': + STOP.set() + if phase in ('preflight', 'verify', 'audit') or r['status'] != 'absent' or r.get('retry_safe') is not True: + self.ledger.mark(batch, key, phase, r['status']) + return False + if STOP.is_set(): + return False + # Persist intent before a write; never infer success from the command exit code. + self.ledger.mark(batch, key, phase, 'running', attempt=True) + applied = self.call(n, phase, 'apply') + if applied['status'] == 'conflict': + self.ledger.mark(batch, key, phase, 'conflict') + STOP.set() + return False + r = self.call(n, phase, 'probe') + self.ledger.mark(batch, key, phase, 'done' if r['status'] == 'ready' else r['status']) + if r['status'] == 'conflict': + STOP.set() + return r['status'] == 'ready' + + def worker(self, n): + try: + with lock(self.ledger.root, 'node:' + self.plan['context']['panel_vps'] + ':' + n['key']): + self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'running') + # Already finished batches are re-audited; disabled QA need not be re-enabled. + if self.ledger.state(self.plan['batch_id'], n['key'], 'audit') == 'done': + success = self.phase(n, 'audit') + else: + success = True + for phase in PHASES: + if STOP.is_set() or not self.phase(n, phase): + success = False + break + self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'done' if success else 'waiting') + return success + except Exception: + # Never print adapter payloads, commands, credentials or exception contents. + self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'unknown') + return False + + def run(self): + # Whole-batch barrier: no staging until every candidate passed current preflight. + with concurrent.futures.ThreadPoolExecutor(max_workers=self.jobs) as pool: + def check(n): + try: + return self.phase(n, 'preflight') + except Exception: + self.ledger.mark(self.plan['batch_id'], n['key'], 'preflight', 'unknown') + return False + checks = list(pool.map(check, self.plan['nodes'])) + if not all(checks): + return False + return all(list(pool.map(self.worker, self.plan['nodes']))) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('command', choices=('plan', 'run', 'status')) + p.add_argument('--plan', required=True) + p.add_argument('--state-root', required=True) + p.add_argument('--jobs', type=int, default=4) + p.add_argument('--timeout', type=int, default=900) + p.add_argument('--execute', action='store_true', help='Required for a run that may write remotely') + a = p.parse_args() + os.umask(0o077) + if not 1 <= a.jobs <= 16 or not 1 <= a.timeout <= 3600: + p.error('jobs must be 1..16; timeout 1..3600') + try: + plan = validate(read_json(a.plan)) + if a.command == 'plan': + print(json.dumps({'batch_id': plan['batch_id'], 'nodes': len(plan['nodes']), + 'plan_sha256': digest(plan), 'writes': False})) + return 0 + if a.command == 'run' and not a.execute: + p.error('run requires --execute; plan does not perform remote requests') + if a.command == 'status': + path = Path(a.state_root).resolve() / 'registry.sqlite3' + rows = {} + if path.exists(): + with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as c: + prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone() + if prior and prior[0] != digest(plan): + raise ValueError('Batch plan differs from its reserved plan') + rows = {(key, phase): status for key, phase, status in c.execute( + 'SELECT node,phase,status FROM phases WHERE batch=?', (plan['batch_id'],))} + print(json.dumps({n['key']: {phase: rows.get((n['key'], phase), 'pending') + for phase in ('preflight',) + PHASES + ('coordinator',)} + for n in plan['nodes']})) + return 0 + runner = Coordinator(plan, a.state_root, a.jobs, a.timeout) + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, lambda *_: STOP.set()) + success = runner.run() + print(json.dumps({'batch_id': plan['batch_id'], 'complete': success, + 'next': None if success else 'Inspect status, resolve waiting/conflict, rerun the exact plan'})) + return 0 if success else 2 + except Exception as exc: + # Validation errors have no adapter/credential material. + print(json.dumps({'error': str(exc) if isinstance(exc, ValueError) else 'Coordinator failed; inspect protected state'})) + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/node-onboarding/command-adapter.py b/scripts/node-onboarding/command-adapter.py new file mode 100644 index 0000000..9c54328 --- /dev/null +++ b/scripts/node-onboarding/command-adapter.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run reviewed, pinned site-specific commands for batch.py without a shell. + +Missing integration is a waiting gate, never an assumed successful operation. +Handlers receive paths to the frozen request, not passwords on the command line. +""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + for field in ('plan', 'node', 'phase', 'action', 'state-dir'): + p.add_argument('--' + field, required=True) + a = p.parse_args() + node = json.loads(Path(a.node).read_text()) + plan = json.loads(Path(a.plan).read_text()) + identity = {'key': node['key'], 'phase': a.phase, 'request_sha256': node['request_sha256']} + try: + profile = plan['command_profile'] + # Every runnable local script/config must be pinned; pinning the interpreter + # alone does not pin scripts, remote code or the commands' dependencies. + files = profile['files'] + if not files: + raise ValueError('No pinned handler files') + for filename, expected in files.items(): + if not Path(filename).is_absolute(): + raise ValueError('Absolute handler paths required') + if hashlib.sha256(Path(filename).read_bytes()).hexdigest() != expected: + raise ValueError('Handler changed') + command = profile['phases'].get(a.phase, {}).get(a.action) + if command is None: + result = dict(identity, status='waiting', reason='handler_not_configured') + else: + if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command): + raise ValueError('Command must be argv') + values = dict(plan=a.plan, node=a.node, state_dir=a.state_dir, + key=node['key'], phase=a.phase, action=a.action) + argv = [x.format_map(values) for x in command] + if not Path(argv[0]).is_absolute(): + raise ValueError('Absolute executable required') + completed = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if completed.returncode or len(completed.stdout) > 65536: + raise ValueError('Handler failed') + result = json.loads(completed.stdout) + if any(result.get(k) != v for k, v in identity.items()): + raise ValueError('Wrong handler receipt') + if result.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'): + raise ValueError('Wrong handler status') + # Only structured nonsecret receipt fields are allowed through to the runner. + safe = {**identity, 'status': result['status']} + if result.get('retry_safe') is True: + safe['retry_safe'] = True + print(json.dumps(safe)) + return 0 + except Exception: + print(json.dumps(dict(identity, status='unknown'))) + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/node-onboarding/operations.php b/scripts/node-onboarding/operations.php new file mode 100644 index 0000000..54d1d0f --- /dev/null +++ b/scripts/node-onboarding/operations.php @@ -0,0 +1,332 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +} + +final class PanelChange +{ + private const MODELS = ['node'=>Server::class,'qa'=>User::class,'user'=>User::class, + 'plan'=>Plan::class,'group'=>ServerGroup::class,'setting'=>Setting::class]; + private const FIELDS = ['node'=>['group_ids'],'qa'=>['banned'],'user'=>['group_id'], + 'plan'=>['group_id'],'group'=>['name'],'setting'=>['value']]; + + private static function normalize(string $field, mixed $value): mixed + { + return match ($field) { + 'group_ids' => array_map('strval', $value ?? []), + 'group_id' => $value === null ? null : (int)$value, + 'banned','enabled','show' => (bool)$value, + default => $value, + }; + } + + private static function object(string $kind, int|string $id): mixed + { + $class = self::MODELS[$kind] ?? throw new RuntimeException('Unsupported journal object'); + return $class::where($kind === 'setting' ? 'name' : 'id', $id)->lockForUpdate()->first(); + } + + private static function value(mixed $model, string $field): mixed + { + return $model === null ? null : self::normalize($field, $model->getAttribute($field)); + } + + private static function operation(string $kind, mixed $model, string $field, mixed $after, int|string|null $id = null): ?array + { + if (!in_array($field, self::FIELDS[$kind] ?? [], true)) throw new RuntimeException('Unsupported journal field'); + $before = self::value($model, $field); + $after = self::normalize($field, $after); + if ($model !== null && $before === $after) return null; + return ['kind'=>$kind,'id'=>$id ?? $model->getKey(),'field'=>$field, + 'before_exists'=>$model !== null,'before'=>$before,'after'=>$after]; + } + + private static function context(array $m): array + { + $node = self::object('node', $m['node_id']) ?? throw new RuntimeException('Manifest node missing'); + $qa = self::object('qa', $m['test_user_id']) ?? throw new RuntimeException('Manifest QA missing'); + if ((int)$node->machine_id !== $m['machine_id'] || $node->type !== 'anytls') { + throw new RuntimeException('Manifest node/machine mismatch'); + } + if ($qa->is_admin || (int)$qa->group_id !== $m['group_id'] || + !str_starts_with($qa->email, 'node-qa-' . $m['key'] . '-') || + !str_ends_with($qa->email, '@example.invalid')) { + throw new RuntimeException('Manifest does not identify an isolated QA user'); + } + return [$node, $qa]; + } + + private static function checkJournal(array $j, array $m): void + { + if (($j['version'] ?? null) !== 1 || ($j['manifest'] ?? null) !== $m || !is_array($j['operations'] ?? null)) { + throw new RuntimeException('Journal/manifest mismatch or unsupported version'); + } + foreach ($j['operations'] as $op) { + if (!in_array($op['field'], self::FIELDS[$op['kind']] ?? [], true)) throw new RuntimeException('Invalid journal operation'); + if ($op['kind']==='node' && $op['id']!==$m['node_id']) throw new RuntimeException('Wrong node in journal'); + if ($op['kind']==='qa' && $op['id']!==$m['test_user_id']) throw new RuntimeException('Wrong QA in journal'); + if ($op['kind']==='setting' && $op['id']!=='subscribe_url') throw new RuntimeException('Wrong setting in journal'); + if ($j['request']['mode']==='append' && !in_array($op['kind'], ['node','qa'], true)) { + throw new RuntimeException('Append journal contains shared-resource writes'); + } + } + } + + private static function current(array $op): array + { + $model = self::object($op['kind'], $op['id']); + return [$model, $model !== null, self::value($model, $op['field'])]; + } + + private static function applyOperation(array $op): void + { + [$model, $exists, $value] = self::current($op); + if ($exists !== $op['before_exists'] || $value !== $op['before']) throw new RuntimeException('Object changed before application'); + if (!$exists) { + if ($op['kind'] !== 'setting') throw new RuntimeException('Missing target'); + $model = new Setting(['name'=>$op['id']]); + } + $model->setAttribute($op['field'], $op['after']); + // Keep model observers for node full-sync notifications; QA does not send notifications. + $op['kind'] === 'qa' ? $model->saveQuietly() : $model->save(); + } + + private static function flushSettings(array $ops): void + { + if (array_filter($ops, fn($op) => $op['kind']==='setting')) { + // Called after commit; clearing request-local state also makes retry recovery safe. + Cache::store('redis')->forget(\App\Support\Setting::CACHE_KEY); + app()->forgetInstance(\App\Support\Setting::class); + } + } + + private static function notifyCommitted(array $m): void + { + // Explicitly repeat idempotent invalidations after commit. If an observer's + // after-commit callback failed, prepared+after recovery must still notify. + \App\Services\NodeSyncService::notifyFullSync($m['node_id']); + \App\Services\NodeSyncService::notifyMachineNodesChanged($m['machine_id']); + } + + private static function verifyEvidence(array $request, array $m, mixed $node): void + { + $report = readJson($request['verification'] ?? throw new RuntimeException('Missing --verification live QA report')); + $at = $report['verified_at'] ?? 0; + if (($report['state'] ?? '') !== 'live_verified' || ($report['qa_user_id'] ?? null) !== $m['test_user_id'] || + !is_int($at) || $at > time() || time()-$at > 900) { + throw new RuntimeException('Recent live verification for this manifest QA required'); + } + $sni = $node->protocol_settings['tls']['server_name'] ?? null; + $targets = array_filter($report['results'] ?? [], fn($row) => + ($row['live'] ?? false) === true && ($row['strict_tls'] ?? false) === true && + ($row['relay'] ?? null) === $node->host && (int)($row['relay_port'] ?? 0) === (int)$node->port && + ($row['sni'] ?? null) === $sni); + if (count($targets)!==1) throw new RuntimeException('Live evidence endpoint/SNI mismatch'); + } + + public static function promote(string $dir, array $request, bool $apply): array + { + $mode = $request['mode'] ?? ''; + if (!in_array($mode, ['append','initialize'], true)) throw new RuntimeException('Explicit append/initialize mode required'); + $request['group'] = isset($request['group']) ? (int)$request['group'] : null; + if ($mode==='append' && (!$request['group'] || isset($request['plans']) || isset($request['subscribe-url']) || isset($request['group-name']))) { + throw new RuntimeException('Append requires group and forbids plans, subscribe-url and group-name'); + } + if ($mode==='initialize') { + $request['plan_ids'] = ids($request['plans'] ?? ''); + $url = $request['subscribe-url'] ?? ''; + $parts = parse_url($url); + if (!is_array($parts) || ($parts['scheme'] ?? '')!=='https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { + throw new RuntimeException('Initialize requires an explicit HTTPS subscribe-url without credentials/query'); + } + } + ksort($request); + $m = manifest($dir); $path = $dir . '/promotion-journal.json'; + $result = DB::transaction(function () use ($dir,$request,$mode,$m,$path,$apply) { + [$node,$qa] = self::context($m); + if (is_file($path)) { + $j = readJson($path); self::checkJournal($j,$m); + if ($j['request'] !== $request) throw new RuntimeException('Existing operation has different parameters'); + if (is_file($dir.'/rollback-journal.json')) throw new RuntimeException('Rollback started; use a new reviewed task'); + $before = $after = true; + foreach ($j['operations'] as $op) { + [, $exists,$value] = self::current($op); + $before = $before && $exists===$op['before_exists'] && $value===$op['before']; + $after = $after && $exists && $value===$op['after']; + } + if (!$after && (!$before || $j['state']!=='prepared')) throw new RuntimeException('Journal conflict; inspect before retrying'); + if (!$apply) return ['state'=>$after?'already_applied':'preview','journal'=>$j]; + if (!$after) { + self::verifyEvidence($request,$m,$node); + if (!$node->is_online || (int)$qa->u+(int)$qa->d<=0 || $qa->banned) throw new RuntimeException('QA evidence no longer ready'); + foreach ($j['operations'] as $op) self::applyOperation($op); + } + return ['state'=>'applied','journal'=>$j]; + } + if (is_file($dir.'/promoted.json')) throw new RuntimeException('Legacy promotion exists without new journal; do not adopt automatically'); + if (!$node->enabled || !$node->show || !$node->is_online || (int)$qa->u + (int)$qa->d <= 0 || $qa->banned) throw new RuntimeException('Fresh online/active QA traffic evidence missing'); + if (self::value($node,'group_ids') !== [(string)$m['group_id']]) throw new RuntimeException('Node no longer in its isolated stage group'); + $groupId = $request['group'] ?? $m['group_id']; + $group = self::object('group',$groupId) ?? throw new RuntimeException('Target group missing'); + if ($mode==='append' && $groupId===$m['group_id']) throw new RuntimeException('Append requires an existing formal group'); + $ops = [self::operation('node',$node,'group_ids',[(string)$groupId]), self::operation('qa',$qa,'banned',true)]; + if ($mode==='initialize') { + $plans = Plan::whereIn('id',$request['plan_ids'])->orderBy('id')->lockForUpdate()->get(); + if ($plans->count()!==count($request['plan_ids'])) throw new RuntimeException('Plan IDs do not all exist'); + foreach ($plans as $plan) { + if ($plan->group_id !== null && (int)$plan->group_id!==$groupId) throw new RuntimeException('Plan belongs to another group'); + $ops[] = self::operation('plan',$plan,'group_id',$groupId); + } + // Only the explicitly selected plans' unassigned users can change in initialize mode. + foreach (User::whereIn('plan_id',$request['plan_ids'])->whereNull('group_id')->orderBy('id')->lockForUpdate()->get() as $user) { + $ops[] = self::operation('user',$user,'group_id',$groupId); + } + if (isset($request['group-name'])) $ops[] = self::operation('group',$group,'name',$request['group-name']); + $setting = self::object('setting','subscribe_url'); + $ops[] = self::operation('setting',$setting,'value',$request['subscribe-url'],'subscribe_url'); + } + if ($apply) self::verifyEvidence($request,$m,$node); + $j = ['version'=>1,'state'=>'prepared','manifest'=>$m,'request'=>$request, + 'operations'=>array_values(array_filter($ops)), + 'node_guard'=>['enabled'=>(bool)$node->enabled,'show'=>(bool)$node->show]]; + if (!$apply) return ['state'=>'preview','journal'=>$j]; + writeJson($path,$j); // Durable intent before any DB write; recover prepared+after on retry. + foreach ($j['operations'] as $op) self::applyOperation($op); + return ['state'=>'applied','journal'=>$j]; + },1); + if ($apply) { + self::flushSettings($result['journal']['operations']); + self::notifyCommitted($m); + $result['journal']['state']='applied'; writeJson($path,$result['journal']); + } + return $result; + } + + public static function rollback(string $dir, bool $apply): array + { + $m = manifest($dir); $j = readJson($dir.'/promotion-journal.json'); self::checkJournal($j,$m); + $rollbackPath=$dir.'/rollback-journal.json'; + $result=DB::transaction(function () use ($m,$j,$apply,$rollbackPath) { + [$node,$qa]=self::context($m); + if (!$qa->banned) throw new RuntimeException('QA must remain disabled during rollback'); + if (is_file($rollbackPath)) { + $r=readJson($rollbackPath); + if (($r['promotion']??null)!==$j) throw new RuntimeException('Rollback journal mismatch'); + } else { + // Rollback itself has a durable before/after journal, including node containment. + $undo=[]; + foreach (array_reverse($j['operations']) as $op) { + if ($op['kind']==='qa') continue; // Never reactivate test users. + [$model,$exists,$value]=self::current($op); + if (!$exists || $value!==$op['after']) throw new RuntimeException('Rollback conflict: current value differs from recorded after'); + $undo[]=['kind'=>$op['kind'],'id'=>$op['id'],'field'=>$op['field'], + 'before_exists'=>true,'before'=>$op['after'],'after'=>$op['before'], + 'delete'=>!$op['before_exists']]; + } + foreach (['enabled','show'] as $field) { + if ((bool)$node->$field!==$j['node_guard'][$field]) throw new RuntimeException('Node containment field changed after promotion'); + if ($node->$field) $undo[]=['kind'=>'node','id'=>$m['node_id'],'field'=>$field, + 'before_exists'=>true,'before'=>true,'after'=>false,'delete'=>false]; + } + if (!$qa->banned) throw new RuntimeException('QA reactivated after promotion; resolve conflict before rollback'); + $r=['version'=>1,'state'=>'prepared','promotion'=>$j,'operations'=>$undo]; + } + $before=$after=true; + foreach ($r['operations'] as $op) { + // Only rollback containment adds these two node fields. + if (!in_array($op['field'],self::FIELDS[$op['kind']]??[],true) && !($op['kind']==='node' && $op['id']===$m['node_id'] && in_array($op['field'],['enabled','show'],true))) { + throw new RuntimeException('Invalid rollback field'); + } + [, $exists,$value]=self::current($op); + $before=$before && $exists && $value===$op['before']; + $after=$after && ($op['delete'] ? !$exists : ($exists && $value===$op['after'])); + } + if (!$after && (!$before || $r['state']!=='prepared')) throw new RuntimeException('Rollback conflicts; no objects restored'); + if (!$apply) return ['state'=>$after?'already_rolled_back':'preview','journal'=>$r]; + if (!is_file($rollbackPath)) writeJson($rollbackPath,$r); + if (!$after) foreach ($r['operations'] as $op) { + [$model]=self::current($op); + if ($op['delete']) $model->delete(); + else { $model->setAttribute($op['field'],$op['after']); $model->save(); } + } + return ['state'=>'rolled_back','journal'=>$r]; + },1); + if ($apply) { + self::flushSettings($j['operations']); + self::notifyCommitted($m); + $result['journal']['state']='rolled_back';writeJson($rollbackPath,$result['journal']); + } + return $result; + } +} diff --git a/scripts/node-onboarding/promote.php b/scripts/node-onboarding/promote.php index 453a801..10299d1 100644 --- a/scripts/node-onboarding/promote.php +++ b/scripts/node-onboarding/promote.php @@ -1,43 +1,25 @@ make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); -use App\Models\Plan; -use App\Models\Server; -use App\Models\ServerGroup; -use App\Models\User; -use Illuminate\Support\Facades\DB; -$opts = getopt('', ['backup:', 'plans:', 'group:']); -$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup'); -$ids = array_map('intval', explode(',', $opts['plans'] ?? '')); -if (!$ids || in_array(0, $ids, true)) throw new RuntimeException('Explicit plan IDs required'); -$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); -$groupId = (int)($opts['group'] ?? $m['group_id']); -$node = Server::findOrFail($m['node_id']); -$qa = User::findOrFail($m['test_user_id']); -if (!$node->is_online || $qa->u + $qa->d <= 0) throw new RuntimeException('Online and traffic evidence missing'); -if (is_file("$dir/promoted.json")) { echo "Already promoted; verify current state separately\n"; exit; } -$plans = Plan::whereIn('id', $ids)->get(); -if ($plans->count() !== count($ids)) throw new RuntimeException('Plan list mismatch'); -foreach ($plans as $p) if ($p->group_id !== null && $p->group_id !== $groupId) throw new RuntimeException('Plan already assigned elsewhere'); -$users = User::whereIn('plan_id', $ids)->whereNull('group_id')->get(['id','group_id','updated_at']); -umask(0077); -file_put_contents("$dir/before-promotion.json", json_encode([ - 'plans' => $plans->map->getAttributes(), 'user_groups' => $users, - 'node_groups' => $node->group_ids, 'group' => ServerGroup::findOrFail($groupId)->getAttributes(), - 'subscribe_url' => admin_setting('subscribe_url'), 'qa_banned' => $qa->banned, -], JSON_THROW_ON_ERROR)); -DB::transaction(function () use ($ids, $users, $node, $qa, $groupId) { - $group = ServerGroup::findOrFail($groupId); - if (str_starts_with($group->name, 'dpl 接入验收 ')) { $group->name = '基础节点'; $group->save(); } - Plan::whereIn('id', $ids)->whereNull('group_id')->update(['group_id' => $groupId]); - User::whereIn('id', $users->pluck('id'))->whereNull('group_id')->update(['group_id' => $groupId]); - $node->group_ids = [(string)$groupId]; $node->save(); - $qa->banned = 1; $qa->saveQuietly(); - admin_setting(['subscribe_url' => 'https://c2.deeplink-subscribe.com']); -}); -$result = ['node_id' => $node->id, 'group_id' => $groupId, 'plan_ids' => $ids, - 'existing_users_assigned' => $users->count(), 'qa_disabled' => true]; -file_put_contents("$dir/promoted.json", json_encode($result, JSON_THROW_ON_ERROR)); -echo json_encode($result) . PHP_EOL; +// Default is preview. Apply only in a separately authorized target environment. +require __DIR__ . '/operations.php'; +use DplOnboarding\PanelChange; +$opts = getopt('', ['backup:', 'mode:', 'plans:', 'group:', 'subscribe-url:', 'group-name:', 'verification:', 'apply', 'help']); +if (isset($opts['help'])) { + echo "--backup=DIR --mode=append --group=ID [--verification=QA_REPORT --apply]\n"; + echo "--backup=DIR --mode=initialize --plans=IDS --subscribe-url=HTTPS_URL [--group=ID] [--group-name=NAME] [--verification=QA_REPORT --apply]\n"; + exit; +} +try { + $dir = $opts['backup'] ?? throw new RuntimeException('Missing --backup'); + $apply = array_key_exists('apply', $opts); + unset($opts['backup'], $opts['apply']); + if (!isset($opts['mode']) || !in_array($opts['mode'], ['append','initialize'], true)) throw new RuntimeException('Explicit --mode required'); + if (isset($opts['group'])) { DplOnboarding\ids($opts['group']); if (str_contains($opts['group'],',')) throw new RuntimeException('One group required'); } + DplOnboarding\boot(); + $result = DplOnboarding\locked($dir, fn() => PanelChange::promote($dir, $opts, $apply)); + // Journal is field-scoped and contains no passwords/UUIDs/tokens. + echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . PHP_EOL; +} catch (Throwable $e) { + $reason=get_class($e)===RuntimeException::class ? $e->getMessage() : "Inspect protected journal/current state"; + fwrite(STDERR, "Promotion stopped: " . $reason . "\n"); + exit(1); +} diff --git a/scripts/node-onboarding/readiness.php b/scripts/node-onboarding/readiness.php index 42650e4..58fb0eb 100644 --- a/scripts/node-onboarding/readiness.php +++ b/scripts/node-onboarding/readiness.php @@ -1,16 +1,32 @@ make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); -$opts = getopt('', ['backup:']); -$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup'); -$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); -$node = App\Models\Server::findOrFail($m['node_id']); -$qa = App\Models\User::findOrFail($m['test_user_id']); -echo json_encode([ - 'node_id' => $node->id, 'name' => $node->name, 'is_online' => $node->is_online, - 'last_check_at' => $node->last_check_at, 'last_push_at' => $node->last_push_at, - 'qa_upload' => $qa->u, 'qa_download' => $qa->d, 'qa_banned' => $qa->banned, - 'plans' => App\Models\Plan::orderBy('id')->get(['id', 'group_id']), - 'existing_users_with_selected_plan_and_null_group' => App\Models\User::whereIn('plan_id', [1,6,7,8])->whereNull('group_id')->count(), -], JSON_UNESCAPED_UNICODE) . PHP_EOL; +require __DIR__ . '/operations.php'; +$opts=getopt('', ['backup:', 'plans:', 'group:', 'help']); +if (isset($opts['help'])) { echo "--backup=DIR [--plans=ID,ID] [--group=ID]; no implicit plan IDs\n"; exit; } +try { + $dir=$opts['backup'] ?? throw new RuntimeException('Missing --backup'); + $ids=isset($opts['plans']) ? DplOnboarding\ids($opts['plans']) : []; + $group=null; + if (isset($opts['group'])) { + $groups=DplOnboarding\ids($opts['group']); + if (count($groups)!==1) throw new RuntimeException('One group required'); + $group=$groups[0]; + } + $m=DplOnboarding\manifest($dir); + DplOnboarding\boot(); + $node=App\Models\Server::findOrFail($m['node_id']); + $qa=App\Models\User::findOrFail($m['test_user_id']); + if ((int)$node->machine_id!==$m['machine_id']) throw new RuntimeException('Manifest machine mismatch'); + $plans=App\Models\Plan::whereIn('id',$ids)->orderBy('id')->get(['id','group_id']); + if ($plans->count()!==count($ids)) throw new RuntimeException('Some selected plans missing'); + if ($group!==null) App\Models\ServerGroup::findOrFail($group); + $result=[ + 'node_id'=>$node->id, 'name'=>$node->name, 'is_online'=>(bool)$node->is_online, + 'last_check_at'=>$node->last_check_at, 'last_push_at'=>$node->last_push_at, + 'group_ids'=>$node->group_ids, 'qa_upload'=>(int)$qa->u, 'qa_download'=>(int)$qa->d, + 'qa_banned'=>(bool)$qa->banned, 'selected_plan_ids'=>$ids, 'plans'=>$plans->toArray(), + 'existing_users_with_selected_plan_and_null_group'=>$ids ? App\Models\User::whereIn('plan_id',$ids)->whereNull('group_id')->count() : null, + 'selected_plans_in_other_group'=>$group===null ? null : $plans->filter(fn($p) => $p->group_id!==null && (int)$p->group_id!==$group)->pluck('id')->all(), + 'scope'=>'Read-only snapshot; not subscription/traffic/GUI acceptance', + ]; + echo json_encode($result,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE).PHP_EOL; +} catch (Throwable $e) { fwrite(STDERR,"Readiness failed; verify parameters and manifest.\n"); exit(1); } diff --git a/scripts/node-onboarding/rollback.php b/scripts/node-onboarding/rollback.php new file mode 100644 index 0000000..b8c5657 --- /dev/null +++ b/scripts/node-onboarding/rollback.php @@ -0,0 +1,16 @@ + PanelChange::rollback($dir, array_key_exists('apply',$opts))); + echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . PHP_EOL; +} catch (Throwable $e) { + $reason=get_class($e)===RuntimeException::class ? $e->getMessage() : "Inspect journal/conflicts"; + fwrite(STDERR, "Rollback stopped: " . $reason . "\n"); + exit(1); +} diff --git a/scripts/node-onboarding/stage.php b/scripts/node-onboarding/stage.php index e3de6a2..6202553 100644 --- a/scripts/node-onboarding/stage.php +++ b/scripts/node-onboarding/stage.php @@ -1,82 +1,109 @@ make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); - -use App\Models\Server; -use App\Models\ServerGroup; -use App\Models\ServerMachine; -use App\Models\User; +// Run only in an authorized target environment. --help never boots the app. +require __DIR__ . '/operations.php'; +use App\Models\{Server,ServerGroup,ServerMachine,User}; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; - -$opts = getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'backup:']); -foreach (['key', 'name', 'relay-host', 'relay-port', 'listen-port', 'tls-domain', 'backup'] as $key) { - if (empty($opts[$key])) throw new RuntimeException("Missing --$key"); -} -if (!preg_match('/^[a-z0-9-]+$/', $opts['key'])) throw new RuntimeException('Invalid key'); -foreach (['relay-port', 'listen-port'] as $key) { - if (!ctype_digit((string)$opts[$key]) || $opts[$key] < 1024 || $opts[$key] > 65535) throw new RuntimeException('Invalid port'); -} -$dir = rtrim($opts['backup'], '/'); -umask(0077); -if (!is_dir($dir) && !mkdir($dir, 0700, true)) throw new RuntimeException('Backup creation failed'); -if (is_file("$dir/manifest.json")) { - $manifest = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR); - if ($manifest['key'] !== $opts['key']) throw new RuntimeException('Backup belongs to another node'); - echo json_encode(['already_staged' => true, 'node_id' => $manifest['node_id']]) . PHP_EOL; +$opts=getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'panel-url:', 'backup:', 'help']); +if (isset($opts['help'])) { + echo "--key=KEY --name=NAME --relay-host=HOST --relay-port=PORT --listen-port=PORT --tls-domain=DOMAIN --panel-url=HTTPS_URL --backup=DIR\n"; exit; } -if (ServerMachine::where('name', 'dpl-' . $opts['key'])->exists() || Server::where('name', $opts['name'])->exists()) { - throw new RuntimeException('Existing resources found without this backup manifest'); +try { + foreach (['key','name','relay-host','relay-port','listen-port','tls-domain','panel-url','backup'] as $key) { + if (!isset($opts[$key]) || !is_string($opts[$key]) || $opts[$key]==='') throw new RuntimeException('Missing required stage option'); + } + if (!preg_match('/^[a-z0-9-]+$/D',$opts['key'])) throw new RuntimeException('Invalid deployment key'); + foreach (['relay-port','listen-port'] as $key) { + if (!ctype_digit($opts[$key]) || (int)$opts[$key]<1024 || (int)$opts[$key]>65535) throw new RuntimeException('Invalid port'); + $opts[$key]=(int)$opts[$key]; + } + foreach (['relay-host','tls-domain'] as $key) { + if (!preg_match('/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/Di',$opts[$key])) throw new RuntimeException('Explicit hostname required'); + } + $url=parse_url($opts['panel-url']); + if (!is_array($url) || ($url['scheme']??'')!=='https' || empty($url['host']) || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment'])) { + throw new RuntimeException('Explicit HTTPS panel URL without credentials/query required'); + } + $dir=rtrim($opts['backup'],'/'); + if (!str_starts_with($dir,'/') || is_link($dir)) throw new RuntimeException('Absolute private backup directory required'); + umask(0077); + if (!is_dir($dir) && !mkdir($dir,0700,true)) throw new RuntimeException('Cannot create backup directory'); + if ((fileperms($dir)&0077)!==0) throw new RuntimeException('Backup directory must be private'); + $parameters=$opts;unset($parameters['backup']);ksort($parameters); + DplOnboarding\boot(); + $m=DplOnboarding\locked($dir,function () use ($dir,$parameters) { + $path=$dir.'/manifest.json'; + if (is_file($path)) { + $m=DplOnboarding\manifest($dir); + if (($m['parameters']??null)!==$parameters) throw new RuntimeException('Existing manifest has different/legacy parameters; no automatic adoption'); + $node=Server::findOrFail($m['node_id']);$qa=User::findOrFail($m['test_user_id']); + $machine=ServerMachine::findOrFail($m['machine_id']); + if ((int)$node->machine_id!==$machine->id || $machine->name!=='dpl-'.$parameters['key'] || + $node->host!==$parameters['relay-host'] || (int)$node->port!==$parameters['relay-port'] || + (int)$node->server_port!==$parameters['listen-port'] || $node->type!=='anytls' || + ($node->protocol_settings['tls']['server_name']??null)!==$parameters['tls-domain'] || + (int)$qa->group_id!==$m['group_id']) throw new RuntimeException('Staged resources drifted'); + if (is_file($dir.'/promotion-journal.json') || is_file($dir.'/promoted.json')) throw new RuntimeException('Node already promoted; staging cannot regenerate credentials'); + return $m; + } + return DB::transaction(function () use ($parameters,$path,$dir) { + if (ServerMachine::where('name','dpl-'.$parameters['key'])->exists() || Server::where('name',$parameters['name'])->exists() || + Server::where('host',$parameters['relay-host'])->where('port',(string)$parameters['relay-port'])->exists() || + ServerGroup::where('name','dpl 接入验收 '.$parameters['key'])->exists()) { + throw new RuntimeException('Existing resource/relay tuple without matching manifest; refusing takeover'); + } + DplOnboarding\writeJson($dir.'/before.json',[ + 'server_ids'=>Server::orderBy('id')->pluck('id')->all(), + 'machine_ids'=>ServerMachine::orderBy('id')->pluck('id')->all(), + 'group_ids'=>ServerGroup::orderBy('id')->pluck('id')->all(), + ]); + $group=new ServerGroup();$group->name='dpl 接入验收 '.$parameters['key'];$group->save(); + $machine=ServerMachine::create(['name'=>'dpl-'.$parameters['key'], + 'notes'=>'Independent AnyTLS service; existing XrayR preserved','is_active'=>true,'token'=>ServerMachine::generateToken()]); + $node=Server::create([ + 'name'=>$parameters['name'],'type'=>'anytls','host'=>$parameters['relay-host'], + 'port'=>(string)$parameters['relay-port'],'server_port'=>(string)$parameters['listen-port'], + 'group_ids'=>[(string)$group->id],'route_ids'=>[],'tags'=>[], + 'rate'=>1,'show'=>true,'enabled'=>true,'machine_id'=>$machine->id,'sort'=>1, + 'protocol_settings'=>['tls'=>['server_name'=>$parameters['tls-domain'],'allow_insecure'=>false]], + 'cert_config'=>['cert_mode'=>'file','domain'=>$parameters['tls-domain'], + 'cert_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/fullchain.pem', + 'key_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/privkey.pem'], + ]); + $qa=new User([ + 'email'=>'node-qa-'.$parameters['key'].'-'.Str::lower(Str::random(8)).'@example.invalid', + 'password'=>password_hash(Str::random(40),PASSWORD_BCRYPT),'uuid'=>(string)Str::uuid(),'token'=>Str::random(32), + 'group_id'=>$group->id,'transfer_enable'=>104857600,'expired_at'=>time()+86400,'banned'=>0,'is_admin'=>0, + 'remind_expire'=>0,'remind_traffic'=>0,'device_limit'=>2, + ]);$qa->saveQuietly(); + $m=['key'=>$parameters['key'],'node_id'=>$node->id,'machine_id'=>$machine->id,'group_id'=>$group->id, + 'test_user_id'=>$qa->id,'parameters'=>$parameters]; + // Save identities before commit. A failed/unknown commit must be inspected, + // never repeated into new resources merely because a response was lost. + DplOnboarding\writeJson($path,$m); + return $m; + },1); + }); + // Recovery can regenerate missing transport files from the same staged objects. + $machine=ServerMachine::findOrFail($m['machine_id']);$qa=User::findOrFail($m['test_user_id']); + $panel=json_encode($parameters['panel-url'],JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES); + $config="panel:\n url: $panel\nmachine:\n machine_id: {$machine->id}\n token_env: DPL_MACHINE_TOKEN\nkernel:\n type: singbox\n config_dir: /var/lib/dpl-node/kernel\nlog:\n level: info\nruntime:\n gomemlimit: 384MiB\n gogc: 75\nnode:\n push_interval: 30\n pull_interval: 30\n"; + foreach (['node-config.yml'=>$config,'node-secret.env'=>'DPL_MACHINE_TOKEN='.$machine->token."\n"] as $file=>$body) { + $path=$dir.'/'.$file; + if (is_link($path)) throw new RuntimeException('Refusing private transport symlink'); + $tmp=tempnam($dir,'.private-');if ($tmp===false) throw new RuntimeException('Private file failed'); + chmod($tmp,0600); + if (file_put_contents($tmp,$body)!==strlen($body) || !rename($tmp,$path)) { + if (is_file($tmp)) unlink($tmp); + throw new RuntimeException('Private file failed'); + } + } + DplOnboarding\writeJson($dir.'/qa-private.json',['user_id'=>$qa->id,'uuid'=>$qa->uuid,'subscribe_token'=>$qa->token, + 'host'=>$parameters['relay-host'],'port'=>$parameters['relay-port'],'sni'=>$parameters['tls-domain']]); + echo json_encode(['key'=>$m['key'],'node_id'=>$m['node_id'],'machine_id'=>$m['machine_id'], + 'group_id'=>$m['group_id'],'test_user_id'=>$m['test_user_id']],JSON_THROW_ON_ERROR).PHP_EOL; +} catch (Throwable $e) { + $reason=get_class($e)===RuntimeException::class ? $e->getMessage() : 'Inspect manifest/DB state before retrying'; + fwrite(STDERR,'Staging stopped: '.$reason.PHP_EOL);exit(1); } -file_put_contents("$dir/before.json", json_encode([ - 'servers' => DB::table('v2_server')->get(), - 'groups' => DB::table('v2_server_group')->get(), - 'machines' => DB::table('v2_server_machine')->get(), -], JSON_THROW_ON_ERROR)); - -$manifest = DB::transaction(function () use ($opts) { - $group = new ServerGroup(); - $group->name = 'dpl 接入验收 ' . $opts['key']; - $group->save(); - $machine = ServerMachine::create([ - 'name' => 'dpl-' . $opts['key'], 'notes' => 'Independent AnyTLS service; existing XrayR preserved', - 'is_active' => true, 'token' => ServerMachine::generateToken(), - ]); - $node = Server::create([ - 'name' => $opts['name'], 'type' => 'anytls', 'host' => $opts['relay-host'], - 'port' => (string)$opts['relay-port'], 'server_port' => (string)$opts['listen-port'], - 'group_ids' => [(string)$group->id], 'route_ids' => [], 'tags' => [], - 'rate' => 1, 'show' => true, 'enabled' => true, 'machine_id' => $machine->id, 'sort' => 1, - 'protocol_settings' => ['tls' => ['server_name' => $opts['tls-domain'], 'allow_insecure' => false]], - 'cert_config' => [ - 'cert_mode' => 'file', 'domain' => $opts['tls-domain'], - 'cert_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/fullchain.pem', - 'key_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/privkey.pem', - ], - ]); - $user = new User([ - 'email' => 'node-qa-' . $opts['key'] . '-' . Str::lower(Str::random(8)) . '@example.invalid', - 'password' => password_hash(Str::random(40), PASSWORD_BCRYPT), - 'uuid' => (string)Str::uuid(), 'token' => Str::random(32), - 'group_id' => $group->id, 'transfer_enable' => 104857600, - 'expired_at' => time() + 86400, 'banned' => 0, 'is_admin' => 0, - 'remind_expire' => 0, 'remind_traffic' => 0, 'device_limit' => 2, - ]); - $user->saveQuietly(); - return ['key' => $opts['key'], 'node_id' => $node->id, 'machine_id' => $machine->id, - 'group_id' => $group->id, 'test_user_id' => $user->id]; -}); -file_put_contents("$dir/manifest.json", json_encode($manifest, JSON_THROW_ON_ERROR)); -$machine = ServerMachine::findOrFail($manifest['machine_id']); -$user = User::findOrFail($manifest['test_user_id']); -$config = "panel:\n url: \"https://dp-link.net\"\nmachine:\n machine_id: {$machine->id}\n token_env: DPL_MACHINE_TOKEN\nkernel:\n type: singbox\n config_dir: /var/lib/dpl-node/kernel\nlog:\n level: info\nruntime:\n gomemlimit: 384MiB\n gogc: 75\nnode:\n push_interval: 30\n pull_interval: 30\n"; -file_put_contents("$dir/node-config.yml", $config); -file_put_contents("$dir/node-secret.env", 'DPL_MACHINE_TOKEN=' . $machine->token . "\n"); -file_put_contents("$dir/qa-private.json", json_encode([ - 'user_id' => $user->id, 'uuid' => $user->uuid, 'subscribe_token' => $user->token, - 'host' => $opts['relay-host'], 'port' => (int)$opts['relay-port'], 'sni' => $opts['tls-domain'], -], JSON_THROW_ON_ERROR)); -echo json_encode($manifest, JSON_UNESCAPED_UNICODE) . PHP_EOL; diff --git a/scripts/node-onboarding/verify-node.py b/scripts/node-onboarding/verify-node.py index 0c99d86..e08a948 100644 --- a/scripts/node-onboarding/verify-node.py +++ b/scripts/node-onboarding/verify-node.py @@ -1,72 +1,240 @@ #!/usr/bin/env python3 -"""Run an isolated SOCKS client with strict TLS; never print QA credentials.""" +"""Validate private subscriptions; network execution requires explicit --run. + +For a formal multi-node subscription use a private --expectations JSON array: +[{"tag": "exact outbound tag", "host": "relay.example", "port": 12345, + "sni": "node.example", "uuid": "private password", "expected_exit": "192.0.2.1", + "expected_country": "JP"}]. Never print credentials or the subscription. +""" import argparse +import copy +import ipaddress import json import os -import pathlib +from pathlib import Path import shutil import socket import subprocess import tempfile import time -p = argparse.ArgumentParser() -p.add_argument('--qa', required=True) -p.add_argument('--binary', required=True) -p.add_argument('--expected-exit', required=True) -p.add_argument('--port', type=int, default=19080) -p.add_argument('--subscription') -a = p.parse_args() -q = json.loads(pathlib.Path(a.qa).read_text()) -c = {'log': {'level': 'error'}, 'dns': {'servers': [{'type': 'local', 'tag': 'local'}]}, - 'inbounds': [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': a.port}], - 'outbounds': [{'type': 'anytls', 'tag': 'qa', 'server': q['host'], 'server_port': q['port'], - 'password': q['uuid'], 'tls': {'enabled': True, 'server_name': q['sni'], 'insecure': False}}], - 'route': {'default_domain_resolver': 'local', 'final': 'qa'}} -if a.subscription: - c = json.loads(pathlib.Path(a.subscription).read_text()) - nodes = [x for x in c['outbounds'] if x.get('type') == 'anytls'] - assert len(nodes) == 1 and nodes[0]['tls'].get('insecure') is False - assert nodes[0]['server'] == q['host'] and nodes[0]['server_port'] == q['port'] - assert nodes[0]['password'] == q['uuid'] - c['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': a.port}] - c['log'] = {'level': 'error'} -os.umask(0o077) -with tempfile.TemporaryDirectory(prefix='dpl-client-') as d: - config = pathlib.Path(d) / 'client.json' - config.write_text(json.dumps(c)) - env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75') - with (pathlib.Path(d) / 'client.log').open('wb') as log: - proc = subprocess.Popen([a.binary, 'run', '-c', str(config)], cwd=d, env=env, stdout=log, stderr=log) + +class VerificationError(Exception): + pass + + +def require(condition, message): + if not condition: + raise VerificationError(message) + + +def read_private(path): + p = Path(path) + require(p.is_file() and not p.is_symlink(), 'Private input must be a regular file') + require(p.stat().st_mode & 0o077 == 0, 'Private input must have no group/other permissions') + return json.loads(p.read_text()) + + +def validate(config, expectations, exact=True): + require(isinstance(config, dict) and isinstance(config.get('outbounds'), list), 'Missing outbounds') + all_tags = [o.get('tag') for o in config['outbounds'] if isinstance(o, dict)] + require(len(all_tags) == len(config['outbounds']) and all(isinstance(t, str) and t for t in all_tags), 'Each outbound needs a tag') + require(len(set(all_tags)) == len(all_tags), 'Duplicate outbound tags') + nodes = {o['tag']: o for o in config['outbounds'] if o.get('type') == 'anytls'} + require(isinstance(expectations, list) and expectations, 'Nonempty expectations required') + selected = [] + for spec in expectations: + require(isinstance(spec, dict), 'Invalid expectation') + for key in ['tag', 'host', 'sni', 'uuid', 'expected_exit', 'expected_country']: + require(isinstance(spec.get(key), str) and bool(spec[key]), 'Expectation missing required field') + require(isinstance(spec.get('port'), int) and not isinstance(spec['port'], bool) and 1 <= spec['port'] <= 65535, 'Invalid relay port') try: - for _ in range(50): - assert proc.poll() is None, 'Client exited during startup' - try: - with socket.create_connection(('127.0.0.1', a.port), 0.1): - break - except OSError: - time.sleep(0.1) - command = ['curl', '-q', '--fail', '--silent', '--show-error', '--max-time', '30', - '--socks5-hostname', f'127.0.0.1:{a.port}'] - result = subprocess.run(command + ['https://api.ipify.org'], capture_output=True, text=True, check=True) - ip = result.stdout.strip() - assert ip == a.expected_exit, 'Unexpected exit IP' - result = subprocess.run(command + ['https://www.cloudflare.com/cdn-cgi/trace'], capture_output=True, text=True, check=True) - trace = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line) - assert trace.get('ip') == a.expected_exit, 'Second exit check mismatch' - print(json.dumps({'anytls': True, 'relay': q['host'], 'port': q['port'], 'strict_tls': True, - 'exit_ip': ip, 'country': trace.get('loc'), 'https_tls': trace.get('tls')}, ensure_ascii=False)) + ipaddress.ip_address(spec['expected_exit']) + except ValueError as exc: + raise VerificationError('Invalid expected exit IP') from exc + require(len(spec['expected_country']) == 2 and spec['expected_country'].isalpha() and spec['expected_country'].isupper(), 'Expected country must be two uppercase letters') + tag = spec['tag'] + require(tag in nodes and tag not in selected, 'Missing/duplicate selected node') + node = nodes[tag] + tls = node.get('tls') or {} + require(tls.get('enabled') is True and tls.get('insecure') is False, 'Strict TLS must be explicit') + require(node.get('server') == spec['host'] and node.get('server_port') == spec['port'], 'Relay endpoint mismatch') + require(tls.get('server_name') == spec['sni'], 'SNI mismatch') + require(node.get('password') == spec['uuid'], 'Subscription password mismatch') + selected.append(tag) + if exact: + require(set(selected) == set(nodes), 'Expectations must cover every AnyTLS node exactly') + return selected + + +def probe_config(config, spec, port): + result = copy.deepcopy(config) + result['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': port}] + result['log'] = {'level': 'error'} + # Preserve DNS, remote rulesets and all original outbounds. Only the two + # diagnostic destinations are pinned; ordinary subscription routing remains. + route = result.setdefault('route', {}) + rules = route.setdefault('rules', []) + require(isinstance(rules, list), 'Invalid route rules') + rules.insert(0, {'domain': ['api.ipify.org', 'www.cloudflare.com'], + 'action': 'route', 'outbound': spec['tag']}) + return result + + +def stop(proc): + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def run_node(config, spec, binary, port, diagnostic_dir): + require(1024 <= port <= 65535, 'Invalid local SOCKS port') + with socket.socket() as free: + try: + free.bind(('127.0.0.1', port)) + except OSError as exc: + raise VerificationError('Local SOCKS port is occupied') from exc + with tempfile.TemporaryDirectory(prefix='dpl-client-', dir=diagnostic_dir) as tmp: + temporary = Path(tmp) + path = temporary / 'client.json' + path.write_text(json.dumps(probe_config(config, spec, port))) + path.chmod(0o600) + env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75') + proc = None + log_path = temporary / 'client.log' + try: + with log_path.open('wb') as log: + subprocess.run([binary, 'check', '-c', str(path)], env=env, + cwd=tmp, stdout=log, stderr=log, check=True, timeout=30) + proc = subprocess.Popen([binary, 'run', '-c', str(path)], env=env, + cwd=tmp, stdout=log, stderr=log) + ready = False + for _ in range(100): + require(proc.poll() is None, 'Client exited during startup') + try: + with socket.create_connection(('127.0.0.1', port), 0.1): + ready = True + break + except OSError: + time.sleep(0.1) + require(ready, 'Client startup timed out') + command = ['curl', '-q', '--fail', '--silent', '--show-error', '--max-time', '30', + '--socks5-hostname', f'127.0.0.1:{port}', '--noproxy', ''] + ip = subprocess.run(command + ['https://api.ipify.org'], capture_output=True, + text=True, check=True, timeout=35).stdout.strip() + require(ipaddress.ip_address(ip) == ipaddress.ip_address(spec['expected_exit']), 'Unexpected exit IP') + response = subprocess.run(command + ['https://www.cloudflare.com/cdn-cgi/trace'], + capture_output=True, text=True, check=True, timeout=35) + trace = dict(line.split('=', 1) for line in response.stdout.splitlines() if '=' in line) + require(trace.get('ip') == ip, 'Second exit check mismatch') + require(trace.get('loc') == spec['expected_country'], 'Exit country mismatch') + require(trace.get('tls', '').startswith('TLSv'), 'Target HTTPS TLS not observed') + return {'tag': spec['tag'], 'strict_tls': True, 'exit_ip': ip, + 'country': trace['loc'], 'https_tls': trace['tls'], 'live': True, + 'relay': spec['host'], 'relay_port': spec['port'], 'sni': spec['sni']} except Exception: - log.flush() - failure = pathlib.Path(a.qa).parent / 'client-failure.log' - shutil.copyfile(pathlib.Path(d) / 'client.log', failure) - failure.chmod(0o600) - print(f'Client failed; protected diagnostic log: {failure}') + # Logs may contain private config context; retain only in private task directory. + stop(proc) + if log_path.exists(): + fd, name = tempfile.mkstemp(prefix='client-failure-', suffix='.log', dir=diagnostic_dir) + os.close(fd) + failure = Path(name) + shutil.copyfile(log_path, failure) + failure.chmod(0o600) raise finally: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + stop(proc) + + +def write_report(path, report): + destination = Path(path) + require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0, + 'Report directory must be private') + require(not destination.is_symlink(), 'Refusing report symlink') + with tempfile.NamedTemporaryFile(mode='w', dir=destination.parent, delete=False) as stream: + temporary = Path(stream.name) + try: + os.chmod(temporary, 0o600) + json.dump(report, stream, ensure_ascii=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + finally: + if temporary.exists(): + temporary.unlink() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sources = parser.add_mutually_exclusive_group(required=True) + sources.add_argument('--qa') + sources.add_argument('--expectations') + parser.add_argument('--subscription', required=True) + parser.add_argument('--binary') + parser.add_argument('--expected-exit') + parser.add_argument('--expected-country') + parser.add_argument('--target-tag') + parser.add_argument('--report', help='Private JSON result file; required for --run') + parser.add_argument('--port', type=int, default=19080) + execution = parser.add_mutually_exclusive_group() + execution.add_argument('--run', action='store_true') + execution.add_argument('--validate-only', action='store_true') + args = parser.parse_args() + os.umask(0o077) + try: + require(not args.run or args.report, '--run requires a private --report destination') + if args.report: + destination = Path(args.report) + inputs = [args.subscription, args.qa, args.expectations] + require(all(destination.resolve() != Path(item).resolve() for item in inputs if item), 'Report must not overwrite a private input') + require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0 and not destination.is_symlink(), 'Private regular report destination required') + if args.report: + write_report(args.report, {'state': 'verification_pending', 'verified_at': None}) + config = read_private(args.subscription) + qa_user_id = None + source = args.expectations or args.qa + if args.expectations: + require(not any([args.expected_exit, args.expected_country, args.target_tag]), 'Put each node expectation in the private expectations file') + specs = read_private(args.expectations) + else: + qa = read_private(args.qa) + qa_user_id = qa.get('user_id') + require(args.expected_exit and args.expected_country, 'QA requires explicit expected-exit and expected-country') + candidates = [n for n in config.get('outbounds', []) if n.get('type') == 'anytls' + and n.get('server') == qa.get('host') and n.get('server_port') == qa.get('port') + and (not args.target_tag or n.get('tag') == args.target_tag)] + require(len(candidates) == 1, 'QA target ambiguous/missing; supply exact target-tag') + specs = [{'tag': candidates[0]['tag'], 'host': qa.get('host'), 'port': qa.get('port'), + 'sni': qa.get('sni'), 'uuid': qa.get('uuid'), 'expected_exit': args.expected_exit, + 'expected_country': args.expected_country}] + tags = validate(config, specs, exact=bool(args.expectations)) + results = [] + if args.run: + require(args.binary and Path(args.binary).is_file(), 'Explicit existing client binary required') + binary = str(Path(args.binary).resolve()) + folder = Path(source).resolve().parent + require(folder.stat().st_mode & 0o077 == 0, 'Live diagnostics directory must be private') + for spec in specs: + results.append(run_node(config, spec, binary, args.port, str(folder))) + report = {'state': 'live_verified' if args.run else 'structure_validated', + 'verified_at': int(time.time()) if args.run else None, + 'qa_user_id': qa_user_id, 'selected_nodes': tags, 'results': results, + 'scope': 'Full subscription retained; only diagnostic destination routes pinned. No traffic/account/GUI acceptance implied.'} + if args.report: + write_report(args.report, report) + print(json.dumps(report, ensure_ascii=False)) + return 0 + except (Exception, KeyboardInterrupt) as exc: + # No traceback or subprocess stderr: either can contain credentials. + message = str(exc) if isinstance(exc, VerificationError) else 'Verification failed; inspect protected diagnostics' + print(json.dumps({'state': 'failed', 'reason': message}), file=__import__('sys').stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/node-onboarding/PanelChangeIsolatedTest.php b/tests/node-onboarding/PanelChangeIsolatedTest.php new file mode 100644 index 0000000..d098fd0 --- /dev/null +++ b/tests/node-onboarding/PanelChangeIsolatedTest.php @@ -0,0 +1,116 @@ +instance('config',new \Illuminate\Config\Repository(['cache'=>['default'=>'array','stores'=>['array'=>['driver'=>'array']]],'app'=>['key'=>'test']])); + $events=new Dispatcher($app); $app->instance('events',$events); + $this->db=new Capsule($app); $this->db->addConnection(['driver'=>'sqlite','database'=>':memory:','foreign_key_constraints'=>true]); + $this->db->setEventDispatcher($events); $this->db->setAsGlobal(); $this->db->bootEloquent(); + $app->instance('db',$this->db->getDatabaseManager()); + $app->singleton('cache',fn($app)=>new \Illuminate\Cache\CacheManager($app)); + $schema=$this->db->schema(); + $schema->create('v2_server',function($t){$t->increments('id');$t->string('name');$t->string('type');$t->integer('machine_id');$t->text('group_ids');$t->text('protocol_settings');$t->string('host');$t->string('port');$t->boolean('enabled');$t->boolean('show');$t->timestamps();}); + $schema->create('v2_user',function($t){$t->increments('id');$t->string('email');$t->boolean('is_admin')->default(false);$t->integer('group_id')->nullable();$t->integer('plan_id')->nullable();$t->integer('u')->default(0);$t->integer('d')->default(0);$t->boolean('banned')->default(false);$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();}); + $schema->create('v2_server_group',function($t){$t->increments('id');$t->string('name');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();}); + $schema->create('v2_plan',function($t){$t->increments('id');$t->integer('group_id')->nullable();$t->string('name');$t->integer('price');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();}); + $schema->create('v2_settings',function($t){$t->increments('id');$t->string('name')->unique();$t->text('value')->nullable();$t->timestamps();}); + Capsule::table('v2_server_group')->insert([['id'=>1,'name'=>'formal'],['id'=>2,'name'=>'qa']]); + Capsule::table('v2_plan')->insert(['id'=>1,'name'=>'protected-plan','price'=>1234,'group_id'=>1]); + Capsule::table('v2_user')->insert([['id'=>1,'email'=>'node-qa-test-key-a@example.invalid','group_id'=>2,'u'=>10,'d'=>20],['id'=>2,'email'=>'formal@example.invalid','group_id'=>1,'u'=>200,'d'=>300]]); + Capsule::table('v2_settings')->insert(['name'=>'subscribe_url','value'=>'https://original.example.invalid']); + foreach ([1,2] as $id) Server::create(['id'=>$id,'name'=>'node-'.$id,'type'=>'anytls','machine_id'=>$id,'group_ids'=>[(string)$id], 'host'=>'relay.example.invalid','port'=>(string)(47000+$id),'enabled'=>true,'show'=>true,'protocol_settings'=>['tls'=>['server_name'=>'node.example.invalid','allow_insecure'=>false]]]); + // Guarded IDs auto-increment to 1/2, matching the manifest. + \Illuminate\Support\Facades\Cache::put(\App\Utils\CacheKey::get('SERVER_ANYTLS_LAST_CHECK_AT',2),time()); + $this->dir=sys_get_temp_dir().'/dpl-isolated-'.bin2hex(random_bytes(8)); mkdir($this->dir,0700); + \DplOnboarding\writeJson($this->dir.'/manifest.json',['key'=>'test-key','node_id'=>2,'machine_id'=>2,'group_id'=>2,'test_user_id'=>1]); + $this->request=['mode'=>'append','group'=>'1','verification'=>$this->dir.'/report.json']; + $this->evidence(); NodeSyncService::$calls=[]; NodeSyncService::$fail=false; + } + private function evidence(array $overrides=[]): void { + \DplOnboarding\writeJson($this->dir.'/report.json',array_replace(['state'=>'live_verified','qa_user_id'=>1,'verified_at'=>time(),'results'=>[['live'=>true,'strict_tls'=>true,'relay'=>'relay.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]],$overrides)); + } + protected function tearDown(): void { foreach(glob($this->dir.'/*') as $p) unlink($p);rmdir($this->dir);$this->db->getDatabaseManager()->disconnect();NodeSyncService::$fail=false; } + private function snapshot(): array { $all=[];foreach(['v2_server','v2_user','v2_plan','v2_settings','v2_server_group'] as $t)$all[$t]=Capsule::table($t)->orderBy('id')->get()->toJson();return $all; } + private function protectedSnapshot(): array { return [Capsule::table('v2_server')->where('id',1)->first(),Capsule::table('v2_user')->where('id',2)->first(),Capsule::table('v2_plan')->get()->toJson(),Capsule::table('v2_settings')->get()->toJson(),Capsule::table('v2_server_group')->get()->toJson()]; } + private function rejects(callable $f): void { $rejected=false;try{$f();}catch(\RuntimeException $e){$rejected=true;}self::assertTrue($rejected,'Expected safe refusal'); } + public function testPreviewAppendIdempotenceAndRollback(): void { + $before=$this->snapshot();$protected=$this->protectedSnapshot(); + self::assertSame('preview',PanelChange::promote($this->dir,$this->request,false)['state']);self::assertSame($before,$this->snapshot());self::assertFileDoesNotExist($this->dir.'/promotion-journal.json'); + self::assertSame('applied',PanelChange::promote($this->dir,$this->request,true)['state']);self::assertSame(['1'],Server::find(2)->group_ids);self::assertTrue((bool)User::find(1)->banned);self::assertEquals($protected,$this->protectedSnapshot()); + $after=$this->snapshot();PanelChange::promote($this->dir,$this->request,true);self::assertSame($after,$this->snapshot()); + self::assertSame('preview',PanelChange::rollback($this->dir,false)['state']);self::assertSame($after,$this->snapshot()); + PanelChange::rollback($this->dir,true);self::assertSame(['2'],Server::find(2)->group_ids);self::assertFalse(Server::find(2)->enabled);self::assertFalse(Server::find(2)->show);self::assertTrue((bool)User::find(1)->banned);self::assertEquals($protected,$this->protectedSnapshot()); + $rolled=$this->snapshot();PanelChange::rollback($this->dir,true);self::assertSame($rolled,$this->snapshot()); + } + public function testAppendForbidsSharedWritesAndBadEvidence(): void { + $before=$this->snapshot();foreach(['plans'=>'1','subscribe-url'=>'https://bad.example.invalid','group-name'=>'bad'] as $key=>$value){$r=$this->request;$r[$key]=$value;$this->rejects(fn()=>PanelChange::promote($this->dir,$r,true));} + foreach([['state'=>'structure_validated'],['qa_user_id'=>2],['verified_at'=>time()-901],['verified_at'=>time()+60],['results'=>[]],['results'=>[['live'=>true,'strict_tls'=>true,'relay'=>'wrong.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]],['results'=>[['live'=>true,'strict_tls'=>false,'relay'=>'relay.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]]] as $bad){$this->evidence($bad);$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot());} + } + public function testTransactionFailurePreparedBeforeRecovers(): void { + $before=$this->snapshot();$once=true; + Server::updating(function()use(&$once){if($once){$once=false;throw new \RuntimeException('Injected transaction failure');}}); + $this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot());self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']); + PanelChange::promote($this->dir,$this->request,true);self::assertTrue((bool)User::find(1)->banned); + } + public function testCommittedNotificationFailureRecoversWithoutReactivation(): void { + NodeSyncService::$fail=true;$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertTrue((bool)User::find(1)->banned);self::assertSame(['1'],Server::find(2)->group_ids); + self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']);NodeSyncService::$fail=false; + $after=$this->snapshot();PanelChange::promote($this->dir,$this->request,true);self::assertSame($after,$this->snapshot());self::assertSame('applied',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']); + } + public function testRollbackConflictDoesNotOverwriteLaterChange(): void { + PanelChange::promote($this->dir,$this->request,true);Capsule::table('v2_server')->where('id',2)->update(['group_ids'=>'["3"]']);$before=$this->snapshot(); + $this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot());self::assertTrue((bool)User::find(1)->banned); + } + public function testPreparedMixedStateCannotResume(): void { + NodeSyncService::$fail=true;$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));NodeSyncService::$fail=false; + Capsule::table('v2_user')->where('id',1)->update(['banned'=>0]);$before=$this->snapshot();$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot()); + } + public function testDifferentRequestAndRollbackPreventRepromotion(): void { + PanelChange::promote($this->dir,$this->request,true);$before=$this->snapshot();$r=$this->request;$r['group']='3'; + $this->rejects(fn()=>PanelChange::promote($this->dir,$r,true));self::assertSame($before,$this->snapshot()); + PanelChange::rollback($this->dir,true);$rolled=$this->snapshot();$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($rolled,$this->snapshot());self::assertTrue((bool)User::find(1)->banned); + } + public function testRollbackNotificationFailureRecoversAfterState(): void { + PanelChange::promote($this->dir,$this->request,true);NodeSyncService::$fail=true; + $this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/rollback-journal.json')['state']);self::assertFalse(Server::find(2)->enabled);self::assertTrue((bool)User::find(1)->banned); + $rolled=$this->snapshot();NodeSyncService::$fail=false;PanelChange::rollback($this->dir,true);self::assertSame($rolled,$this->snapshot());self::assertSame('rolled_back',\DplOnboarding\readJson($this->dir.'/rollback-journal.json')['state']); + } + public function testQaReactivationAndContainmentChangeBlockRollback(): void { + PanelChange::promote($this->dir,$this->request,true);Capsule::table('v2_user')->where('id',1)->update(['banned'=>0]);$before=$this->snapshot(); + $this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot()); + Capsule::table('v2_user')->where('id',1)->update(['banned'=>1]);Capsule::table('v2_server')->where('id',2)->update(['enabled'=>false]);$before=$this->snapshot(); + $this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot());self::assertTrue((bool)User::find(1)->banned); + } +} +} diff --git a/tests/node-onboarding/test_batch.py b/tests/node-onboarding/test_batch.py new file mode 100644 index 0000000..837be56 --- /dev/null +++ b/tests/node-onboarding/test_batch.py @@ -0,0 +1,202 @@ +"""Offline process-level tests. No Laravel bootstrap, .env, SSH or network.""" +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +RUNNER = ROOT / 'scripts/node-onboarding/batch.py' +ADAPTER = ROOT / 'scripts/node-onboarding/command-adapter.py' +spec = importlib.util.spec_from_file_location('dpl_batch', RUNNER) +batch = importlib.util.module_from_spec(spec) +spec.loader.exec_module(batch) + +FAKE = r''' +import argparse, json, os, sys, time +from pathlib import Path +p=argparse.ArgumentParser() +for x in ('plan','node','phase','action','state-dir'): p.add_argument('--'+x,required=True) +a=p.parse_args(); n=json.loads(Path(a.node).read_text()); plan=json.loads(Path(a.plan).read_text()) +root=Path(plan['fixture_root']); root.mkdir(exist_ok=True) +identity=dict(key=n['key'], phase=a.phase, request_sha256=n['request_sha256']) +def emit(status, **kw): print(json.dumps(dict(identity,status=status,**kw))) +if plan.get('bad_receipt'): identity['request_sha256']='wrong' +if a.phase=='preflight': + emit('waiting' if plan.get('block_preflight') and n['key']=='jp2' else 'ready'); sys.exit() +if a.phase in ('verify','audit'): emit('ready'); sys.exit() +marker=root/(n['key']+'-'+a.phase) +if a.action=='probe': + emit('ready' if marker.exists() else 'absent',retry_safe=True); sys.exit() +def log(event): + fd=os.open(root/'events',os.O_CREAT|os.O_WRONLY|os.O_APPEND,0o600) + os.write(fd,(json.dumps(dict(key=n['key'],phase=a.phase,event=event,t=time.monotonic()))+'\n').encode());os.close(fd) +log('start'); time.sleep(1.2 if a.phase=='provision' else .12); marker.write_text('owned'); log('end') +if plan.get('crash') and a.phase=='provision' and not (root/'crashed').exists(): + (root/'crashed').write_text('yes'); sys.exit(8) +emit('ready') +''' + + +class BatchTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.fake = self.root / 'fake.py' + self.fake.write_text(FAKE) + self.state = self.root / 'state' + self.plan = { + 'version': 1, 'mode': 'append', 'batch_id': 'offline-test', + 'context': {'panel_vps': 'VPS-001', 'gateway_vps': 'VPS-002', + 'jump_vps': 'VPS-003', 'app_root': '/app', + 'panel_url': 'https://panel.example.invalid', + 'subscription_url': 'https://sub.example.invalid', + 'provision_commit': 'a' * 40, 'bundle': '/private/bundle', + 'client_binary': '/private/sing-box'}, + 'adapter': [sys.executable, str(self.fake)], + 'fixture_root': str(self.root / 'remote'), 'nodes': [self.node(1)], + } + self.path = self.root / 'plan.json' + + def tearDown(self): + self.tmp.cleanup() + + def node(self, i): + return dict(key=f'jp{i}', country='JP', sequence=i, flag='🇯🇵', country_name='日本', + name=f'🇯🇵 日本 JP - {i:02d}', vps_id=f'VPS-{10+i:03d}', + node_address=f'192.0.2.{i}', listen_port=24443, relay_host='relay.example.invalid', + relay_port=47000+i, tls_domain=f'jp{i}.example.invalid', formal_group_id=1, + expected_exit=f'192.0.2.{i}', socks_port=19000+i) + + def argv(self, command='run'): + self.path.write_text(json.dumps(self.plan, ensure_ascii=False)) + return [sys.executable, str(RUNNER), command, '--plan', str(self.path), + '--state-root', str(self.state), '--jobs', '3'] + (['--execute'] if command == 'run' else []) + + def run_cli(self, command='run', expected=0): + p = subprocess.run(self.argv(command), capture_output=True, text=True, timeout=30) + self.assertEqual(p.returncode, expected, p.stdout + p.stderr) + return json.loads(p.stdout) + + def events(self): + path = self.root / 'remote/events' + return [json.loads(x) for x in path.read_text().splitlines()] if path.exists() else [] + + def test_plan_and_status_are_read_only(self): + self.run_cli('plan'); self.run_cli('status') + self.assertFalse(self.state.exists()) + self.assertFalse((self.root/'remote').exists()) + + def test_duplicate_parameters_and_naming_rejected(self): + for field in ('key','name','vps_id','node_address','tls_domain','relay_port','socks_port','sequence'): + with self.subTest(field=field): + other = self.node(2); other[field] = self.plan['nodes'][0][field] + p = copy.deepcopy(self.plan); p['nodes'].append(other) + with self.assertRaises(ValueError): batch.validate(p) + p = copy.deepcopy(self.plan); p['nodes'][0]['name'] = '🇯🇵 日本 JP - 11' + with self.assertRaises(ValueError): batch.validate(p) + + def test_preflight_barrier_blocks_every_write(self): + self.plan['nodes'].append(self.node(2)); self.plan['block_preflight'] = True + self.run_cli(expected=2) + self.assertEqual(self.events(), []) + + def test_parallel_nodes_serial_panel_and_resume(self): + self.plan['nodes'] = [self.node(i) for i in (1,2,3)] + self.run_cli() + events = self.events() + for phase, expected_peak in [('stage',1), ('publish',1), ('provision',None)]: + active = peak = 0 + for e in sorted((e for e in events if e['phase']==phase), key=lambda e:e['t']): + active += 1 if e['event']=='start' else -1; peak=max(peak,active) + if expected_peak is None: + self.assertGreater(peak,1,'Independent nodes should overlap') + self.assertLessEqual(peak,3) + else: + self.assertEqual(peak, expected_peak, phase) + self.run_cli() + self.assertEqual(self.events(), events, 'A completed retry must not write again') + status = self.run_cli('status') + self.assertTrue(all(n['audit']=='done' for n in status.values())) + self.assertEqual(self.state.stat().st_mode & 0o777, 0o700) + self.assertEqual((self.state/'registry.sqlite3').stat().st_mode & 0o777, 0o600) + + def test_lost_response_recovers_by_probe_without_duplicate(self): + self.plan['crash'] = True + self.run_cli(expected=2) + self.run_cli() + writes = [e for e in self.events() if e['phase']=='provision' and e['event']=='start'] + self.assertEqual(len(writes),1) + self.assertEqual(self.run_cli('status')['jp1']['audit'],'done') + + def test_cross_batch_and_plan_drift_rejected_before_commands(self): + self.run_cli(); events = self.events() + self.plan['nodes'][0]['expected_exit'] = '192.0.2.99' + self.run_cli(expected=2) + self.plan['batch_id'] = 'other-batch' + self.run_cli(expected=2) + self.assertEqual(self.events(), events) + + def test_two_processes_share_locks_and_do_not_duplicate(self): + argv = self.argv() + a = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + b = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for process in (a,b): + out, err = process.communicate(timeout=30) + self.assertEqual(process.returncode,0,out+err) + stages = [e for e in self.events() if e['phase']=='stage' and e['event']=='start'] + self.assertEqual(len(stages),1) + + def test_invalid_receipt_cannot_authorize_write(self): + self.plan['bad_receipt'] = True + self.run_cli(expected=2) + self.assertEqual(self.events(), []) + + def test_interrupted_apply_records_unknown_and_can_resume(self): + p = subprocess.Popen(self.argv(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 10 + while not any(e['phase']=='provision' and e['event']=='start' for e in self.events()): + self.assertIsNone(p.poll()) + self.assertLess(time.monotonic(), deadline) + time.sleep(.02) + p.send_signal(signal.SIGTERM) + out, err = p.communicate(timeout=10) + self.assertEqual(p.returncode,2,out+err) + self.assertEqual(self.run_cli('status')['jp1']['provision'],'unknown') + self.run_cli() + self.assertEqual(self.run_cli('status')['jp1']['audit'],'done') + finally: + if p.poll() is None: + p.kill(); p.communicate() + + def test_configured_command_adapter_runs_full_protocol(self): + self.plan['adapter'] = [sys.executable,str(ADAPTER)] + command = [sys.executable,str(self.fake),'--plan','{plan}','--node','{node}', + '--phase','{phase}','--action','{action}','--state-dir','{state_dir}'] + self.plan['command_profile'] = { + 'files': {str(self.fake): hashlib.sha256(self.fake.read_bytes()).hexdigest()}, + 'phases': {phase: {'probe': command, 'apply': command} + for phase in ('preflight',) + batch.PHASES}} + self.run_cli() + self.assertEqual(self.run_cli('status')['jp1']['audit'],'done') + + def test_command_adapter_pins_handlers_and_missing_steps_wait(self): + self.plan['adapter'] = [sys.executable,str(ADAPTER)] + self.plan['command_profile'] = {'files': {str(self.fake): hashlib.sha256(self.fake.read_bytes()).hexdigest()}, + 'phases': {}} + self.run_cli(expected=2) + self.assertEqual(self.run_cli('status')['jp1']['preflight'],'waiting') + self.fake.write_text('# changed') + self.run_cli(expected=2) + self.assertEqual(self.run_cli('status')['jp1']['preflight'],'unknown') + + +if __name__ == '__main__': unittest.main() From 1de59971f6867f1e91a99e637afb4521e6c8f114 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 09:11:53 +0800 Subject: [PATCH 09/25] docs: record three-node Japan batch acceptance and recovery --- docs/maintenance/NODE_BATCH.md | 6 +++++ docs/maintenance/NODE_ONBOARDING.md | 39 +++++++++++++++++++++++------ docs/maintenance/NODE_RUNBOOK.md | 14 ++++++----- docs/maintenance/README.md | 4 +-- docs/maintenance/STATUS.md | 6 +++-- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/docs/maintenance/NODE_BATCH.md b/docs/maintenance/NODE_BATCH.md index c33e477..f71a47c 100644 --- a/docs/maintenance/NODE_BATCH.md +++ b/docs/maintenance/NODE_BATCH.md @@ -114,6 +114,10 @@ apply 返回成功后仍必须 probe 确认。退出码非零、输出损坏、 SIGINT/SIGTERM 会停止派发后续写入,终止当前本地适配器进程组;**这不保证已发出的远端命令停止,也不自动回滚**。其他已开始的节点可能已完成步骤。已完成节点保留成果;未完成节点留在其最后安全阶段。需要回退时按执行手册逐字段预览与回退,不做全批删除。 +### 原服务自行重启时的恢复 + +PID/启动时间漂移应先停止写入,不直接刷新基线。读取服务生命周期、退出原因、重启策略、内核OOM与本任务命令记录,区分本次变更影响和原服务自身故障。确认已恢复时,保留原基线只读副本及SHA256、事故日志、两次稳定采样、443监听归属与当前配置/二进制/unit哈希;没有历史哈希就明确不能证明前后相同。独立助手复核后,只更新本任务进程比对所需字段,保存diff、原因与证据路径;不修改原服务、不删除账本、不改变冻结代码/计划。再次漂移必须重新调查,不能滚动更新绕过保护。2026-10-17 jp03 QUIC sniffer自身panic的实际处理见 [STATUS](STATUS.md)。 + ## 多智能体协作流程 用户于 2026-10-16 明确授权:以后执行批量节点接入,默认启动一个子助手,按本节分工执行,不再重复询问是否需要助手。触发入口见项目根 [AGENTS.md](../../AGENTS.md)。这里的“启动”由主协调者使用子助手工具完成;`batch.py` 本身不调用模型或创建助手。明确要求批量,或同批接入两个及以上节点时触发;仅讨论、修改脚本/文档、单节点接入不自动触发。用户当次明确调整角色或数量时,以最新要求为准。 @@ -147,3 +151,5 @@ python3 -m unittest discover -s tests/node-onboarding -p 'test_batch.py' -v 2026-10-17追加面板隔离测试:`php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php`,9 tests / 65 assertions通过。真实Eloquent与SQLite内存库,无.env/Redis/队列/网络;覆盖append保护范围、QA禁用、幂等、事务失败/通知失败恢复和冲突拒绝。NodeSync为测试替身,生产通知与用户下发仍需现场验收。 + +2026-10-17:已在用户授权的三日本节点批次使用同一冻结计划/账本及一个核查验收助手完成生产接入与分阶段恢复;DNS/中转仍由注册后台界面保存后现场probe,无通用服务商API写入。精确版本、五节点订阅/QA/原资源验收及进程基线事故见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-17-三个日本节点批量接入)。本轮三节点结果不推导十节点耗时或无人值守能力。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index e63f117..d21c137 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,22 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-17 三个日本节点批量接入 + +按用户授权新增日本 JP - 02/03/04,分别部署于 node-jp03/04/05;展示编号、VPS、部署key和数据库ID独立。三台均为本次登录核实的 Debian11/x86_64、JP IPv4出口;新建独立 AnyTLS 24443、DNS only A记录及中转规则 dpl-jp03/04/05-anytls,入口47203/47204/47205,总规则25→28。收发Proxy Protocol均关闭,未改原443规则。 + +三个隔离QA分别实测严格TLS/SNI、实际IPv4出口与国家、双向流量及在线心跳,经一个核查验收助手逐报告摘要复核后,主协调者以append预览/应用,只将新节点加入基础节点组1并禁用本次QA。QA账号19/20/18分别属于jp03/04/05;节点/机器ID分别4/5/3,不能据数据库ID推断展示顺序。 + +本次使用既有正式组有效管理员账号ID1,确认其在五节点实际下发集合中;未为正式验收另建账号或更改该账号权限/密码,不代表普通付费用户、购买或套餐流程验收。正式有效订阅实取后包含美国01和日本01至04共五个AnyTLS出站,官方sing-box 1.14.3逐一实际联网;五个端点均严格TLS,出口/国家分别与节点登记相符,完整订阅DNS/路由校验通过。三个QA订阅均403且不在节点实际用户下发集合;既有全部套餐、正式用户组/套餐关联、订阅地址和旧两节点关键配置逐字段符合基线。四个新旧网关容器ID、启动时间及重启计数保持原值;未部署生产应用代码、改价格/套餐或重启共享网关。 + +三张独立证书有效期至2027-01-14 UTC,沿用既有Snap Certbot调度,独立renewal与仅重启dpl-node的deploy hook;逐台续期dry-run成功,原XrayR 443保留。例外:jp03旧XrayR在DPL尚未安装时因既有QUIC sniffer越界panic,经systemd自动恢复(198435→215765);前检自动停止后保留原基线/哈希、事故日志、两次稳定采样和diff,经独立复核仅更新任务进程检查字段。其余两台旧PID保持。未修改旧XrayR配置、程序或服务;无原配置哈希,不宣称前后哈希一致。不能把此次结果写成三台原PID全程不变。 + +源码6af6764已提交并推送;独立运维提交b8d10fac963d63825c97d36d41ff7f525116bbfd已推送codex/node-batch-20261017并部署到面板/网关/三台新节点。PHP语法检查、9项/65断言面板隔离测试与11项批量进程测试通过;真实通知/用户下发由本次在线和实际用户集合补充验证,未演练生产故障回退。 + +唯一任务计划与账本:codex/output/node-batch-20261017/batch.json、ledger/jp03-jp04-jp05-20261017-reviewed;证据包括*-evidence.json、formal-evidence.json、final-metadata.json和后台截图,均不入Git。远端面板/节点私有备份为/root/codex-backups/dpl/node-onboarding-20261017-jp03/04/05;面板全批旧资源基线在/root/codex-backups/dpl/node-batch-20261017;网关正式验收在/var/lib/dpl-sgw/qa-batch-20261017。面板回退按各自promotion-journal逐字段预览,不恢复整库;DNS/中转/节点服务分别专项回退。 + +自动audit会检查三条新端点集合、报告摘要和QA403;冻结任务适配器未自动逐一核对旧两节点结果、每个出口IP及QA拒绝文件摘要/身份/时间,须由主协调与助手独立对照完整五节点实测报告及原始文件补齐,本次按此执行。三节点账本已全部complete。管理面板浏览器已验证节点/服务器列表切换、五台在线及保存后的名称、端口、基础节点组显示,未额外提交编辑表单默认值。未进行本机/手机客户端GUI、TUN、多地区或多设备并发测试;三节点调度验证不等于十节点耗时承诺。 + ## 2026-10-16 日本节点接入 本轮选择 `node-jp02`;现场确认 IPv4 出口地区 JP、24443 空闲、无既有 DPL 服务,生产原来只有美国节点,故未重复接入。新节点运行接入及正式开放完成,管理面板浏览器验收已通过;下文首节点历史验收范围不自动扩大到本轮。 @@ -25,20 +41,26 @@ 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 -流量链路按项目现有接入记录分列(2026-10-11/16历史现场结果,本次未联网复验): +流量链路按部署key分列;2026-10-17正式五节点订阅逐条联网复验通过: | 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | | --- | --- | --- | --- | --- | --- | | us01 | node-us01 | haiwi.lshan.eu.org:47101 | 64.44.115.18:24443 | us01.deeplink-subscribe.com | 2026-10-11 首节点验收 | | jp02 | node-jp02 | haiwi.lshan.eu.org:47202 | 193.111.31.52:24443 | jp02.deeplink-subscribe.com | 本文2026-10-16日本节点接入 | +| jp03 | node-jp03 | haiwi.lshan.eu.org:47203 | 185.200.65.70:24443 | jp03.deeplink-subscribe.com | 本文2026-10-17批量接入 | +| jp04 | node-jp04 | haiwi.lshan.eu.org:47204 | 85.113.70.174:24443 | jp04.deeplink-subscribe.com | 本文2026-10-17批量接入 | +| jp05 | node-jp05 | haiwi.lshan.eu.org:47205 | 45.153.246.171:24443 | jp05.deeplink-subscribe.com | 本文2026-10-17批量接入 | -客户端 → 对应中转入口 → 对应落地 → 目标网络。两条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 +客户端 → 对应中转入口 → 对应落地 → 目标网络。五条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 -| 资源 | 本次新增 | 必须保留的原有资源 | +| 资源 | DPL独立资源(各次接入) | 必须保留的原有资源 | | --- | --- | --- | | node-us01 | dpl-node,TCP 24443,机器/节点 ID 1(2026-10-11) | XrayR,TCP 443 | | node-jp02 | dpl-node,TCP 24443,机器/节点 ID 2(2026-10-16) | XrayR,TCP 443;原中转 46202 → 443 | -| 中转 | dpl-us01-anytls,47101 → 24443 | us01,46101 → 443;其他规则 | +| node-jp03 | dpl-node,TCP 24443,机器/节点 ID 4(2026-10-17) | XrayR,TCP 443;自身panic恢复例外见本次记录 | +| node-jp04 | dpl-node,TCP 24443,机器/节点 ID 5(2026-10-17) | XrayR,TCP 443 | +| node-jp05 | dpl-node,TCP 24443,机器/节点 ID 3(2026-10-17) | XrayR,TCP 443 | +| 中转 | 五条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | @@ -50,7 +72,7 @@ ## DNS、真实 IP 和证书 -Namecheap 已委托至 bradley.ns.cloudflare.com、jewel.ns.cloudflare.com。导入的原有记录保留。c2 指向网关并启用代理;us01 指向节点,DNS only。两张源站证书有效期至 2027-01-09,公网订阅 HTTPS 可用。 +2026-10-11首节点记录:Namecheap 已委托至 bradley.ns.cloudflare.com、jewel.ns.cloudflare.com。导入的原有记录保留。c2 指向网关并启用代理;us01 指向节点,DNS only。两张源站证书有效期至 2027-01-09,公网订阅 HTTPS 可用。后续日本节点独立DNS与证书见各次接入记录。 2026-10-11 本聊天通过正常浏览器访问直接复核:dpl 区域 SSL/TLS 为 Full (strict)。初次只读审查没有配置/源站/请求或响应标头转换/URL 改写/重定向规则,旧式 Page Rules 为零。后续按具体授权新增订阅路径 BIC 配置规则,未设置 SSL,严格模式保持。 @@ -104,9 +126,9 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立 ## 重复执行步骤 -按[统一接入顺序](NODE_RUNBOOK.md#统一接入顺序)执行;先区分首次 initialize 与已有组 append。新本地promote.php必须显式mode;append只修改新节点及本次QA,默认预览、apply才写入。该版本尚未测试或发布,线上旧脚本不能直接按新语义调用。 +按[统一接入顺序](NODE_RUNBOOK.md#统一接入顺序)执行;先区分首次 initialize 与已有组 append。promote.php必须显式mode;append只修改新节点及本次QA,默认预览、apply才写入。2026-10-17已通过隔离测试并部署独立运维快照,具体版本与现场范围见[STATUS](STATUS.md);不能用未知版本旧脚本替代。 -依赖检查在创建面板资源前完成;证书演练、完整订阅验收、精确回退与现有脚本限制均在执行手册维护,不在多处复制命令。本地新增面板正式开放/精确回退与多节点验证入口,均尚未测试;细节与奶油接手测试清单见执行手册。远程服务/DNS/中转仍按专项恢复。 +依赖检查在创建面板资源前完成;证书演练、完整订阅验收、精确回退与现有脚本限制均在执行手册维护,不在多处复制命令。面板正式开放/精确回退与多节点验证入口的测试、部署及现场范围见执行手册和STATUS。远程服务/DNS/中转仍按专项恢复。 数据库默认 sing-box 模板已迁移新 DNS/路由格式,保留原分流和选择逻辑;现代本地 DoH 使用默认 dialer,不绕到空 direct 出站。仓库原默认资源和其他机场模板不改。apply-template.php 仅接受原默认模板,或与显式 expected-current-sha256 完全匹配的旧版本,逐次备份后只更新该模板/缓存。用户定制或未知模板须先审查,不能强行覆盖。 @@ -128,6 +150,9 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立 | --- | --- | --- | --- | --- | --- | --- | | dpl | 🇺🇸 美国 US - 01 / node-us01 | AnyTLS | 24443 | haiwi.lshan.eu.org:47101 | 1 | 已上线;基础节点组,正式套餐 1/6/7/8 | | dpl | 🇯🇵 日本 JP - 01 / node-jp02 | AnyTLS | 24443 | haiwi.lshan.eu.org:47202 | 2 | 2026-10-16 运行上线;基础节点组 1,正式套餐 1/6/7/8;浏览器验收通过 | +| dpl | 🇯🇵 日本 JP - 02 / node-jp03 | AnyTLS | 24443 | haiwi.lshan.eu.org:47203 | 4 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 | +| dpl | 🇯🇵 日本 JP - 03 / node-jp04 | AnyTLS | 24443 | haiwi.lshan.eu.org:47204 | 5 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 | +| dpl | 🇯🇵 日本 JP - 04 / node-jp05 | AnyTLS | 24443 | haiwi.lshan.eu.org:47205 | 3 | 2026-10-17 append上线;组1,正式五节点订阅及QA403通过 | 已完成:独立服务、节点 TLS、新中转、新网关、DNS/橙云、真实 IP 比对、新旧订阅 vhost 官方 IP 自动同步、两张证书续期演练。官方 sing-box 1.14.3 的完整实际订阅(仅把入站替换为临时本机 SOCKS)经中转联网,出口 64.44.115.18、US、HTTPS TLSv1.3,严格节点证书校验;完整 DNS 和远程分流规则实际启动通过,面板已记录上下行流量。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index b70fc5b..d97b881 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -82,7 +82,9 @@ esac 前置完成后,保存本次允许改变的对象、每个对象的 before 值、预期 after 值和精确回退依据。不要打印整库快照或秘密。 -## 本地脚本能力与边界(尚未测试/发布) +## 脚本能力与边界 + +2026-10-17:下列脚本已完成 PHP 语法检查、面板隔离测试(9 tests / 65 assertions)及批量进程测试(11 tests),源码提交并推送 `6af6764`;独立运维快照 `b8d10fac963d63825c97d36d41ff7f525116bbfd` 已部署到本次面板/网关的 `/opt/dpl-batch/provision` 与三台新节点 `/opt/dpl-node/provision`。生产应用代码未随之更新。各阶段现场结果以 [STATUS](STATUS.md) 和 [接入记录](NODE_ONBOARDING.md) 为准;隔离测试中的通知替身不证明生产通知成功。 | 工具 | 新行为 | 边界 | | --- | --- | --- | @@ -94,7 +96,7 @@ esac | operations.php | 私有文件锁、原子写日志、事务内记录写前意图、提交后完成标记;中断后按before/after识别已应用或冲突 | 文件与数据库不是同一个事务;状态未知需依据日志恢复,不盲目重新stage | | install-node.sh / renew-node.sh | 沿用已登记的独立安装与专用续期hook | 本次未更改,不代表前置工具/安装源/实际timer已经验证 | -新脚本不修改节点展示名、不分配编号。操作日志不包含密码/UUID/机器令牌;脚本不在错误输出中打印数据库异常全文或客户端配置。首次运行前必须由奶油进行隔离测试,特别核对事务、缓存/节点通知、重复执行和崩溃恢复。 +新脚本不修改已有节点展示名、不自动分配编号。操作日志不包含密码/UUID/机器令牌;脚本不在错误输出中打印数据库异常全文或客户端配置。上述隔离测试覆盖事务、幂等、崩溃恢复与冲突拒绝;缓存/节点通知与用户下发仍按本次现场证据验收。 ## 统一接入顺序 @@ -238,11 +240,11 @@ php "$PROVISION_ROOT/rollback.php" --backup="$BACKUP" 回退逐字段核对当前值仍等于promotion记录的after,任何冲突整体停止;写回本次before、保持QA禁用,并禁用/隐藏本次节点。rollback-journal.json保存回退本身的意图与完成状态,重复执行也需匹配完整结果。旧日志不能自动补造after或升级成新日志。DNS、中转、远程服务、证书、网关/CF属于其他对象,仍按对应专项步骤和恢复证据处理;该面板脚本不包含跨服务器/服务商变更。 -## 本次交付与交接测试范围 +## 原交接测试清单与当前证据 -2026-10-16,本地已编写mode隔离的正式开放、参数化回读、多节点验证及面板字段回退,并补stage参数与重跑保护。**按用户要求尚未测试,包括未运行php -l、Python结构/联网验证或任何应用命令;未提交/推送/部署。** 原有运行结果仍引用接入历史,不替代新代码验收。 +2026-10-16编写时尚未测试或发布;2026-10-17已执行本节上方所列隔离测试、语法检查和独立运维快照部署,现场任务仍以STATUS区分完成范围。下列保留为完整验证清单,不表示每一项都已有独立用例或实际故障演练;未执行的项不能因部署而记为通过。 -奶油应在全新SQLite内存库/隔离应用容器中测试,禁止加载项目本地真实.env、默认数据库/Redis/队列或使用正式凭据: +离线测试应在全新SQLite内存库/隔离应用容器中进行,禁止加载项目本地真实.env、默认数据库/Redis/队列或使用正式凭据: 1. append仅改变本次node.group_ids、QA.banned,现有订阅设置/全部套餐/正式用户/旧节点逐字段不变;拒绝append夹带plans/url/group-name。 2. initialize只修改明确套餐及其无组用户、指定订阅地址/可选组名;拒绝不存在ID、其他组冲突,验证原null订阅设置的创建及回退。 @@ -251,4 +253,4 @@ php "$PROVISION_ROOT/rollback.php" --backup="$BACKUP" 5. 应用/回退幂等;数据库提交前失败、提交后通知/缓存/日志失败、prepared+完整before/after与混合冲突,不能重复创建或覆盖后续变化;QA始终不复活。 6. 多节点JSON用虚构身份;缺失/重复标签、端点/SNI/密码/严格TLS/国家/IP错误均失败。模拟子进程和传输,不发公网请求;默认无run绝不启动进程/开socket,失败后不能留下旧成功报告。 7. stage相同完整参数恢复缺失传输文件;参数变化、旧manifest、现有同名/同中转端点资源或已promote时拒绝接管;检查所有秘密文件权限。 -8. 通过隔离测试之后才另行申请或接受具体线上发布/验收任务,本次授权不包含这些动作。节点命名仍由原维护任务处理,不因测试改名或分配编号。 +8. 通过隔离测试之后,线上发布/验收仍需具体任务授权;测试本身不授权生产变更。节点命名按NODE_ONBOARDING执行,不因测试改名或分配编号。 diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index b0fd3de..a7e8221 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -21,8 +21,8 @@ | TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 | | STATUS.md | 最近验证基线、已知限制与待验收项目 | | CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 | -| NODE_ONBOARDING.md | 两节点链路、命名规则、共享资源与历史验收 | -| NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;新本地代码未测试,含奶油测试清单 | +| NODE_ONBOARDING.md | 五节点链路、命名规则、共享资源与验收 | +| NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;隔离测试/运维版本与现场边界,含验证清单 | | NODE_BATCH.md | 批量接入默认一个子助手的分工与关卡;append并发锁/查重/断点恢复、命令协议与测试边界 | | GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 504bfd0..ddf1c35 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,9 +2,11 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 -## 2026-10-17 三节点批次前检(执行中) +## 2026-10-17 三节点批量接入完成 -用户授权追加3节点,暂定node-jp03/04/05,展示编号JP - 02/03/04;经用户批准的精确SSH指纹及白色机跳转登录,现场确认root、Debian11、x86_64、JP出口、旧XrayR active、无dpl-node、24443空闲。面板现场只有US - 01与JP - 01,未创建新节点。新增PanelChangeIsolatedTest.php:9 tests / 65 assertions通过;批量进程11 tests重跑通过。面板测试使用真实Eloquent/SQLite内存库与通知替身,不覆盖真实Redis/observer/联网;生产追加尚未完成。 +新增日本JP - 02/03/04(node-jp03/04/05),均已append正式组1、QA禁用。五节点正式完整订阅逐条严格TLS联网、预期IPv4出口/国家及三个QA订阅403通过;旧节点、套餐/用户关联、订阅设置与四个网关容器基线保持。续期演练逐台通过。源码6af6764、独立运维b8d10fac已提交推送并部署运维目录,生产应用代码未随之更新;9项/65断言面板隔离测试及11项批量进程测试通过。 + +jp03旧XrayR接入前自身QUIC sniffer panic自动恢复的基线例外、原证据与恢复边界,以及完整参数/验收/备份/自动audit不足的人工补验,见[本次接入记录](NODE_ONBOARDING.md#2026-10-17-三个日本节点批量接入)。GUI/TUN/多地区/多设备并发与生产回退演练未执行。下方2026-10-16未测试/未发布为当时历史,当前以本节为准。CoDocs新增三实例/三域名、服务器核实字段及关联已同步;resources.py check通过(166对象/299关系),check_docs.py全库检查发现6项.obsidian目录/文件权限不合要求;本任务未修改该目录,不能把全库检查写成通过。 ## 2026-10-16 批量接入默认单子助手规则 From 5c9157f5d7852976c92900c8a1113c547c2e2b97 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 15:22:30 +0800 Subject: [PATCH 10/25] feat: add reusable jump-host node onboarding runtime --- AGENTS.md | 2 + docs/maintenance/NODE_BATCH.md | 2 + docs/maintenance/NODE_BATCH_RUNTIME.md | 111 +++++ docs/maintenance/NODE_RUNBOOK.md | 2 + docs/maintenance/README.md | 1 + docs/maintenance/STATUS.md | 8 + scripts/node-onboarding/acceptance.py | 93 ++++ scripts/node-onboarding/batch.py | 101 +++- scripts/node-onboarding/bundle-ops.py | 55 +++ scripts/node-onboarding/configure-site.py | 44 ++ scripts/node-onboarding/panel-acceptance.php | 94 ++++ scripts/node-onboarding/requirements.txt | 1 + scripts/node-onboarding/site_adapter.py | 431 ++++++++++++++++++ scripts/node-onboarding/ssh_pool.py | 99 ++++ .../AcceptanceUserIsolatedTest.php | 66 +++ tests/node-onboarding/test_runtime.py | 312 +++++++++++++ 16 files changed, 1418 insertions(+), 4 deletions(-) create mode 100644 docs/maintenance/NODE_BATCH_RUNTIME.md create mode 100644 scripts/node-onboarding/acceptance.py create mode 100644 scripts/node-onboarding/bundle-ops.py create mode 100644 scripts/node-onboarding/configure-site.py create mode 100644 scripts/node-onboarding/panel-acceptance.php create mode 100644 scripts/node-onboarding/requirements.txt create mode 100644 scripts/node-onboarding/site_adapter.py create mode 100644 scripts/node-onboarding/ssh_pool.py create mode 100644 tests/node-onboarding/AcceptanceUserIsolatedTest.php create mode 100644 tests/node-onboarding/test_runtime.py diff --git a/AGENTS.md b/AGENTS.md index 8e672f0..2552251 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ ## 批量节点接入:默认启动一个子助手 +后续新批次默认使用 [正式适配器入口](docs/maintenance/NODE_BATCH_RUNTIME.md),由 `configure-site.py` 固定计划,再由 `batch.py` 的 `ssh-site-v1` 运行;动态读取验收账号应得节点集合,不固定节点数量。没有有效验收账号时自动创建本任务受限测试用户并负责禁用收尾;SSH始终经已登记白色机。具体实现、证据续跑和原服务调查边界见该专题;旧冻结计划及其处理脚本不改摘要迁就新实现。仅维护脚本/文档不触发真实接入或线上创建测试用户。 + 用户要求执行批量节点接入(明确说批量,或同批接入两个及以上节点)时,主协调者必须默认启动一个子助手,无需再次询问是否使用助手:核查验收助手负责候选服务器、端口、中转与已有资源冲突核查,并独立审查脚本、测试并发/查重/恢复及执行接入验收。主协调者负责统一参数、展示编号、端口、唯一账本及生产写入。完整分工、执行关卡与消耗控制见 [批量接入协作流程](docs/maintenance/NODE_BATCH.md#多智能体协作流程)。 这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。 diff --git a/docs/maintenance/NODE_BATCH.md b/docs/maintenance/NODE_BATCH.md index f71a47c..bad216c 100644 --- a/docs/maintenance/NODE_BATCH.md +++ b/docs/maintenance/NODE_BATCH.md @@ -6,6 +6,8 @@ ## 文件与运行方式 +2026-10-17新增[正式站点适配器](NODE_BATCH_RUNTIME.md):后续新批次默认用`configure-site.py`生成`ssh-site-v1`计划,具备动态完整节点验收、受限测试账号、白色机SSH会话复用、证据续跑及阶段事件。已通过本地隔离测试,尚未部署或线上演练。本页下述外部命令适配器协议保留兼容,旧任务脚本和既有冻结摘要不修改;旧命令适配器不透传缓存证据,继续逐阶段现场probe。 + | 文件 | 用途 | | --- | --- | | `scripts/node-onboarding/batch.py` | 参数校验、资源预留、并发、进程间锁、逐阶段账本与恢复 | diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md new file mode 100644 index 0000000..90f555b --- /dev/null +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -0,0 +1,111 @@ +# 批量接入正式适配器 + +本专题是后续批量接入的默认技术入口。适配器实现、隔离测试及文档于2026-10-17完成;**本轮没有新增线上节点、部署新运维版本或运行真实SSH/客户端验收**。上一轮三节点实际验收使用的是已冻结旧任务适配器,不能套用为新实现的生产验收。 + +`batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、独立助手审核、Git运维部署及现场验收。 + +## 文件与职责 + +| 文件 | 职责 | +| --- | --- | +| `configure-site.py` | 本地合并明确站点参数,固定解释器、Paramiko版本、源码与非秘密配置摘要;不联网、不写账本、不覆盖旧计划 | +| `site_adapter.py` | 复用正式stage/provision/promote流程;按国家、组、批次和路径参数执行,按需连接服务器,进行动态正式订阅验收 | +| `ssh_pool.py` | 线程内复用白色机及下游SSH会话;下游强制通过白色机direct-tcpip,不提供直连回退 | +| `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 | +| `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 | + +## 准备与Git部署 + +先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 + +生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,拒绝覆盖已有分支或bundle,不联网: + +```sh +python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle +``` + +随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 + +适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 + +## 本地参数文件 + +全部放在权限700的任务目录,文件600。基础计划沿用[NODE_BATCH](NODE_BATCH.md#参数格式),包括本批任意数量、任意已支持国家的候选节点;不固定五节点、日本、组1或任务日期。新增站点配置JSON如下,示例路径必须换为实际已核实值: + +```json +{ + "ops_root": "/opt/dpl-batch/provision", + "node_root": "/opt/dpl-node/provision", + "batch_backup_root": "/root/codex-backups/dpl/BATCH", + "node_backup_root": "/root/codex-backups/dpl/BATCH-nodes", + "qa_root": "/var/lib/dpl-sgw/BATCH", + "provider_snapshot": "/private/task/providers.json", + "exit_registry": "/private/task/exits.json", + "ssh_config": "/private/task/ssh.json", + "preserved_service": { + "unit": "XrayR", "binary": "/usr/local/XrayR/XrayR", + "config": "/etc/XrayR/config.yml", "port": 443 + }, + "formal_socks_port": 19203, + "client_sha256": "填已核验官方客户端文件的64位SHA256", + "gateway_containers": ["dpl-subscribe-gateway", "dpl-subscribe-admin", "subscribe-gateway", "subscribe-admin"] +} +``` + +批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 + +`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。默认JSON支持password/key_filename/passphrase;既有CoDocs Markdown可设`credential_format=codocs-password`,仅按原文件中唯一的“密码:”字段在内存读取,不能为了适配而复制或改写原凭据正文。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 + +只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。 + +`exits.json`按SNI登记独立已知预期,例如: + +```json +{"node.example.invalid":{"expected_exit":"192.0.2.11","country":"JP"}} +``` + +覆盖该账号当前应获得的全部旧节点与新候选,不能从本次实测结果反填预期来让检查通过。增加或改变节点时,按CoDocs/授权现场证据更新新任务的登记;遇到未知预期或不支持协议停止,不能静默省略。当前站点适配器针对IPv4 A记录、固定端口AnyTLS;端口范围或其他协议停止而非静默省略,扩展需另补验证能力。 + +`providers.json`是实际已登记后台UI或授权官方API的近期观察,最多900秒有效,不使用语言相关DOM字符串: + +```json +{ + "captured_at": 0, + "dns": [{"type":"A","name":"node.example.invalid","address":"192.0.2.11","proxy":false}], + "relays": [{"name":"dpl-new-anytls","host":"relay.example.invalid","port":47011,"target":"192.0.2.11","listen_port":24443,"proxy_protocol_in":false,"proxy_protocol_out":false}] +} +``` + +`captured_at`填实际采集Unix时间。观察文件可以刷新,不能把静态勾选当现场证据。资源存在但参数/归属不符停止;尚未创建的DNS/中转仍按注册后台批量操作、更新观察后续跑。本实现没有新增Cloudflare/中转官方API写入权限,也不默认直连其源站。 + +## 执行与等待 + +```sh +/path/to/reviewed/python scripts/node-onboarding/configure-site.py --plan /private/task/base.json --site /private/task/site.json --output /private/task/frozen.json +/path/to/reviewed/python scripts/node-onboarding/batch.py plan --plan /private/task/frozen.json --state-root /private/task/ledger +/path/to/reviewed/python scripts/node-onboarding/batch.py run --plan /private/task/frozen.json --state-root /private/task/ledger --jobs 3 --timeout 900 --execute --events +``` + +配置/plan只做本地工作,不创建线上测试用户。`run --execute`进入准备前检时才允许准备验收账号,随后全批前检屏障通过才stage。`--events`输出无秘密的阶段状态JSON行,最后一行为批次结果;不加此参数保持原单JSON输出协议。账本`events.jsonl`仅追加状态变化,等待时使用进程输出/事件,不每几秒重复查全部状态。只有有新依赖证据、外部DNS/中转变化或用户恢复指令时续跑,不能无限重试。 + +SSH会话仅在本次进程、同一工作线程内复用,线程不共用可变SSH客户端;进程退出关闭连接,不建立后台守护进程。每个阶段通过lazy连接只访问必要主机。Paramiko channel有远端命令超时;进程内模式不承诺`--timeout`能杀死已发送远端命令,停止后仍须核实实际结果。 + +## 动态验收和测试用户 + +准备阶段先按正式组、banned、额度和有效期选既有账号,不把管理员身份自动排除;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤。没有可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 + +新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 + +每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 + +正式验收成功或失败都禁用本任务新建的正式测试用户;既有账号不被禁用或改密码/权限。进程正常退出在finally收尾,即使等待或部分前检失败也尝试清理;遇到断网/SIGKILL无法保证立即清理,记录的归属、1小时有效期与额度是兜底,恢复后先核对旧journal。已关闭用户绝不重新启用;下一次需要用户时创建新受限租约,保留历史并在最终审计核对其403/下发排除。完成要求由服务端实际集合/订阅拒绝证明,不承诺立即踢断已经建立的客户端会话。 + +## 续跑与原服务调查 + +preflight/publish/audit始终现场执行。只有先前状态为done、收到真实前检依赖摘要、请求/代码/config未变、证据未过期,才可复用stage/dns/provision/relay/verify。前驱依赖变化使后续缓存失效;running/unknown/conflict绝不跳过。旧适配器不提供这些证据时仍按原逐阶段probe逻辑执行。生产写入仍先probe、持久化意图、apply后probe,不为速度跳过归属检查。 + +原服务保存最初unit/程序/配置SHA256、监听、active及PID/启动时间/重启数。配置/程序/监听改变或不可用停止;仅进程变化分类为“需调查”,不宣称是DPL导致,也不自动刷新基线。记录观察后由操作者及助手核查生命周期、OOM、操作时间线和稳定采样;认可时在本节点私有备份登记`preserved-service-review.json`,绑定最初/本次观察摘要、原因及证据SHA256。原基线保持;再次重启不匹配旧审核,须重新调查。 + +## 验证范围 + +新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index d97b881..a90c38d 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -84,6 +84,8 @@ esac ## 脚本能力与边界 +后续新批次按[正式适配器](NODE_BATCH_RUNTIME.md)准备和执行:动态账号授权集合验收、缺账号时自动创建受限用户、白色机SSH复用及证据有效期续跑。旧命令适配器继续兼容。下文运维提交为上一轮三节点已验收版本,新适配器与PHP验收用户入口尚未部署,不能把旧任务验收视为新版本线上通过。 + 2026-10-17:下列脚本已完成 PHP 语法检查、面板隔离测试(9 tests / 65 assertions)及批量进程测试(11 tests),源码提交并推送 `6af6764`;独立运维快照 `b8d10fac963d63825c97d36d41ff7f525116bbfd` 已部署到本次面板/网关的 `/opt/dpl-batch/provision` 与三台新节点 `/opt/dpl-node/provision`。生产应用代码未随之更新。各阶段现场结果以 [STATUS](STATUS.md) 和 [接入记录](NODE_ONBOARDING.md) 为准;隔离测试中的通知替身不证明生产通知成功。 | 工具 | 新行为 | 边界 | diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index a7e8221..47f23c8 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -24,6 +24,7 @@ | NODE_ONBOARDING.md | 五节点链路、命名规则、共享资源与验收 | | NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;隔离测试/运维版本与现场边界,含验证清单 | | NODE_BATCH.md | 批量接入默认一个子助手的分工与关卡;append并发锁/查重/断点恢复、命令协议与测试边界 | +| NODE_BATCH_RUNTIME.md | 新批次默认正式适配器、动态集合验收、自动受限测试用户、白色机SSH复用、证据续跑及部署准备 | | GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 | 这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index ddf1c35..c3a4f98 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,14 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-17 批量接入流程优化(本地验证) + +已实现正式`ssh-site-v1`适配器及`configure-site.py`入口,去除临时handler中的固定日期、日本、组1和节点数量。正式验收从真实账号授权集合生成快照,完整校验新旧节点、预期出口、严格TLS、原始证据摘要、QA身份/403/禁用/下发排除及时效;集合变化停止。没有有效正式组账号时自动创建100MiB/1小时/1设备非管理员测试用户,journal恢复同一身份,正常成功/失败/等待均清理本任务用户,关闭后不复活;异常中断依靠日志恢复和时限兜底。已批准指纹匹配后才读取凭据,SSH始终经白色机,按线程复用会话、按阶段延迟连接,不自动重放断线命令。 + +续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。 + +本轮Python 30项测试通过;PHP新增9项/35断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。 + ## 2026-10-17 三节点批量接入完成 新增日本JP - 02/03/04(node-jp03/04/05),均已append正式组1、QA禁用。五节点正式完整订阅逐条严格TLS联网、预期IPv4出口/国家及三个QA订阅403通过;旧节点、套餐/用户关联、订阅设置与四个网关容器基线保持。续期演练逐台通过。源码6af6764、独立运维b8d10fac已提交推送并部署运维目录,生产应用代码未随之更新;9项/65断言面板隔离测试及11项批量进程测试通过。 diff --git a/scripts/node-onboarding/acceptance.py b/scripts/node-onboarding/acceptance.py new file mode 100644 index 0000000..c62470a --- /dev/null +++ b/scripts/node-onboarding/acceptance.py @@ -0,0 +1,93 @@ +"""Token-free dynamic evidence validation shared by the reusable site adapter.""" +import hashlib +import ipaddress +import re +import json +import time + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest() + + +def snapshot(nodes): + if not isinstance(nodes, list) or not nodes: + raise ValueError('Empty authorized node set') + fields = ('id', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country') + result = [] + for node in nodes: + if node.get('type') != 'anytls' or any(node.get(k) in (None, '') for k in fields): + raise ValueError('Unsupported protocol or incomplete independently registered expectations') + if type(node['id']) is not int or node['id']<1 or type(node['relay_port']) is not int or not 1<=node['relay_port']<=65535 or not isinstance(node['country'],str) or not re.fullmatch('[A-Z]{2}',node['country']): + raise ValueError('Invalid authorized node identity or expectation') + address=ipaddress.ip_address(node['expected_exit']) + if getattr(address,'scope_id',None):raise ValueError('Scoped exit expectation unsupported') + result.append({k: node[k] for k in fields}) + for key in ('id', 'tag'): + if len({n[key] for n in result}) != len(result): + raise ValueError('Duplicate authorized node identity') + if len({(n['relay'], n['relay_port'], n['sni']) for n in result}) != len(result): + raise ValueError('Ambiguous authorized endpoint') + return sorted(result, key=lambda n: n['id']) + + +def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900): + now = time.time() if now is None else now + before, after = snapshot(before), snapshot(after) + if before != after or binding.get('snapshot_sha256') != digest(before): + raise ValueError('Authorized node set changed; regenerate and verify the complete set') + if binding.get('plan_sha256') != plan_sha: + raise ValueError('Wrong plan binding') + for label, raw in [('report', report_bytes), ('qa_rejections', qa_bytes)]: + if binding.get(label + '_sha256') != hashlib.sha256(raw).hexdigest(): + raise ValueError('Evidence bytes changed') + report, qas = json.loads(report_bytes), json.loads(qa_bytes) + for stamp in (binding.get('verified_at'), report.get('verified_at'), qas.get('captured_at')): + if type(stamp) not in (int, float) or not 0 <= now-stamp <= ttl: + raise ValueError('Evidence expired or future dated') + if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before): + raise ValueError('Incomplete live verification') + expected = {(n['tag'], n['relay'], n['relay_port'], n['sni'], n['expected_exit'], n['country']) for n in before} + actual = set() + for n in report['results']: + if n.get('live') is not True or n.get('strict_tls') is not True: + raise ValueError('Live strict TLS required for every node') + actual.add(tuple(n.get(k) for k in ('tag', 'relay', 'relay_port', 'sni', 'exit_ip', 'country'))) + if actual != expected: + raise ValueError('Node/endpoint/exit/country mismatch') + if qas.get('plan_sha256') != plan_sha or set(qas.get('users', {})) != set(expected_qas): + raise ValueError('QA evidence identity mismatch') + for key, identity in expected_qas.items(): + q = qas['users'][key] + if q.get('id') != identity or q.get('http_status') != 403 or q.get('banned') is not True or q.get('excluded') is not True: + raise ValueError('QA account not fully revoked') + return {'nodes': len(before), 'snapshot_sha256': digest(before), 'verified_at': now} + + +def service_change(before, current): + """Process movement is investigatory; never silently accept/rebase it.""" + required = ('unit_sha256', 'binary_sha256', 'config_sha256', 'listeners') + if any(not before.get(k) or not current.get(k) for k in required): + return 'unknown' + if any(before[k] != current[k] for k in required) or current.get('active') is not True: + return 'conflict' + if any(before.get(k) != current.get(k) for k in ('pid', 'started', 'restarts')): + return 'investigate_restart' + return 'ready' + + +def provider_state(observed, node, now=None): + """Canonical observations from the registered provider UI/API, never HTML substrings.""" + now=time.time() if now is None else now + stamp=observed.get('captured_at') + if type(stamp) not in (int,float) or not 0<=now-stamp<=900: + raise ValueError('Provider observation expired') + if not isinstance(observed.get('dns'),list) or not isinstance(observed.get('relays'),list): + raise ValueError('Canonical DNS and relay observations required') + dns=[d for d in observed['dns'] if d.get('name')==node['tls_domain']] + relays=[r for r in observed['relays'] if r.get('host')==node['relay_host'] and r.get('port')==node['relay_port']] + if dns and (len(dns)!=1 or dns[0].get('type')!='A' or dns[0].get('address')!=node['node_address'] or dns[0].get('proxy') is not False): + raise ValueError('DNS provider resource conflict') + if relays and (len(relays)!=1 or relays[0].get('target')!=node['node_address'] or relays[0].get('listen_port')!=node['listen_port'] or relays[0].get('name')!='dpl-'+node['key']+'-anytls' or relays[0].get('proxy_protocol_in') is not False or relays[0].get('proxy_protocol_out') is not False): + raise ValueError('Relay provider resource conflict') + return {'dns':dns,'relays':relays} diff --git a/scripts/node-onboarding/batch.py b/scripts/node-onboarding/batch.py index baeb5de..01d349e 100644 --- a/scripts/node-onboarding/batch.py +++ b/scripts/node-onboarding/batch.py @@ -6,6 +6,7 @@ import contextlib import fcntl import hashlib import ipaddress +import importlib.util import json import os from pathlib import Path @@ -20,6 +21,7 @@ import time PHASES = ('stage', 'dns', 'provision', 'relay', 'verify', 'publish', 'audit') SHARED = {'stage': 'panel', 'dns': 'dns', 'relay': 'relay', 'publish': 'panel'} STOP = threading.Event() +EVENTS = False def digest(value): @@ -85,7 +87,8 @@ def validate(plan): if type(n['formal_group_id']) is not int or n['formal_group_id'] < 1: raise ValueError('An existing formal group is required') ipaddress.ip_address(n['node_address']) - ipaddress.ip_address(n['expected_exit']) + exit_address=ipaddress.ip_address(n['expected_exit']) + if getattr(exit_address,'scope_id',None):raise ValueError('Scoped/link-local exit addresses are not supported') for k in ('relay_host', 'tls_domain'): if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}', n[k]): raise ValueError('Invalid DNS name: ' + k) @@ -143,16 +146,21 @@ class Ledger: CREATE TABLE IF NOT EXISTS batches(id TEXT PRIMARY KEY, digest TEXT NOT NULL); CREATE TABLE IF NOT EXISTS claims(namespace TEXT, kind TEXT, value TEXT, owner TEXT NOT NULL, PRIMARY KEY(namespace,kind,value)); + CREATE TABLE IF NOT EXISTS evidence(batch TEXT,node TEXT,phase TEXT, + dependency TEXT,expires REAL,request TEXT,PRIMARY KEY(batch,node,phase)); CREATE TABLE IF NOT EXISTS phases(batch TEXT, node TEXT, phase TEXT, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, updated REAL NOT NULL, PRIMARY KEY(batch,node,phase)); ''') os.chmod(self.path, 0o600) + @contextlib.contextmanager def db(self): c = sqlite3.connect(self.path, timeout=30) - c.execute('PRAGMA synchronous=FULL') - return c + try: + c.execute('PRAGMA synchronous=FULL') + with c: yield c + finally: c.close() def reserve(self, plan): namespace = plan['context']['panel_vps'] + ':' + plan['context']['app_root'] @@ -185,6 +193,15 @@ class Ledger: if old and old[0] != owner: raise ValueError('Another batch reserved the gateway SOCKS port') c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, 'socks_port', value, owner)) + formal_port=plan['context'].get('formal_socks_port') + if formal_port is not None: + if type(formal_port) is not int or not 1024<=formal_port<=65535 or any(n['socks_port']==formal_port for n in plan['nodes']): + raise ValueError('A distinct formal SOCKS port is required') + scope,value='gateway:'+plan['context']['gateway_vps'],str(formal_port) + owner=digest({'context':plan['context'],'formal_port':formal_port}) + old=c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',(scope,'socks_port',value)).fetchone() + if old and old[0]!=owner:raise ValueError('Formal SOCKS port already reserved') + c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)',(scope,'socks_port',value,owner)) c.execute('INSERT OR IGNORE INTO batches VALUES(?,?)', (plan['batch_id'], digest(plan))) def state(self, batch, key, phase): @@ -193,11 +210,31 @@ class Ledger: (batch, key, phase)).fetchone() return row[0] if row else 'pending' + def reusable(self, batch, key, phase, dependency, request): + if not dependency or self.state(batch, key, phase) != 'done': + return False + with self.db() as c: + row = c.execute('SELECT dependency,expires,request FROM evidence WHERE batch=? AND node=? AND phase=?', (batch,key,phase)).fetchone() + return bool(row and row[0] == dependency and time.time() <= row[1] <= time.time()+900 and row[2] == request) + + def proof(self, batch, key, phase, dependency, expires, request): + with self.db() as c: + c.execute('INSERT OR REPLACE INTO evidence VALUES(?,?,?,?,?,?)',(batch,key,phase,dependency,expires,request)) + def mark(self, batch, key, phase, status, attempt=False): + previous=self.state(batch,key,phase) with self.db() as c: c.execute('''INSERT INTO phases VALUES(?,?,?,?,?,?) ON CONFLICT(batch,node,phase) DO UPDATE SET status=excluded.status,attempts=phases.attempts+?,updated=excluded.updated''', (batch, key, phase, status, int(attempt), time.time(), int(attempt))) + if previous!=status or attempt: + fd=os.open(self.root/'events.jsonl',os.O_CREAT|os.O_WRONLY|os.O_APPEND,0o600) + try: + os.fchmod(fd,0o600) + event=json.dumps({'batch':batch,'node':key,'phase':phase,'status':status,'time':time.time()})+'\n' + os.write(fd,event.encode()) + if EVENTS:sys.stdout.write(event);sys.stdout.flush() + finally:os.close(fd) class Coordinator: @@ -206,11 +243,35 @@ class Coordinator: self.ledger = Ledger(root) self.jobs = jobs self.timeout = timeout + self.dependencies = {} + self.runtime = None self.ledger.reserve(plan) self.dir = self.ledger.root / plan['batch_id'] self.dir.mkdir(mode=0o700, exist_ok=True) self.plan_file = self.dir / 'plan.json' atomic_json(self.plan_file, plan) + if plan.get('runtime'): + self.check_runtime() + directory = str(Path(__file__).resolve().parent) + if directory not in sys.path: sys.path.insert(0, directory) + from site_adapter import SiteAdapter + self.runtime = SiteAdapter(plan, self.dir, atomic_writer=atomic_json, resource_lock=lock) + + def check_runtime(self): + runtime = self.plan['runtime'] + if runtime.get('kind') != 'ssh-site-v1': raise ValueError('Unknown runtime') + if runtime.get('python') != str(Path(sys.executable).resolve()):raise ValueError('Use the frozen runtime interpreter') + import paramiko + if paramiko.__version__ != runtime.get('paramiko_version'):raise ValueError('Runtime dependency version changed') + base = Path(__file__).resolve().parent + required = [base/n for n in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')] + required += [Path(self.plan['context'][k]) for k in ('ssh_config','exit_registry')] + files = runtime.get('files', {}) + if any(str(f) not in files for f in required): raise ValueError('Runtime source pin missing') + for f, sha in files.items(): + if not Path(f).is_absolute() or hashlib.sha256(Path(f).read_bytes()).hexdigest() != sha: + raise ValueError('Runtime code/config changed') + def call(self, n, phase, action): fingerprint = digest({'context': self.plan['context'], 'node': n}) @@ -218,6 +279,14 @@ class Coordinator: atomic_json(path, dict(n, request_sha256=fingerprint)) argv = self.plan['adapter'] + ['--plan', str(self.plan_file), '--node', str(path), '--phase', phase, '--action', action, '--state-dir', str(self.dir)] + if self.runtime: + self.check_runtime() + r = self.runtime.call(dict(n, request_sha256=fingerprint), phase, action) + if (r.get('key'),r.get('phase'),r.get('request_sha256')) != (n['key'],phase,fingerprint): + raise RuntimeError('Runtime identity mismatch') + if r.get('status') not in ('ready','absent','waiting','unknown','conflict'): + raise RuntimeError('Runtime status invalid') + return r with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True) as p: deadline = time.monotonic() + self.timeout @@ -258,8 +327,20 @@ class Coordinator: if resource in ('dns', 'relay'): namespace = 'dpl-external' with lock(self.ledger.root, namespace + ':' + resource): + dependency = self.dependencies.get(key, {}).get(phase) + request = digest({'plan': self.plan, 'node': n}) + # Publish/audit/preflight are always live; unknown or running phases cannot use a cache. + if phase in ('stage','dns','provision','relay','verify') and self.ledger.reusable(batch,key,phase,dependency,request): + if self.runtime: self.check_runtime() + return True r = self.call(n, phase, 'probe') + if phase == 'preflight' and r['status'] == 'ready': + deps = r.get('resume_dependencies', {}) + clean = {k:v for k,v in deps.items() if k in PHASES and isinstance(v,str) and re.fullmatch('[0-9a-f]{64}',v)} + self.dependencies[key] = {k:digest({p:clean.get(p) for p in PHASES[:PHASES.index(k)+1]}) for k in clean} if r['status'] == 'ready': + if dependency and type(r.get('valid_until')) in (int,float) and time.time() < r['valid_until'] <= time.time()+900: + self.ledger.proof(batch,key,phase,dependency,r['valid_until'],request) self.ledger.mark(batch, key, phase, 'done') return True if r['status'] == 'conflict': @@ -278,6 +359,8 @@ class Coordinator: return False r = self.call(n, phase, 'probe') self.ledger.mark(batch, key, phase, 'done' if r['status'] == 'ready' else r['status']) + if r['status'] == 'ready' and dependency and type(r.get('valid_until')) in (int,float) and time.time()banned && (int)$u->group_id===$group && (int)$u->u+(int)$u->d<(int)$u->transfer_enable && ($u->expired_at===null || (int)$u->expired_at>=time()); + } + public static function prepare(string $dir,int $group,bool $apply): array { + return locked($dir,function()use($dir,$group,$apply){ + if(!ServerGroup::find($group))throw new RuntimeException('Formal group missing'); + $path=$dir.'/acceptance-user.json'; + if(is_file($path) && readJson($path)['state']==='closed' && $apply){ + $old=readJson($path);$prior=isset($old['id'])?User::find($old['id']):null; + if($old['owned'] && $prior && (!$prior->banned || $prior->email!==$old['email'] || $prior->uuid!==$old['uuid'] || $prior->token!==$old['token'] || $prior->is_admin || (int)$prior->group_id!==$old['group']))throw new RuntimeException('Prior lease not revoked'); + writeJson($dir.'/acceptance-closed-'.($old['id']??'absent').'.json',$old);unlink($path); + } + if(is_file($path)){ + $j=readJson($path); + if($j['group']!==$group || $j['state']==='closed')throw new RuntimeException('Lease closed or parameters changed; never reactivate'); + $u=User::where('email',$j['email'])->first(); + if(!$u && $j['owned'] && $j['state']==='prepared' && $apply){ + $u=DB::transaction(fn()=>self::create($j),1); + } + if(!$u || $u->uuid!==$j['uuid'] || $u->token!==$j['token'] || !self::eligible($u,$group) || ($j['owned'] && $u->is_admin))throw new RuntimeException('Acceptance lease drifted'); + if($j['owned'])self::limits($u,$j); + $j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j); + return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']]; + } + $u=User::where('group_id',$group)->where('banned',0)->whereRaw('u+dwhere(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderBy('id')->first(); + if(!$apply)return ['state'=>$u?'existing':'would_create']; + $j=['group'=>$group,'state'=>'prepared','owned'=>!$u,'email'=>$u?->email??'batch-acceptance-'.substr(hash('sha256',$dir),0,12).'-'.Str::lower(Str::random(8)).'@example.invalid','uuid'=>$u?->uuid??(string)Str::uuid(),'token'=>$u?->token??Str::random(32),'quota'=>104857600,'expires_at'=>time()+3600]; + // Persist identity before commit. A lost response resumes the same row, never another user. + if(!$u && User::where('email',$j['email'])->exists())throw new RuntimeException('Unowned acceptance email conflict'); + writeJson($path,$j); + if(!$u)$u=DB::transaction(fn()=>self::create($j),1); + $j['id']=$u->id;$j['state']='active';writeJson($path,$j); + return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']]; + }); + } + private static function limits(User $u,array $j):void { + if((int)$u->transfer_enable!==$j['quota'] || (int)$u->expired_at!==$j['expires_at'] || (int)$u->device_limit!==1)throw new RuntimeException('Owned lease limits changed; inspect before mutation'); + } + private static function create(array $j): User { + $u=new User(['email'=>$j['email'],'uuid'=>$j['uuid'],'token'=>$j['token'],'password'=>password_hash(Str::random(40),PASSWORD_BCRYPT),'group_id'=>$j['group'],'transfer_enable'=>$j['quota'],'expired_at'=>$j['expires_at'],'banned'=>0,'is_admin'=>0,'remind_expire'=>0,'remind_traffic'=>0,'device_limit'=>1]);$u->saveQuietly();return $u; + } + public static function source(string $dir,int $group,bool $inspect=false): array { + $j=readJson($dir.'/acceptance-user.json');$u=User::findOrFail($j['id']); + if($j['group']!==$group || (!$inspect && $j['state']!=='active') || $u->uuid!==$j['uuid'] || $u->token!==$j['token'] || (!$inspect && !self::eligible($u,$group)) || ($inspect && !$j['owned'] && !self::eligible($u,$group)) || (int)$u->group_id!==$group)throw new RuntimeException('Acceptance user unavailable'); + if($j['owned'])self::limits($u,$j); + $nodes=[]; + foreach(ServerService::getAvailableServers($u) as $s){ + if(isset($s['ports']))throw new RuntimeException('Port-range nodes require an explicit verifier'); + $model=Server::findOrFail($s['id']); + if(!$inspect && !ServerService::getAvailableUsers($model)->contains('id',$u->id))throw new RuntimeException('User excluded by effective node filter'); + $nodes[]=['id'=>$s['id'],'type'=>$s['type'],'tag'=>$s['name'],'relay'=>$s['host'],'relay_port'=>(int)$s['port'],'sni'=>$s['protocol_settings']['tls']['server_name']??null,'uuid'=>$s['password']]; + } + if($inspect){foreach($nodes as &$n)unset($n['uuid']);unset($n);return ['nodes'=>$nodes];} + return ['user_id'=>$u->id,'owned'=>$j['owned'],'token'=>$u->token,'nodes'=>$nodes]; + } + public static function cleanup(string $dir,bool $apply): array { + return locked($dir,function()use($dir,$apply){ + $p=$dir.'/acceptance-user.json';if(!is_file($p))return ['state'=>'absent'];$j=readJson($p); + $u=isset($j['id'])?User::find($j['id']):User::where('email',$j['email'])->first(); + if($j['owned'] && $u){ + if($u->email!==$j['email'] || $u->uuid!==$j['uuid'] || $u->token!==$j['token'] || $u->is_admin || (int)$u->group_id!==$j['group'])throw new RuntimeException('Cleanup ownership mismatch'); + self::limits($u,$j); + if($apply){$u->banned=true;$u->saveQuietly();} + if($apply && !$u->fresh()->banned)throw new RuntimeException('Cleanup failed'); + } + if($apply){$j['state']='closed';$j['id']=$u?->id;writeJson($p,$j);} + return ['state'=>$apply?'closed':'preview','owned'=>$j['owned'],'id'=>$u?->id]; + }); + } +} +if(realpath($_SERVER['SCRIPT_FILENAME']??'')===__FILE__){ + $o=getopt('',['action:','dir:','group:','apply','help']); + if(isset($o['help'])){echo "--action=prepare|source|snapshot|cleanup --dir=PRIVATE_DIR --group=EXISTING_ID [--apply]\n";exit;} + try{ + $dir=$o['dir']??'';$group=(int)($o['group']??0); + if(!preg_match('~^/[A-Za-z0-9_./-]+$~D',$dir)||str_contains($dir,'..')||$group<1)throw new RuntimeException('Explicit private directory and group required'); + umask(0077);if(!is_dir($dir))mkdir($dir,0700,true); + if(is_link($dir)||(fileperms($dir)&0077))throw new RuntimeException('Private directory required'); + boot();$apply=isset($o['apply']); + $r=match($o['action']??''){'prepare'=>Acceptance::prepare($dir,$group,$apply),'source'=>Acceptance::source($dir,$group),'snapshot'=>Acceptance::source($dir,$group,true),'cleanup'=>Acceptance::cleanup($dir,$apply),default=>throw new RuntimeException('Unknown action')}; + echo json_encode($r,JSON_THROW_ON_ERROR).PHP_EOL; + }catch(\Throwable $e){fwrite(STDERR,json_encode(['state'=>'blocked','reason'=>'Inspect protected lease and effective authorization; no automatic overwrite']).PHP_EOL);exit(2);} +} diff --git a/scripts/node-onboarding/requirements.txt b/scripts/node-onboarding/requirements.txt new file mode 100644 index 0000000..e7f7b5d --- /dev/null +++ b/scripts/node-onboarding/requirements.txt @@ -0,0 +1 @@ +paramiko==5.0.0 diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py new file mode 100644 index 0000000..b569b66 --- /dev/null +++ b/scripts/node-onboarding/site_adapter.py @@ -0,0 +1,431 @@ +"""Reusable AnyTLS site adapter; invoked in-process by batch.py, never a daemon.""" +import base64,hashlib,json,os,shlex,threading,time +from pathlib import Path +from types import SimpleNamespace +from ssh_pool import Pool,private_json +from acceptance import audit,digest,snapshot,service_change,provider_state + +class SiteAdapter: + def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None): + if atomic_writer is None or resource_lock is None: + from batch import atomic_json,lock + atomic_writer,resource_lock=atomic_writer or atomic_json,resource_lock or lock + self.atomic_json,self.resource_lock=atomic_writer,resource_lock + self.plan,self.directory=plan,Path(directory) + self.ctx=plan['context'];self.lock=threading.RLock();self.leases={};self.formal_done={} + for field in ('ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','client_binary'): + value=self.ctx.get(field,'') + if not isinstance(value,str) or not value.startswith('/') or '..' in value.split('/') or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-' for c in value): + raise ValueError('Explicit safe absolute site path required: '+field) + config=private_json(self.ctx['ssh_config']) + for candidate in plan['nodes']: + if config['hosts'].get(candidate['vps_id'],{}).get('host')!=candidate['node_address']:raise ValueError('SSH target differs from frozen candidate address') + for role in ('jump_vps','panel_vps','gateway_vps'): + if self.ctx[role] not in config['hosts']:raise ValueError('Missing registered SSH role') + self.pool=pool or Pool(config,self.ctx['jump_vps']) + if not __import__('re').fullmatch('[0-9a-f]{64}',self.ctx.get('client_sha256','')):raise ValueError('Reviewed official client SHA256 required') + self.exit_registry=private_json(self.ctx['exit_registry']) + for candidate in plan['nodes']: + if self.exit_registry.get(candidate['tls_domain'])!={'expected_exit':candidate['expected_exit'],'country':candidate['country']}:raise ValueError('Candidate exit registry missing or changed') + if any(n['vps_id'] in [self.ctx['jump_vps'],self.ctx['panel_vps'],self.ctx['gateway_vps']] for n in plan['nodes']):raise ValueError('Dedicated node host required') + for key in ('app_root',): + if not __import__('re').fullmatch('/[A-Za-z0-9_./-]+',self.ctx[key]) or '..' in self.ctx[key].split('/'):raise ValueError('Unsafe application root') + if type(self.ctx.get('formal_socks_port')) is not int or not 1024<=self.ctx['formal_socks_port']<=65535 or self.ctx['formal_socks_port'] in [n['socks_port'] for n in plan['nodes']]:raise ValueError('Reserve a distinct formal SOCKS port') + if not self.ctx.get('gateway_containers') or any(not __import__('re').fullmatch('[A-Za-z0-9_.-]+',v) for v in self.ctx['gateway_containers']):raise ValueError('Explicit gateway container inventory required') + + def close(self): + # On waiting, failure and success, close owned formal leases before closing SSH. + failed=False + for group in list(self.leases): + try:self.account_command(group,'cleanup',True) + except Exception:failed=True + self.pool.close() + if failed: + self.atomic_json(self.directory/'cleanup-error.json',{'groups':list(self.leases),'reason':'cleanup_outcome_unknown','needs_probe':True}) + raise RuntimeError('Acceptance cleanup incomplete; inspect protected leases before retry') + + def account_command(self,group,action,apply=False,client=None): + panel=client or self.pool.get(self.ctx['panel_vps']) + directory=self.ctx['batch_backup_root']+'/acceptance-group-'+str(group) + cmd='cd '+self.ctx['app_root']+' && php '+self.ctx['ops_root']+'/panel-acceptance.php --action='+action+' --dir='+directory+' --group='+str(group)+(' --apply' if apply else '') + _,out,err=panel.exec_command(cmd,timeout=90);data=out.read();err.read() + if out.channel.recv_exit_status():raise RuntimeError('acceptance_account_blocked') + try:return json.loads(data) + except ValueError:raise RuntimeError('acceptance_account_response_not_json') from None + + def prepare_account(self,n,panel,execute): + with self.lock: + group=n['formal_group_id'] + for spec in self.plan['nodes']: + registered=self.exit_registry.get(spec['tls_domain'],{}) + if registered != {'expected_exit':spec['expected_exit'],'country':spec['country']}: + raise ValueError('Exit registry must independently cover the candidate plan') + if group not in self.leases: + # Register cleanup obligation before any possibly successful remote create. + self.leases[group]=True + self.account_command(group,'prepare',True,panel) + source=self.account_command(group,'source',client=panel) + for spec in source['nodes']: + if spec['sni'] not in self.exit_registry:raise ValueError('Missing old-node exit expectation') + + def certificate_gate(self,n,node,read,execute): + owner=read(node,'/etc/dpl-node/batch-owner.json') + owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256']) + script=r"""from pathlib import Path +import re +for name in ['/etc/letsencrypt/cli.ini','/root/.config/letsencrypt/cli.ini']: + p=Path(name) + if p.exists(): + for line in p.read_text().splitlines(): + if re.match(r'^\s*(pre[-_]hook|post[-_]hook|deploy[-_]hook)\s*=\s*\S',line):raise SystemExit(2) +domain=DOMAIN +paths=[Path('/etc/letsencrypt')/kind/domain for kind in ['live','archive']]+[Path('/etc/letsencrypt/renewal')/(domain+'.conf')] +if not OWNED and any(p.exists() or p.is_symlink() for p in paths):raise SystemExit(3) +p=paths[-1] +if p.exists(): + for line in p.read_text().splitlines(): + if re.match(r'^\s*(pre_hook|post_hook)\s*=\s*\S',line):raise SystemExit(4) + if re.match(r'^\s*deploy_hook\s*=',line) and line.split('=',1)[1].strip()!=HOOK:raise SystemExit(5) +""" + prefix='DOMAIN='+repr(n['tls_domain'])+';OWNED='+repr(owned)+';HOOK='+repr('/bin/sh '+self.ctx['node_root']+'/renew-node.sh')+'\n' + execute(node,"python3 - <<'DPLCERTGATE'\n"+prefix+script+"\nDPLCERTGATE") + execute(node,'command -v git >/dev/null && command -v curl >/dev/null && command -v certbot >/dev/null && command -v ss >/dev/null && command -v sha256sum >/dev/null && command -v openssl >/dev/null') + + def guard_service(self,node,backup,read,write,execute): + spec=self.ctx['preserved_service'] + for field in ('unit','binary','config','port'): + if field not in spec:raise ValueError('Explicit preserved service specification required') + if not all(isinstance(spec[k],str) and __import__('re').fullmatch('[A-Za-z0-9_./@-]+',spec[k]) for k in ('unit','binary','config')) or type(spec['port']) is not int: + raise ValueError('Unsafe preserved service specification') + script="""import hashlib,json,pathlib,subprocess +s=SPEC +v=dict(x.split('=',1) for x in subprocess.check_output(['systemctl','show',s['unit'],'-p','MainPID','-p','ActiveEnterTimestamp','-p','NRestarts','-p','ActiveState','-p','FragmentPath'],text=True).splitlines()) +def h(p):return hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() +listeners=subprocess.check_output(['ss','-lntH','( sport = :'+str(s['port'])+' )'],text=True) +print(json.dumps({'unit_sha256':h(v['FragmentPath']),'binary_sha256':h(s['binary']),'config_sha256':h(s['config']),'listeners':sorted(x.split()[3] for x in listeners.splitlines()),'pid':v['MainPID'],'started':v['ActiveEnterTimestamp'],'restarts':v['NRestarts'],'active':v['ActiveState']=='active'})) +""".replace('SPEC',repr(spec)) + current=json.loads(execute(node,"python3 - <<'DPLSERVICE'\n"+script+"\nDPLSERVICE")) + if not current['active'] or not current['listeners']:raise RuntimeError('preserved_service_unavailable') + execute(node,'install -d -m 700 '+backup) + p=backup+'/preserved-service-original.json';raw=read(node,p) + if raw is None:write(node,p,json.dumps(current));return current + before=json.loads(raw);status=service_change(before,current) + if status=='investigate_restart': + write(node,backup+'/preserved-service-observed.json',json.dumps(current)) + review=read(node,backup+'/preserved-service-review.json') + review=json.loads(review) if review else {} + if review.get('before_sha256')!=digest(before) or review.get('after_sha256')!=digest(current) or not review.get('reason') or not review.get('evidence_sha256'): + raise RuntimeError('investigate_preserved_service_restart') + elif status!='ready':raise RuntimeError('preserved_service_'+status) + return current + + def dependencies(self,n,panel,gateway,node,stage_probe,read,execute,gateway_py): + """Current dependency reads; a missing observation cannot authorize a skip.""" + state=stage_probe(panel);deps={'stage':digest(state)} + ui=provider_state(private_json(self.ctx['provider_snapshot']),n) + try: + dns=json.loads(gateway_py(gateway,'import socket,json\nprint(json.dumps(sorted(set(x[4][0] for x in socket.getaddrinfo('+repr(n['tls_domain'])+',443,socket.AF_INET)))))')) + if dns==[n['node_address']]:deps['dns']=digest(dns) + except Exception:pass + try: + live=execute(node,'systemctl is-active dpl-node; git -C '+self.ctx['node_root']+' rev-parse HEAD; sha256sum /etc/dpl-node/config.yml /etc/dpl-node/secret.env /etc/systemd/system/dpl-node.service /etc/letsencrypt/live/'+n['tls_domain']+'/fullchain.pem') + deps['provision']=digest(live) + # End-to-end strict TLS at preflight is necessary to reuse a relay observation. + tls=gateway_py(gateway,'import ssl,socket\nc=ssl.create_default_context()\nwith socket.create_connection(('+repr(n['relay_host'])+','+str(n['relay_port'])+'),10) as s:\n with c.wrap_socket(s,server_hostname='+repr(n['tls_domain'])+') as t:print(t.version())') + rows=ui['relays'] + deps['relay']=digest([rows,tls]) + report=read(panel,self.ctx['node_backup_root']+'/'+n['key']+'/qa-report.json') + if report:deps['verify']=digest([state,live,hashlib.sha256(report).hexdigest()]) + except Exception:pass + return deps + + def formal(self,n,panel,gateway,php,execute,read,write): + group=n['formal_group_id'];ctx=self.ctx + with self.lock, self.resource_lock(self.directory.parent, ctx['gateway_vps']+':formal:'+str(ctx['formal_socks_port'])): + source=self.account_command(group,'snapshot' if group in self.formal_done else 'source',client=panel) + def public(source): + values=[] + for item in source['nodes']: + if item['sni'] not in self.exit_registry:raise ValueError('Unregistered current node exit') + values.append(dict(item,**self.exit_registry[item['sni']])) + return snapshot(values) + before=public(source) + # Do not run final verification while any candidate in this group is still isolated. + cohort=[x for x in self.plan['nodes'] if x['formal_group_id']==group] + wanted={(x['relay_host'],x['relay_port'],x['tls_domain']) for x in cohort} + if not wanted.issubset({(x['relay'],x['relay_port'],x['sni']) for x in before}):raise RuntimeError('formal_group_not_ready') + if group in self.formal_done: + prior=self.formal_done[group] + return audit(prior['before'],before,prior['report'],prior['qas'],prior['binding'],digest(self.plan),prior['expected_qas']) + root=ctx['qa_root']+'/formal-group-'+str(group) + execute(gateway,'install -d -m 700 '+root) + expectations=[] + for node in before: + original=next(x for x in source['nodes'] if x['id']==node['id']) + expectations.append({'tag':node['tag'],'host':node['relay'],'port':node['relay_port'],'sni':node['sni'],'uuid':original['uuid'],'expected_exit':node['expected_exit'],'expected_country':node['country']}) + # Tokens/UUIDs never leave memory except the gateway's private task files. + write(gateway,root+'/source-private.json',json.dumps({'token':source['token'],'expectations':expectations,'owned':source['owned']})) + script="""import gzip,json,urllib.request,subprocess,os,time +from pathlib import Path +os.umask(0o077);root=Path(ROOT);source=json.loads((root/'source-private.json').read_text()) +r=urllib.request.Request(SUB+'/api/v1/client/subscribe?token='+source['token'],headers={'User-Agent':'sing-box/1.14.3'}) +with urllib.request.urlopen(r,timeout=30) as response: + data=response.read();headers={k.lower():v for k,v in response.headers.items()} + if headers.get('content-encoding','').lower()=='gzip':data=gzip.decompress(data) +assert headers.get('cf-cache-status','').upper() not in ['HIT','STALE','UPDATING'] and not headers.get('age') and not headers.get('cf-mitigated') and 'no-store' in headers.get('cache-control','').lower() +(root/'subscription.json').write_bytes(data);(root/'expectations.json').write_text(json.dumps(source['expectations'])) +deadline=time.monotonic()+900 +for attempt in range(3 if source['owned'] else 1): + with (root/'verify.stdout').open('wb') as out,(root/'verify.stderr').open('wb') as err: + result=subprocess.run(['python3',OPS+'/verify-node.py','--subscription',str(root/'subscription.json'),'--expectations',str(root/'expectations.json'),'--binary',BINARY,'--port',str(PORT),'--report',str(root/'report.json'),'--run'],timeout=max(1,deadline-time.monotonic()),stdout=out,stderr=err) + if result.returncode==0:break + try:reason=json.loads((root/'verify.stderr').read_text()).get('reason') + except Exception:reason=None + if not source['owned'] or attempt==2 or reason!='Verification failed; inspect protected diagnostics' or deadline-time.monotonic()<15:raise RuntimeError('Formal verification failed') + # A just-created user's UUID may need one node pull interval to propagate. + # Retry only diagnostic reads, keep the same lease; never replay production writes. + time.sleep(15) +""" + prefix='ROOT='+repr(root)+';SUB='+repr(ctx['subscription_url'])+';OPS='+repr(ctx['ops_root'])+';BINARY='+repr(ctx['client_binary'])+';PORT='+repr(ctx['formal_socks_port'])+'\n' + try: + execute(gateway,"python3 - <<'DPLFORMAL'\n"+prefix+script+"\nDPLFORMAL",1000) + after=public(self.account_command(group,'source',client=panel)) + report=read(gateway,root+'/report.json') + finally: + # Never leave an owned formal test user active, including failed client runs. + self.account_command(group,'cleanup',True,panel) + identities={};tokens={} + for candidate in cohort: + b=ctx['node_backup_root']+'/'+candidate['key'] + result=php(panel,'$m=json_decode(file_get_contents('+self.php_value(b+'/manifest.json')+'),true);$s=App\\Models\\Server::findOrFail($m["node_id"]);$u=App\\Models\\User::findOrFail($m["test_user_id"]);echo json_encode(["id"=>$u->id,"token"=>$u->token,"banned"=>(bool)$u->banned,"excluded"=>!App\\Services\\ServerService::getAvailableUsers($s)->contains("id",$u->id)]);') + identities[candidate['key']]=result['id'];tokens[candidate['key']]=result + # Include every owned formal lease from earlier resumptions, not just the current user. + lease_dir=ctx['batch_backup_root']+'/acceptance-group-'+str(group) + leases=php(panel,'$out=[];foreach(glob('+self.php_value(lease_dir+'/acceptance-*.json')+') as $f){$j=json_decode(file_get_contents($f),true);if(!($j["owned"]??false)||empty($j["id"]))continue;$u=App\\Models\\User::findOrFail($j["id"]);$excluded=true;foreach(App\\Models\\Server::all() as $s)if(App\\Services\\ServerService::getAvailableUsers($s)->contains("id",$u->id))$excluded=false;$out[(string)$u->id]=["id"=>$u->id,"token"=>$u->token,"banned"=>(bool)$u->banned,"excluded"=>$excluded];}echo json_encode($out);') + for ident,value in (leases.items() if isinstance(leases,dict) else []): + key='formal-'+str(ident);identities[key]=value['id'];tokens[key]=value + users={} + for key,value in tokens.items(): + # Only the gateway accesses the subscription source; local HTTP never bypasses its registered route. + script='import urllib.request,urllib.error\nr=urllib.request.Request('+repr(ctx['subscription_url']+'/api/v1/client/subscribe?token='+value['token'])+',headers={"User-Agent":"sing-box/1.14.3"})\ntry:\n response=urllib.request.urlopen(r,timeout=30);print(response.status);response.close()\nexcept urllib.error.HTTPError as e:print(e.code)' + # Keep tokens out of argv: exec_command carries a private stdin script, not an OS argv. + si,o,e=gateway.exec_command('python3 -',timeout=60);si.write(script);si.channel.shutdown_write();out=o.read();e.read() + if o.channel.recv_exit_status():raise RuntimeError('qa_rejection_probe_failed') + users[key]={'id':value['id'],'banned':value['banned'],'excluded':value['excluded'],'http_status':int(out.strip())} + qas=json.dumps({'users':users,'captured_at':int(time.time()),'plan_sha256':digest(self.plan)},sort_keys=True).encode() + binding={'plan_sha256':digest(self.plan),'snapshot_sha256':digest(before),'report_sha256':hashlib.sha256(report).hexdigest(),'qa_rejections_sha256':hashlib.sha256(qas).hexdigest(),'verified_at':int(time.time())} + audit(before,after,report,qas,binding,digest(self.plan),identities) + write(gateway,root+'/qa-rejections.json',qas);write(gateway,root+'/binding.json',json.dumps(binding));write(gateway,root+'/snapshot.json',json.dumps(before)) + self.formal_done[group]={'before':before,'report':report,'qas':qas,'binding':binding,'expected_qas':identities} + # Persist only nonsecret, byte-bound results for coordinator/operator review. + self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) + + @staticmethod + def php_value(value): + return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)" + + def call(self,n,phase,action): + plan=self.plan;ctx=self.ctx;key=n['key'];sha=n['request_sha256'] + a=SimpleNamespace(phase=phase,action=action,state_dir=str(self.directory)) + B=ctx['node_backup_root']+'/'+key;Q=ctx['qa_root']+'/'+key + OPS=ctx['ops_root'];NP=ctx['node_root'];APP=ctx['app_root'] + def execute(c,cmd,timeout=120): + si,o,e=c.exec_command(cmd,timeout=timeout);out=o.read();err=e.read();code=o.channel.recv_exit_status() + if code: raise RuntimeError('remote_exit_'+str(code)) + return out.decode() + def php(c,body): + si,o,e=c.exec_command('cd '+shlex.quote(APP)+' && php',timeout=90) + si.write("make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();"+body) + si.channel.shutdown_write();out=o.read();err=e.read() + if o.channel.recv_exit_status():raise RuntimeError('panel_php_failed') + try:return json.loads(out) + except ValueError:raise RuntimeError('panel_response_not_json') from None + def payload(value):return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value,ensure_ascii=False).encode()).decode()+"'),true)" + def read(c,path): + with c.open_sftp() as s: + try: + with s.open(path,'rb') as f:return f.read() + except FileNotFoundError:return None + except OSError as e: + if e.errno==2:return None + raise + def write(c,path,data): + if isinstance(data,str):data=data.encode() + with c.open_sftp() as s: + tmp=path+'.tmp-'+str(os.getpid())+'-'+str(threading.get_ident()) + with s.open(tmp,'w') as f:s.chmod(tmp,0o600);f.write(data) + s.posix_rename(tmp,path) + def parameters():return dict(sorted({'key':key,'name':n['name'],'relay-host':n['relay_host'],'relay-port':n['relay_port'],'listen-port':n['listen_port'],'tls-domain':n['tls_domain'],'panel-url':ctx['panel_url']}.items())) + def canonical(value):return hashlib.sha256(json.dumps(value,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode()).hexdigest() + def containers(gateway): + data=json.loads(execute(gateway,'docker inspect '+ ' '.join(ctx['gateway_containers']))) + return {v['Name']:{'id':v['Id'],'started':v['State']['StartedAt'],'restarts':v['RestartCount']} for v in data} + def receipt(status,retry=False):return {'key':key,'phase':a.phase,'request_sha256':sha,'status':status,'valid_until':time.time()+300,**({'retry_safe':True} if retry else {})} + def stage_probe(panel): + p=parameters() + code='$b='+payload(B)+';$p='+payload(p)+r''';$f=$b.'/manifest.json';if(!is_file($f)){$conf=App\Models\ServerMachine::where('name','dpl-'.$p['key'])->exists()||App\Models\Server::where('name',$p['name'])->exists()||App\Models\Server::where('host',$p['relay-host'])->where('port',(string)$p['relay-port'])->exists();echo json_encode(['status'=>$conf?'conflict':'absent','retry_safe'=>!$conf]);return;}$m=json_decode(file_get_contents($f),true);$s=App\Models\Server::find($m['node_id']);$mch=App\Models\ServerMachine::find($m['machine_id']);$ok=($m['parameters']??null)===$p && $s && $mch && $s->machine_id===$mch->id && $mch->name==='dpl-'.$p['key'] && $s->type==='anytls' && ($s->protocol_settings['tls']['allow_insecure']??null)===false && $s->name===$p['name'] && $s->host===$p['relay-host'] && (int)$s->port===$p['relay-port'] && (int)$s->server_port===$p['listen-port'] && ($s->protocol_settings['tls']['server_name']??null)===$p['tls-domain'];$u=App\Models\User::find($m['test_user_id']);$g=App\Models\ServerGroup::find($m['group_id']);$ok=$ok && $u && $g && !$u->is_admin && (int)$u->group_id===$m['group_id'] && str_starts_with($u->email,'node-qa-'.$p['key'].'-') && $g->name==='dpl 接入验收 '.$p['key'];if(!is_file($b.'/promotion-journal.json'))$ok=$ok && $s->group_ids===[(string)$m['group_id']] && !$u->banned;echo json_encode(['status'=>$ok?'ready':'conflict','manifest'=>$m]);''' + return php(panel,code) + def state(panel): + return php(panel,'$b='+payload(B)+r''';$m=json_decode(file_get_contents($b.'/manifest.json'),true);$s=App\Models\Server::findOrFail($m['node_id']);$u=App\Models\User::findOrFail($m['test_user_id']);echo json_encode(['manifest'=>$m,'node_online'=>(bool)$s->is_online,'groups'=>$s->group_ids,'qa_banned'=>(bool)$u->banned,'u'=>(int)$u->u,'d'=>(int)$u->d]);''') + def baseline(panel,gateway): + directory=ctx['batch_backup_root'];f=directory+'/baseline.json' + version=json.loads(execute(panel,"python3 - <<'DPLVERSION'\nimport subprocess,hashlib,json\np="+repr(APP)+"\nh=subprocess.check_output(['git','-C',p,'rev-parse','HEAD']).decode().strip()\nd=subprocess.check_output(['git','-C',p,'diff','HEAD'])+subprocess.check_output(['git','-C',p,'status','--porcelain'])\nprint(json.dumps({'head':h,'worktree_sha256':hashlib.sha256(d).hexdigest()}))\nDPLVERSION")) + previous=read(panel,directory+'/application-version.json') + if previous is None:write(panel,directory+'/application-version.json',json.dumps(version)) + elif json.loads(previous)!=version:raise RuntimeError('application_version_changed') + # Read/write a protected snapshot only once, before any new stage. + return php(panel,'$p='+payload(f)+r''';if(is_file($p)){echo json_encode(['exists'=>true]);return;} $v=['nodes'=>App\Models\Server::orderBy('id')->get()->toArray(),'plans'=>App\Models\Plan::orderBy('id')->get()->toArray(),'users'=>App\Models\User::orderBy('id')->get(['id','group_id','plan_id'])->toArray(),'subscribe_url'=>admin_setting('subscribe_url')];file_put_contents($p,json_encode($v,JSON_THROW_ON_ERROR));chmod($p,0600);echo json_encode(['exists'=>true]);''') + def gateway_py(gateway,script,timeout=120): + return execute(gateway,"python3 - <<'DPLPY'\n"+script+"\nDPLPY",timeout) + def assert_old(node): + self.guard_service(node,B,read,write,execute) + def transfer(panel,gateway,node): + execute(node,'install -d -m 700 /etc/dpl-node') + execute(gateway,'install -d -m 700 '+Q) + for source,dest,target in [('node-config.yml',node,'/etc/dpl-node/config.yml'),('node-secret.env',node,'/etc/dpl-node/secret.env'),('qa-private.json',gateway,Q+'/qa.json')]: + data=read(panel,B+'/'+source);prior=read(dest,target) + if prior is not None and prior!=data:raise RuntimeError('private_config_conflict') + if prior is None:write(dest,target,data) + def fetch_qa(gateway): + script='''import json,urllib.request + +from pathlib import Path + +q=json.loads(Path(Q+'/qa.json').read_text()) + +req=urllib.request.Request(SUB+'/api/v1/client/subscribe?token='+q['subscribe_token'],headers={'User-Agent':'sing-box/1.14.3'}) + +with urllib.request.urlopen(req,timeout=30) as r: + + data=r.read(); code=r.status; headers={k.lower():v for k,v in r.headers.items()} + +import gzip + +if headers.get('content-encoding','').lower()=='gzip':data=gzip.decompress(data) + +v=json.loads(data);Path(Q+'/subscription.json').write_bytes(data);Path(Q+'/subscription.json').chmod(0o600) + +print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control':headers.get('cache-control'),'age':headers.get('age')})) + +'''.replace('\n+','\n') + return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script) + def phase_impl(panel,gateway,node): + if a.phase=='preflight': + with self.lock, self.resource_lock(self.directory.parent,ctx['panel_vps']+':panel'): + execute(panel,'install -d -m 700 '+ctx['batch_backup_root']) + execute(panel,'test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"') + baseline(panel,gateway) + base_path=ctx['batch_backup_root']+'/gateway-baseline.json' + current_containers=containers(gateway) + prior_gateway=read(panel,base_path) + if prior_gateway is None:write(panel,base_path,json.dumps(current_containers,sort_keys=True)) + elif json.loads(prior_gateway)!=current_containers:return receipt('conflict') + ui=Path(ctx['provider_snapshot']) + if not ui.exists():return receipt('waiting') + snap=private_json(ui) + if type(snap.get('captured_at')) not in (int,float) or not 0<=time.time()-snap['captured_at']<=900:return receipt('waiting') + try:provider_state(snap,n) + except ValueError:return receipt('conflict') + gateway_py(gateway,'import socket\ns=socket.socket();s.bind(("127.0.0.1",'+str(n['socks_port'])+'));s.close()') + s=stage_probe(panel) + if s['status']=='conflict':return receipt('conflict') + assert_old(node) + self.certificate_gate(n,node,read,execute) + execute(node,'test "$(git -C '+NP+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+NP+' status --porcelain)" && command -v git >/dev/null') + execute(gateway,"echo '"+ctx['client_sha256']+" "+ctx['client_binary']+"' | sha256sum -c --status && test -x "+shlex.quote(ctx['client_binary'])+' && test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"') + if s['status']=='absent':execute(node,"test ! -e /etc/dpl-node/config.yml && test ! -e /etc/systemd/system/dpl-node.service && test ! -e /etc/dpl-node/batch-owner.json && ! ss -lntH '( sport = :"+str(n['listen_port'])+" )' | grep -q .") + if not php(panel,'echo json_encode(App\\Models\\ServerGroup::where("id",'+str(n['formal_group_id'])+')->exists());'):return receipt('conflict') + self.prepare_account(n,panel,execute) + r=receipt('ready');r['resume_dependencies']=self.dependencies(n,panel,gateway,node,stage_probe,read,execute,gateway_py);return r + if a.phase=='stage': + if a.action=='probe': + r=stage_probe(panel);return receipt(r['status'],r.get('retry_safe',False)) + command='cd '+APP+' && php '+OPS+'/stage.php '+ ' '.join('--'+k+'='+shlex.quote(str(v)) for k,v in {**parameters(),'backup':B}.items()) + execute(panel,command);return receipt('ready') + if a.phase=='dns': + try: + result=gateway_py(gateway,'import socket,json\nprint(json.dumps(sorted(set(x[4][0] for x in socket.getaddrinfo('+repr(n['tls_domain'])+',443,socket.AF_INET)))))') + return receipt('ready' if json.loads(result)==[n['node_address']] else 'conflict') + except Exception:return receipt('waiting') + if a.phase=='provision': + owner='/etc/dpl-node/batch-owner.json';prior=read(node,owner) + if prior is not None and json.loads(prior).get('request_sha256')!=sha:return receipt('conflict') + if prior is None and read(node,'/etc/systemd/system/dpl-node.service') is not None:return receipt('conflict') + done=read(node,B+'/provision-ok.json') + if a.action=='probe': + if done and json.loads(done).get('request_sha256')==sha: + for file,target in [('node-config.yml','/etc/dpl-node/config.yml'),('node-secret.env','/etc/dpl-node/secret.env')]: + if read(panel,B+'/'+file)!=read(node,target):return receipt('conflict') + execute(node,"echo '55bf71fa9d9f2048d3255ae7c0af929a41897ca7743f6ead34132a6ca4c79043 /opt/dpl-node/xboard-node' | sha256sum -c >/dev/null") + assert_old(node);execute(node,'systemctl is-active --quiet dpl-node && openssl x509 -checkend 86400 -noout -in /etc/letsencrypt/live/'+n['tls_domain']+'/fullchain.pem') + return receipt('ready') + return receipt('absent',True) + if prior is None: + execute(node,'install -d -m 700 /etc/dpl-node');write(node,owner,json.dumps({'request_sha256':sha,'key':key})) + transfer(panel,gateway,node) + if read(node,'/etc/systemd/system/dpl-node.service') is None: + execute(node,'bash '+NP+'/install-node.sh --listen-port='+str(n['listen_port'])+' --tls-domain='+n['tls_domain']+' > '+B+'/install-node.log 2>&1',300) + execute(node,"echo '55bf71fa9d9f2048d3255ae7c0af929a41897ca7743f6ead34132a6ca4c79043 /opt/dpl-node/xboard-node' | sha256sum -c >/dev/null") + domain=n['tls_domain'] + if read(node,'/etc/letsencrypt/live/'+domain+'/fullchain.pem') is None: + execute(node,"certbot certonly --standalone --non-interactive --agree-tos --no-directory-hooks --cert-name "+domain+' -d '+domain+" --deploy-hook '/bin/sh "+NP+"/renew-node.sh' > "+B+'/certbot.log 2>&1',300) + execute(node,'systemctl enable --now dpl-node > '+B+'/enable.log 2>&1 && systemctl is-active --quiet dpl-node',60) + execute(node,'certbot renew --cert-name '+domain+' --dry-run --no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks > '+B+'/renew-dry-run.log 2>&1',600) + assert_old(node) + write(node,B+'/provision-ok.json',json.dumps({'request_sha256':sha,'verified_at':int(time.time())})) + return receipt('ready') + if a.phase=='relay': + try: + result=gateway_py(gateway,'import ssl,socket,json\nc=ssl.create_default_context()\nwith socket.create_connection(('+repr(n['relay_host'])+','+str(n['relay_port'])+'),10) as s:\n with c.wrap_socket(s,server_hostname='+repr(n['tls_domain'])+') as t: print(json.dumps({"tls":t.version()}))') + return receipt('ready' if json.loads(result).get('tls') else 'waiting') + except Exception:return receipt('waiting') + if a.phase=='verify': + s=state(panel) + if s['qa_banned'] and s['groups']==[str(n['formal_group_id'])]: + return receipt('ready' if read(panel,B+'/qa-report.json') else 'unknown') + existing=read(panel,B+'/qa-report.json') + if existing: + report=json.loads(existing) + if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']+s['d']>0: + r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r + transfer(panel,gateway,node);fetch_qa(gateway) + execute(gateway,'python3 '+OPS+'/verify-node.py --qa '+Q+'/qa.json --subscription '+Q+'/subscription.json --binary '+ctx['client_binary']+' --port '+str(n['socks_port'])+' --expected-exit '+shlex.quote(n['expected_exit'])+' --expected-country '+n['country']+' --report '+Q+'/qa-report.json --run > '+Q+'/verify.stdout 2> '+Q+'/verify.stderr',300) + data=read(gateway,Q+'/qa-report.json');report=json.loads(data) + write(panel,B+'/qa-report.json',data) + # Reports contain endpoints and measured results, no passwords or tokens. + evidence={'key':key,'request_sha256':sha,'report':report,'panel':state(panel)} + (Path(a.state_dir)/(key+'-evidence.json')).write_text(json.dumps(evidence,ensure_ascii=False,indent=2)) + for _ in range(24): + s=state(panel) + if s['node_online'] and s['u']+s['d']>0: + r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r + time.sleep(5) + return receipt('waiting') + if a.phase=='publish': + s=state(panel) + report_data=read(panel,B+'/qa-report.json') + review_path=Path(a.state_dir)/(key+'-reviewed.json') + if not report_data or not review_path.exists():return receipt('waiting') + report=json.loads(report_data);review=json.loads(review_path.read_text()) + valid=review.get('request_sha256')==sha and review.get('report_sha256')==hashlib.sha256(report_data).hexdigest() and review.get('qa_user_id')==s['manifest']['test_user_id'] and review.get('verified_at')==report.get('verified_at') + if not valid:return receipt('waiting') + if s['qa_banned'] and s['groups']==[str(n['formal_group_id'])]: + journal=read(panel,B+'/promotion-journal.json') + if not journal:return receipt('conflict') + j=json.loads(journal) + return receipt('ready' if j.get('manifest')==s['manifest'] and j.get('request',{}).get('mode')=='append' and j.get('request',{}).get('group')==n['formal_group_id'] and j.get('state')=='applied' else 'conflict') + if not s['node_online'] or s['u']+s['d']<=0:return receipt('waiting') + if a.action=='probe':return receipt('absent',True) + cmd='cd '+APP+' && php '+OPS+'/promote.php --backup='+B+' --mode=append --group='+str(n['formal_group_id'])+' --verification='+B+'/qa-report.json' + execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready') + if a.phase=='audit': + s=state(panel);assert_old(node) + if not s['qa_banned'] or s['groups']!=[str(n['formal_group_id'])] or not s['node_online']:return receipt('conflict') + ok=php(panel,'$b='+payload(B)+r''';$m=json_decode(file_get_contents($b.'/manifest.json'),true);$n=App\Models\Server::findOrFail($m['node_id']);$base=json_decode(file_get_contents('''+payload(ctx['batch_backup_root']+'/baseline.json')+r'''),true);$ok=$base['plans']===App\Models\Plan::orderBy('id')->get()->toArray() && $base['users']===App\Models\User::whereIn('id',array_column($base['users'],'id'))->orderBy('id')->get(['id','group_id','plan_id'])->toArray() && $base['subscribe_url']===admin_setting('subscribe_url');foreach($base['nodes'] as $old){$cur=App\Models\Server::find($old['id'])->toArray();foreach(['name','host','port','server_port','protocol_settings','group_ids','machine_id','enabled','show','cert_config'] as $k)if(($cur[$k]??null)!==($old[$k]??null))$ok=false;}$ids=App\Services\ServerService::getAvailableUsers($n)->pluck('id')->all();$ok=$ok&&!in_array($m['test_user_id'],$ids);echo json_encode($ok);''') + if not ok:return receipt('conflict') + self.formal(n,panel,gateway,php,execute,read,write) + original_gateway=read(panel,ctx['batch_backup_root']+'/gateway-baseline.json') + if not original_gateway or containers(gateway)!=json.loads(original_gateway):return receipt('conflict') + return receipt('ready') + return receipt('unknown') + panel=Lazy(self.pool,ctx['panel_vps']);gateway=Lazy(self.pool,ctx['gateway_vps']);node=Lazy(self.pool,n['vps_id']) + try:return phase_impl(panel,gateway,node) + except Exception as exc: + # Never print underlying library/server errors or credentials. + safe = str(exc) if type(exc) is RuntimeError and __import__('re').fullmatch('[a-z_0-9]+',str(exc)) else 'inspect_protected_state' + self.atomic_json(self.directory/(key+'-'+phase+'-error.json'),{'reason':safe}) + return receipt('waiting' if safe in ('investigate_preserved_service_restart','formal_group_not_ready') else 'unknown') + +class Lazy: + def __init__(self,pool,rid):self.pool,self.rid=pool,rid + def __getattr__(self,name):return getattr(self.pool.get(self.rid),name) diff --git a/scripts/node-onboarding/ssh_pool.py b/scripts/node-onboarding/ssh_pool.py new file mode 100644 index 0000000..35b602a --- /dev/null +++ b/scripts/node-onboarding/ssh_pool.py @@ -0,0 +1,99 @@ +"""Thread-local SSH sessions, mandatory explicit jump, strict known-host keys.""" +import json +import hashlib +import base64 +import re +import threading +from pathlib import Path + + +def private_text(path): + p = Path(path) + if not p.is_absolute() or p.is_symlink() or not p.is_file() or p.stat().st_mode & 0o077: + raise ValueError('Private regular absolute credential/config reference required') + return p.read_text() + + +def private_json(path): + return json.loads(private_text(path)) + + +def credentials(spec): + if not spec.get('credential_ref'):return {} + if spec.get('credential_format','json')=='json':return private_json(spec['credential_ref']) + if spec['credential_format']=='codocs-password': + matches=re.findall(r'(?m)^[ \t]*(?:[-*][ \t]+)?密码[::][ \t]*([^\r\n]+)$',private_text(spec['credential_ref'])) + if len(matches)!=1:raise ValueError('Unambiguous existing CoDocs password field required') + return {'password':matches[0].strip()} + raise ValueError('Unsupported credential reference format') + + +class Pool: + def __init__(self, config, jump_id, client_factory=None): + self.config, self.jump_id = config, jump_id + if jump_id not in config['hosts'] or config['hosts'][jump_id].get('role') != 'white_jump': + raise ValueError('Explicit registered white jump required') + if client_factory is None: + known=Path(config['known_hosts']) + if not known.is_absolute() or known.is_symlink() or not known.is_file() or known.stat().st_mode & 0o022:raise ValueError('Explicit trusted known-hosts file required') + if any(not re.fullmatch('SHA256:[A-Za-z0-9+/]{43}=?',s.get('host_key_sha256','')) for s in config['hosts'].values()):raise ValueError('Approved explicit host fingerprints required') + self.factory = client_factory + self.local = threading.local() + self.clients = [] + self.lock = threading.Lock() + + def get(self, rid): + sessions = getattr(self.local, 'sessions', None) + if sessions is None: + sessions = self.local.sessions = {} + if rid in sessions: + c = sessions[rid] + if c.get_transport() and c.get_transport().is_active(): + return c + # Do not transparently retry operations after a lost connection. + raise RuntimeError('SSH session lost; probe remote outcome on next run') + spec = self.config['hosts'][rid] + jump = self.get(self.jump_id) if rid != self.jump_id else None + if self.factory: + client = self.factory(rid, spec, jump) + else: + import paramiko + client = paramiko.SSHClient() + known=Path(self.config['known_hosts']) + if not known.is_absolute() or known.is_symlink() or not known.is_file() or known.stat().st_mode & 0o022:raise ValueError('Trusted non-writable known-hosts reference required') + entries=paramiko.HostKeys(str(known)) + hostname=spec['host'] if spec.get('port',22)==22 else '['+spec['host']+']:'+str(spec['port']) + approved=[] + for key in (entries.lookup(hostname) or {}).values(): + actual='SHA256:'+base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip('=') + if actual==spec['host_key_sha256'].rstrip('='):approved.append(key) + if not approved:raise ValueError('Approved known-host key absent; no credentials transmitted') + for key in approved:client.get_host_keys().add(hostname,key.get_name(),key) + client.set_missing_host_key_policy(paramiko.RejectPolicy()) + auth = credentials(spec) + if set(auth) - {'username', 'password', 'key_filename', 'passphrase'}: + raise ValueError('Unexpected SSH credential fields') + if auth.get('username',spec['username'])!=spec['username']:raise ValueError('Credential username differs from registered host') + if auth.get('key_filename'): + key=Path(auth['key_filename']) + if not key.is_absolute() or not key.is_file() or key.stat().st_mode & 0o077:raise ValueError('Private absolute SSH key required') + sock = jump.get_transport().open_channel('direct-tcpip', (spec['host'], spec.get('port', 22)), ('127.0.0.1', 0)) if jump else None + try: + client.connect(spec['host'], port=spec.get('port', 22), username=spec['username'], + password=auth.get('password'), key_filename=auth.get('key_filename'), + passphrase=auth.get('passphrase'), sock=sock, allow_agent=False, + look_for_keys=False, timeout=20, banner_timeout=20, auth_timeout=20) + except Exception: + client.close() + raise RuntimeError('SSH connect failed; inspect registered route/host key/credential reference') from None + finally: + auth.clear() + sessions[rid] = client + with self.lock: + self.clients.append(client) + return client + + def close(self): + for client in reversed(self.clients): + client.close() + self.clients.clear() diff --git a/tests/node-onboarding/AcceptanceUserIsolatedTest.php b/tests/node-onboarding/AcceptanceUserIsolatedTest.php new file mode 100644 index 0000000..fa49600 --- /dev/null +++ b/tests/node-onboarding/AcceptanceUserIsolatedTest.php @@ -0,0 +1,66 @@ +instance('app',$app); + $app->instance('config',new Illuminate\Config\Repository(['cache'=>['default'=>'array','stores'=>['array'=>['driver'=>'array']]],'app'=>['key'=>'offline-test']])); + $events=new Illuminate\Events\Dispatcher($app);$app->instance('events',$events); + $this->db=new Capsule($app);$this->db->addConnection(['driver'=>'sqlite','database'=>':memory:']);$this->db->setEventDispatcher($events);$this->db->setAsGlobal();$this->db->bootEloquent();$app->instance('db',$this->db->getDatabaseManager());$app->singleton('cache',fn($a)=>new Illuminate\Cache\CacheManager($a)); + $s=$this->db->schema(); + $s->create('v2_user',function($t){$t->increments('id');$t->string('email')->unique();foreach(['uuid','token','password'] as $x)$t->string($x);$t->integer('group_id')->nullable();foreach(['u','d'] as $x)$t->integer($x)->default(0);$t->integer('transfer_enable');$t->integer('expired_at')->nullable();foreach(['banned','is_admin','remind_expire','remind_traffic'] as $x)$t->boolean($x)->default(0);foreach(['device_limit','speed_limit'] as $x)$t->integer($x)->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();}); + $s->create('v2_server_group',function($t){$t->increments('id');$t->string('name');});Capsule::table('v2_server_group')->insert([['id'=>1,'name'=>'formal'],['id'=>2,'name'=>'other']]); + $s->create('v2_server',function($t){$t->increments('id');foreach(['name','type','host','port'] as $x)$t->string($x);foreach(['group_ids','protocol_settings'] as $x)$t->text($x);$t->boolean('enabled')->default(1);$t->boolean('show')->default(1);$t->integer('sort')->default(1);foreach(['u','d'] as $x)$t->integer($x)->default(0);$t->integer('transfer_enable')->nullable();$t->float('rate')->default(1);$t->timestamps();}); + $this->node();$this->dir=sys_get_temp_dir().'/dpl-acceptance-'.bin2hex(random_bytes(8));mkdir($this->dir,0700); + } + protected function tearDown():void{foreach(glob($this->dir.'/*') as $p)unlink($p);rmdir($this->dir);$this->db->getDatabaseManager()->disconnect();} + private function node():void {$i=Server::count()+1;Server::create(['name'=>'node'.$i,'type'=>'anytls','host'=>'relay.example.invalid','port'=>(string)(47000+$i),'group_ids'=>['1'],'protocol_settings'=>['tls'=>['server_name'=>'n'.$i.'.example.invalid','allow_insecure'=>false]]]);} + private function user(array $more=[]):User{return User::create(array_replace(['email'=>'existing@example.invalid','uuid'=>'existing-uuid','token'=>'existing-token','password'=>'unused','group_id'=>1,'transfer_enable'=>1000000,'expired_at'=>null],$more));} + private function reject(callable $f):void{$caught=false;try{$f();}catch(RuntimeException $e){$caught=true;}self::assertTrue($caught);} + public function testPreviewDoesNotCreateUserOrLease():void { + self::assertSame('would_create',Acceptance::prepare($this->dir,1,false)['state']);self::assertSame(0,User::count());self::assertFileDoesNotExist($this->dir.'/acceptance-user.json'); + } + public function testCreatesBoundedNonAdminAndUsesDynamicActualNodeSet():void { + $r=Acceptance::prepare($this->dir,1,true);$u=User::findOrFail($r['id']);self::assertTrue($r['owned']);self::assertFalse($u->is_admin);self::assertSame(104857600,(int)$u->transfer_enable);self::assertLessThanOrEqual(time()+3600,(int)$u->expired_at);self::assertGreaterThan(time()+3500,(int)$u->expired_at); + self::assertCount(1,Acceptance::source($this->dir,1)['nodes']);$this->node();self::assertCount(2,Acceptance::source($this->dir,1)['nodes']);self::assertSame($u->id,Acceptance::prepare($this->dir,1,true)['id']);self::assertSame(1,User::count()); + Acceptance::cleanup($this->dir,true);self::assertTrue($u->fresh()->banned);self::assertTrue(App\Services\ServerService::getAvailableUsers(Server::first())->isEmpty());self::assertArrayNotHasKey('token',Acceptance::source($this->dir,1,true));$this->reject(fn()=>Acceptance::source($this->dir,1)); + } + public function testExistingEligibleAdminIsUsedWithoutPrivilegeOrPasswordChange():void { + $u=$this->user(['is_admin'=>1]);$before=$u->fresh()->toArray();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,$u->fresh()->toArray()); + } + public function testExpiredExhaustedBannedWrongGroupAreNotReused():void { + foreach([['expired_at'=>time()-1],['u'=>1000000],['banned'=>1],['group_id'=>2]] as $i=>$change)$this->user($change+['email'=>'existing'.$i.'@example.invalid']); + $r=Acceptance::prepare($this->dir,1,true);self::assertTrue($r['owned']);self::assertSame(5,User::count()); + } + public function testLostCommitResponseRecoversSameUserAndPreparedRollbackRecoversSameIdentity():void { + $r=Acceptance::prepare($this->dir,1,true);$p=$this->dir.'/acceptance-user.json';$j=DplOnboarding\readJson($p);$j['state']='prepared';unset($j['id']);DplOnboarding\writeJson($p,$j); + self::assertSame($r['id'],Acceptance::prepare($this->dir,1,true)['id']);self::assertSame(1,User::count()); + User::find($r['id'])->delete();DplOnboarding\writeJson($p,$j);$again=Acceptance::prepare($this->dir,1,true);self::assertSame(1,User::count());self::assertSame($j['uuid'],User::find($again['id'])->uuid); + } + public function testClosedOwnedUserNeverReactivatedOnResume():void { + $old=Acceptance::prepare($this->dir,1,true)['id'];Acceptance::cleanup($this->dir,true);Acceptance::cleanup($this->dir,true); + $new=Acceptance::prepare($this->dir,1,true)['id'];self::assertNotSame($old,$new);self::assertTrue(User::find($old)->banned);self::assertFalse(User::find($new)->banned);self::assertFileExists($this->dir.'/acceptance-closed-'.$old.'.json');Acceptance::cleanup($this->dir,true); + } + public function testCleanupRefusesDriftAndNeverBansSomeoneElsesAccount():void { + $id=Acceptance::prepare($this->dir,1,true)['id'];$u=User::find($id);$u->is_admin=true;$u->saveQuietly();$this->reject(fn()=>Acceptance::cleanup($this->dir,true));self::assertFalse($u->fresh()->banned); + } + public function testEffectivePluginExclusionBlocksAcceptanceSource():void { + Acceptance::prepare($this->dir,1,true); + App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect()); + $this->reject(fn()=>Acceptance::source($this->dir,1));Acceptance::cleanup($this->dir,true); + } + public function testFailureFinallyCleanupAndMissingGroup():void { + $this->reject(fn()=>Acceptance::prepare($this->dir,99,true));$id=Acceptance::prepare($this->dir,1,true)['id']; + try{throw new RuntimeException('Injected client failure');}catch(RuntimeException $e){}finally{Acceptance::cleanup($this->dir,true);} + self::assertTrue(User::find($id)->banned); + } +} diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py new file mode 100644 index 0000000..d64a00f --- /dev/null +++ b/tests/node-onboarding/test_runtime.py @@ -0,0 +1,312 @@ +"""Offline adversarial evidence/transport/resume tests; no network or Laravel env.""" +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding' +sys.path.insert(0,str(SCRIPTS)) +from acceptance import audit,digest,snapshot,service_change,provider_state +from ssh_pool import Pool,private_json,credentials +import batch + +class EvidenceTests(unittest.TestCase): + def fixture(self,count): + nodes=[dict(id=i,type='anytls',tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',expected_exit=f'192.0.2.{i}',country='JP') for i in range(1,count+1)] + report={'state':'live_verified','verified_at':1000,'results':[dict(tag=n['tag'],relay=n['relay'],relay_port=n['relay_port'],sni=n['sni'],exit_ip=n['expected_exit'],country=n['country'],live=True,strict_tls=True) for n in nodes]} + qas={'captured_at':1000,'plan_sha256':'plan','users':{'new':dict(id=9,http_status=403,banned=True,excluded=True)}} + return nodes,report,qas + def check(self,nodes,report,qas,after=None,**bindings): + raw=json.dumps(report).encode();qraw=json.dumps(qas).encode() + binding=dict(plan_sha256='plan',snapshot_sha256=digest(snapshot(nodes)),report_sha256=hashlib.sha256(raw).hexdigest(),qa_rejections_sha256=hashlib.sha256(qraw).hexdigest(),verified_at=1000) + binding.update(bindings) + return audit(nodes,nodes if after is None else after,raw,qraw,binding,'plan',{'new':9},now=1100) + def test_arbitrary_node_counts_and_reordering(self): + for count in [1,2,7,23]: + nodes,report,qas=self.fixture(count) + self.assertEqual(self.check(nodes,report,qas,after=list(reversed(nodes)))['nodes'],count) + def test_topology_change_including_old_node_is_blocked(self): + nodes,r,q=self.fixture(7) + for after in [nodes[:-1],nodes+self.fixture(8)[0][-1:],list(reversed(nodes[:-1]))]: + with self.assertRaises(ValueError):self.check(nodes,r,q,after=after) + altered=copy.deepcopy(nodes);altered[0]['expected_exit']='198.51.100.9' + with self.assertRaises(ValueError):self.check(nodes,r,q,after=altered) + def test_missing_extra_duplicate_or_insecure_results_fail(self): + nodes,r,q=self.fixture(7) + for results in [r['results'][:-1],r['results']+[r['results'][0]],r['results'][1:]+[r['results'][1]]]: + with self.assertRaises(ValueError):self.check(nodes,dict(r,results=results),q) + for field,value in [('exit_ip','198.51.100.1'),('country','US'),('strict_tls',False),('live',False),('sni','wrong.example.invalid')]: + wrong=copy.deepcopy(r);wrong['results'][0][field]=value + with self.assertRaises(ValueError):self.check(nodes,wrong,q) + def test_qa_identity_status_revocation_and_hashes_are_bound(self): + nodes,r,q=self.fixture(2) + for field,value in [('id',10),('http_status',200),('banned',False),('excluded',False)]: + wrong=copy.deepcopy(q);wrong['users']['new'][field]=value + with self.assertRaises(ValueError):self.check(nodes,r,wrong) + for field in ['snapshot_sha256','plan_sha256','report_sha256','qa_rejections_sha256']: + with self.assertRaises(ValueError):self.check(nodes,r,q,**{field:'wrong'}) + for stamp in [-1,1200,None,'1000']: + with self.assertRaises(ValueError):self.check(nodes,dict(r,verified_at=stamp),q) + with self.assertRaises(ValueError):self.check(nodes,r,dict(q,captured_at=stamp)) + def test_provider_observations_are_exact_and_not_language_or_substring_dependent(self): + node=dict(key='new',tls_domain='a.example.invalid',relay_host='relay.example.invalid',relay_port=47001,node_address='192.0.2.1',listen_port=24443) + observed={'captured_at':1000,'dns':[{'type':'A','name':node['tls_domain'],'address':node['node_address'],'proxy':False}],'relays':[{'name':'dpl-new-anytls','host':node['relay_host'],'port':47001,'target':node['node_address'],'listen_port':24443,'proxy_protocol_in':False,'proxy_protocol_out':False}]} + self.assertTrue(provider_state(observed,node,1100)['dns']) + for section,field,value in [('dns','proxy',True),('dns','address','192.0.2.11'),('relays','proxy_protocol_in',True),('relays','target','192.0.2.11')]: + wrong=copy.deepcopy(observed);wrong[section][0][field]=value + with self.assertRaises(ValueError):provider_state(wrong,node,1100) + self.assertEqual(provider_state(dict(observed,dns=[],relays=[]),node,1100),{'dns':[],'relays':[]}) + with self.assertRaises(ValueError):provider_state(observed,node,2000) + + def test_registry_and_service_changes_do_not_use_pid_alone(self): + for values in [[],[dict(self.fixture(1)[0][0],type='vless')],self.fixture(1)[0]*2]: + with self.assertRaises(ValueError):snapshot(values) + before=dict(unit_sha256='a',binary_sha256='b',config_sha256='c',listeners=['*:443'],active=True,pid=1,started='yesterday',restarts=0) + self.assertEqual(service_change(before,before),'ready') + self.assertEqual(service_change(before,dict(before,pid=2)),'investigate_restart') + self.assertEqual(service_change(before,dict(before,config_sha256='changed')),'conflict') + self.assertEqual(service_change(before,dict(before,active=False)),'conflict') + self.assertEqual(service_change(before,dict(before,binary_sha256=None)),'unknown') + +class TransportTests(unittest.TestCase): + def test_reuse_and_every_nonwhite_host_has_jump(self): + calls=[] + class Client: + active=True + def get_transport(self):return self + def is_active(self):return self.active + def close(self):self.active=False + def factory(rid,spec,jump):calls.append((rid,jump));return Client() + config={'hosts':{'white':{'role':'white_jump'},'panel':{},'node':{}}} + pool=Pool(config,'white',factory) + panel=pool.get('panel');self.assertIs(panel,pool.get('panel')) + node=pool.get('node');self.assertIs(calls[1][1],calls[2][1]);self.assertIsNone(calls[0][1]);self.assertEqual(len(calls),3) + panel.active=False + with self.assertRaises(RuntimeError):pool.get('panel') + self.assertEqual(len(calls),3) # Never automatically replay after disconnect. + pool.close();self.assertFalse(node.active) + def test_sessions_are_not_shared_unsafely_between_workers(self): + barrier=threading.Barrier(2);made=[] + class Client: + def get_transport(self):return self + def is_active(self):return True + def close(self):pass + pool=Pool({'hosts':{'w':{'role':'white_jump'},'n':{}}},'w',lambda *args:Client()) + def worker(): + n=pool.get('n');barrier.wait();made.append(n);self.assertIs(n,pool.get('n')) + threads=[threading.Thread(target=worker) for _ in range(2)] + for t in threads:t.start() + for t in threads:t.join(timeout=5) + self.assertEqual(len(made),2);self.assertIsNot(made[0],made[1]);pool.close() + def test_existing_codocs_credentials_are_read_without_copy_or_rewrite(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'vps.md';body='# Fixture\n密码:test-only-phrase\n';p.write_text(body);p.chmod(0o600) + self.assertEqual(credentials({'credential_ref':str(p),'credential_format':'codocs-password'}),{'password':'test-only-phrase'}) + self.assertEqual(p.read_text(),body);self.assertEqual(len(list(Path(d).iterdir())),1) + p.write_text(body+'密码:ambiguous\n') + with self.assertRaises(ValueError):credentials({'credential_ref':str(p),'credential_format':'codocs-password'}) + def test_private_config_and_explicit_jump_enforced(self): + with self.assertRaises(ValueError):Pool({'hosts':{'n':{}}},'w') + with self.assertRaises(ValueError):Pool({'hosts':{'w':{}}},'w') + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'auth.json';p.write_text('{}');p.chmod(0o644) + with self.assertRaises(ValueError):private_json(p) + p.chmod(0o600);self.assertEqual(private_json(p),{}) + link=Path(d)/'link';link.symlink_to(p) + with self.assertRaises(ValueError):private_json(link) + +class ResumeTests(unittest.TestCase): + def setUp(self): + batch.STOP.clear();self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name) + spec=importlib.util.spec_from_file_location('batch_fixtures',Path(__file__).with_name('test_batch.py'));m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) + fixture=m.BatchTests();fixture.setUp();self.fixture=fixture;self.plan=fixture.plan + self.calls=[];self.generation='a';self.finished=False + owner=self + class Runner(batch.Coordinator): + def call(self,n,phase,action): + owner.calls.append((phase,action)) + r=dict(key=n['key'],phase=phase,request_sha256='irrelevant-in-unit-fixture',status='waiting' if phase=='audit' and not owner.finished else 'ready',valid_until=time.time()+180) + if phase=='preflight':r['resume_dependencies']={k:hashlib.sha256((owner.generation if k=='provision' else k).encode()).hexdigest() for k in ['stage','dns','provision','relay','verify']} + return r + self.Runner=Runner + def tearDown(self):self.fixture.tearDown();self.tmp.cleanup();batch.STOP.clear() + def run_batch(self):return self.Runner(self.plan,self.root/'ledger',jobs=1).run() + def test_fresh_proofs_skip_only_completed_unchanged_phases(self): + self.assertFalse(self.run_batch());self.calls.clear();self.finished=True + self.assertTrue(self.run_batch());self.assertEqual(self.calls,[('preflight','probe'),('publish','probe'),('audit','probe')]) + def test_dependency_change_rechecks_downstream_and_not_unrelated_stage(self): + self.run_batch();self.calls.clear();self.generation='b';self.finished=True;self.run_batch() + self.assertEqual([p for p,a in self.calls],['preflight','provision','relay','verify','publish','audit']) + def test_expired_and_unknown_proofs_never_skip(self): + self.run_batch();runner=self.Runner(self.plan,self.root/'ledger',jobs=1) + with runner.ledger.db() as c:c.execute('UPDATE evidence SET expires=0 WHERE phase="dns"') + runner.ledger.mark(self.plan['batch_id'],self.plan['nodes'][0]['key'],'stage','unknown') + self.calls.clear();self.finished=True;self.run_batch() + self.assertIn(('stage','probe'),self.calls);self.assertIn(('dns','probe'),self.calls) + self.assertTrue((self.root/'ledger/events.jsonl').is_file()) + + +class SiteAdapterTests(unittest.TestCase): + def test_preserved_service_review_binds_original_and_exact_observation(self): + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} + current=dict(unit_sha256='u',binary_sha256='b',config_sha256='c',listeners=['*:443'],active=True,pid=1,started='a',restarts=0) + files={};read=lambda client,path:files.get(path) + def write(client,path,value):files[path]=value.encode() if isinstance(value,str) else value + def execute(client,cmd):return json.dumps(current) if cmd.startswith('python3') else '' + adapter.guard_service(None,'/backup',read,write,execute) + original=copy.deepcopy(current);current.update(pid=2,started='b',restarts=1) + with self.assertRaisesRegex(RuntimeError,'investigate_'):adapter.guard_service(None,'/backup',read,write,execute) + files['/backup/preserved-service-review.json']=json.dumps(dict(before_sha256=digest(original),after_sha256=digest(current),reason='Independently reviewed lifecycle panic',evidence_sha256='e'*64)).encode() + adapter.guard_service(None,'/backup',read,write,execute) + current['config_sha256']='changed' + with self.assertRaisesRegex(RuntimeError,'conflict'):adapter.guard_service(None,'/backup',read,write,execute) + self.assertEqual(json.loads(files['/backup/preserved-service-original.json']),original) + def test_generated_remote_python_and_group_independent_php_compile(self): + import ast + import subprocess + import io + from site_adapter import SiteAdapter + tree=ast.parse((SCRIPTS/'site_adapter.py').read_text()) + for method in [n for n in ast.walk(tree) if isinstance(n,ast.FunctionDef) and n.name in ('formal','guard_service','certificate_gate')]: + for c in ast.walk(method): + if isinstance(c,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='script' for t in c.targets): + program=eval(compile(ast.Expression(c.value),'script-builder','eval'),{'repr':repr,'spec':{},'ctx':{'subscription_url':'https://sub.example.invalid'},'value':{'token':'test-only'}}) + compile(program,'remote-'+method.name,'exec') + with tempfile.TemporaryDirectory() as d: + a=object.__new__(SiteAdapter);a.plan={'context':dict(panel_vps='panel',gateway_vps='gateway',node_root='/node',ops_root='/ops',app_root='/app',batch_backup_root='/batch',node_backup_root='/backups',qa_root='/qa',gateway_containers=['gateway-a','gateway-b'])};a.ctx=a.plan['context'];a.directory=Path(d) + php_sources=[];calls=[] + class Channel: + def shutdown_write(self):pass + def recv_exit_status(self):return 0 + class In: + channel=Channel() + def write(self,body):php_sources.append(body) + class Out: + channel=Channel() + def read(self): + body=php_sources[-1] + if '$base=json_decode' in body:return b'true' + return json.dumps({'manifest':{},'node_online':True,'groups':['7'],'qa_banned':True,'u':1,'d':1}).encode() + class DockerOut: + channel=Channel() + def read(self):return json.dumps([dict(Name='/gateway-a',Id='id-a',State={'StartedAt':'then'},RestartCount=0)]).encode() + class Sftp: + def __enter__(self):return self + def __exit__(self,*args):pass + def open(self,path,mode):return io.BytesIO(json.dumps({'/gateway-a':{'id':'id-a','started':'then','restarts':0}}).encode()) + class Remote: + def exec_command(self,cmd,timeout=0): + calls.append(cmd) + if cmd.startswith('docker inspect'): + if cmd!='docker inspect gateway-a gateway-b':raise RuntimeError('Invalid gateway inventory command') + return In(),DockerOut(),io.BytesIO() + return In(),Out(),io.BytesIO() + def open_sftp(self):return Sftp() + class Pool: + def get(self,rid):return Remote() + a.pool=Pool();a.guard_service=lambda *args:None;a.formal=lambda *args:None + n=dict(key='sg-new',request_sha256='a'*64,formal_group_id=7,vps_id='node') + result=a.call(n,'audit','probe');self.assertEqual(result['status'],'ready');self.assertGreaterEqual(len(php_sources),2) + for php in php_sources: + result=subprocess.run(['php','-l'],input=php,text=True,capture_output=True) + self.assertEqual(result.returncode,0,result.stderr) + def test_pool_real_connect_interface_keeps_strict_keys_and_jump_sock(self): + import types + recorded=[] + class FakeClient: + def load_host_keys(self,path):recorded.append(('keys',path)) + def set_missing_host_key_policy(self,p):recorded.append(('reject',p)) + def get_host_keys(self):return self + def add(self,*args):recorded.append(('approved-key',args)) + def connect(self,host,**kw):recorded.append(('connect',host,kw)) + def get_transport(self):return self + def is_active(self):return True + def open_channel(self,kind,target,origin):recorded.append(('jump',kind,target));return 'through-white' + def close(self):pass + class Key: + def asbytes(self):return b'approved-public-key-fixture' + def get_name(self):return 'ssh-ed25519' + class Keys: + def __init__(self,path):pass + def lookup(self,name):return {'ssh-ed25519':Key()} + module=types.SimpleNamespace(SSHClient=FakeClient,RejectPolicy=lambda:'reject-unknown',HostKeys=Keys) + pin='SHA256:'+__import__('base64').b64encode(hashlib.sha256(Key().asbytes()).digest()).decode().rstrip('=') + with tempfile.TemporaryDirectory() as d,patch.dict(sys.modules,{'paramiko':module}): + keys=Path(d)/'known_hosts';keys.write_text('public fixture');keys.chmod(0o600) + credentials=Path(d)/'credentials.json';credentials.write_text(json.dumps({'password':'test-only-fixture'}));credentials.chmod(0o600) + hosts={'w':dict(role='white_jump',host='192.0.2.1',username='root',credential_ref=str(credentials)),'n':dict(host='192.0.2.2',username='root',credential_ref=str(credentials))} + + for host in hosts.values():host['host_key_sha256']=pin + p=Pool(dict(hosts=hosts,known_hosts=str(keys)),'w');p.get('n');p.get('n');p.close() + before_connects=len([x for x in recorded if x[0]=='connect']) + hosts['w']['host_key_sha256']='SHA256:'+'b'*43 + blocked=Pool(dict(hosts=hosts,known_hosts=str(keys)),'w') + with self.assertRaises(ValueError):blocked.get('n') + self.assertEqual(len([x for x in recorded if x[0]=='connect']),before_connects) + blocked.close() + connections=[x for x in recorded if x[0]=='connect'];self.assertEqual(len(connections),2);self.assertIsNone(connections[0][2]['sock']);self.assertEqual(connections[1][2]['sock'],'through-white') + self.assertEqual(len([x for x in recorded if x[0]=='reject']),2) + for _,_,kw in connections:self.assertFalse(kw['allow_agent']);self.assertFalse(kw['look_for_keys']) + + def test_configure_is_local_only_and_pins_code_context_and_interpreter(self): + import types + spec=importlib.util.spec_from_file_location('configure_site',SCRIPTS/'configure-site.py');module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + with tempfile.TemporaryDirectory() as d: + root=Path(d);ssh=root/'ssh.json';registry=root/'exits.json';(root/'known_hosts').write_text('public fixture');(root/'known_hosts').chmod(0o600) + hosts={'VPS-001':dict(role='white_jump',host='192.0.2.1',username='root'),'VPS-002':dict(host='192.0.2.2',username='root'),'VPS-003':dict(host='192.0.2.3',username='root'),'VPS-011':dict(host='192.0.2.11',username='root')} + for host in hosts.values():host['host_key_sha256']='SHA256:'+'a'*43 + ssh.write_text(json.dumps({'hosts':hosts,'known_hosts':str(root/'known_hosts')}));ssh.chmod(0o600);registry.write_text('{}');registry.chmod(0o600) + fixture_spec=importlib.util.spec_from_file_location('base_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(fixture_spec);fixture_spec.loader.exec_module(f) + original=f.BatchTests();original.setUp() + try: + base=original.plan;node=base['nodes'][0];node['node_address']='192.0.2.11';node['expected_exit']='192.0.2.11';base['context'].update(jump_vps='VPS-001',panel_vps='VPS-002',gateway_vps='VPS-003');registry.write_text(json.dumps({node['tls_domain']:{'expected_exit':node['expected_exit'],'country':node['country']}})) + site=dict(client_sha256='d'*64,ops_root='/ops',node_root='/node',batch_backup_root='/backup/batch',node_backup_root='/backup/nodes',qa_root='/qa/batch',provider_snapshot=str(root/'provider.json'),exit_registry=str(registry),ssh_config=str(ssh),preserved_service=dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443),formal_socks_port=19999,gateway_containers=['gateway-a']) + with patch.dict(sys.modules,{'paramiko':types.SimpleNamespace(__version__='5.0.0')}): + plan=module.configure(base,site);self.assertEqual(plan['runtime']['kind'],'ssh-site-v1');self.assertFalse((root/'ledger').exists()) + runner=batch.Coordinator(plan,root/'ledger',jobs=1);runner.runtime.close();runner.check_runtime() + ssh.write_text(ssh.read_text()+'\n') + with self.assertRaises(ValueError):runner.check_runtime() + finally:original.tearDown() + +class PackagingTests(unittest.TestCase): + def test_committed_flat_snapshot_preserves_worktree_index_and_base(self): + import subprocess + spec=importlib.util.spec_from_file_location('bundle_ops',SCRIPTS/'bundle-ops.py');module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + with tempfile.TemporaryDirectory() as d: + root=Path(d);repo=root/'repo';repo.mkdir() + def git(*args):return subprocess.check_output(['git','-C',str(repo),*args],stderr=subprocess.DEVNULL) + git('init');git('config','user.name','Offline Test');git('config','user.email','test@example.invalid');git('config','commit.gpgsign','false') + (repo/'base-only.txt').write_text('preserved operations base');git('add','.');git('commit','-m','base');base=git('rev-parse','HEAD').decode().strip() + source_dir=repo/'scripts/node-onboarding';source_dir.mkdir(parents=True) + for name in module.FILES:(source_dir/name).write_text('committed '+name) + git('add','.');git('commit','-m','source');source=git('rev-parse','HEAD').decode().strip() + (repo/'base-only.txt').write_text('unrelated user modification');(repo/'unrelated.txt').write_text('staged unrelated');git('add','unrelated.txt') + index_before=git('diff','--cached','--binary');status_before=git('status','--porcelain') + output=root/'operations.bundle';result=module.build(repo,source,base,'codex/offline-ops',output) + self.assertTrue(output.exists());self.assertEqual(git('diff','--cached','--binary'),index_before);self.assertEqual(git('status','--porcelain'),status_before) + self.assertEqual(git('show',result['commit']+':panel-acceptance.php'),b'committed panel-acceptance.php') + self.assertEqual(git('show',result['commit']+':base-only.txt'),b'preserved operations base') + self.assertEqual(git('rev-parse','HEAD').decode().strip(),source) + git('bundle','verify',str(output)) + with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle') + def test_event_stream_preserves_final_result_and_avoids_status_polling(self): + import subprocess + spec=importlib.util.spec_from_file_location('event_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f) + test=f.BatchTests();test.setUp() + try: + result=subprocess.run(test.argv()+['--events'],capture_output=True,text=True,timeout=30) + self.assertEqual(result.returncode,0,result.stderr) + lines=[json.loads(x) for x in result.stdout.splitlines()] + self.assertTrue(lines[-1]['complete']);self.assertTrue(any(x.get('phase')=='provision' for x in lines[:-1])) + for x in lines[:-1]:self.assertEqual(set(x),{'batch','node','phase','status','time'}) + finally:test.tearDown() + +if __name__=='__main__':unittest.main() From e26b5bc6cad9fc6f884e279fe567e7a08ff5f2ee Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 15:32:03 +0800 Subject: [PATCH 11/25] fix: finalize acceptance identity and raw QA review evidence --- docs/maintenance/NODE_BATCH_RUNTIME.md | 16 +++-- docs/maintenance/STATUS.md | 2 +- scripts/node-onboarding/bundle-ops.py | 13 ++-- scripts/node-onboarding/panel-acceptance.php | 8 ++- scripts/node-onboarding/site_adapter.py | 21 +++++-- .../AcceptanceUserIsolatedTest.php | 7 ++- tests/node-onboarding/test_runtime.py | 60 +++++++++++++++++++ 7 files changed, 110 insertions(+), 17 deletions(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 90f555b..6e9d6c0 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -18,13 +18,13 @@ 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 -生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,拒绝覆盖已有分支或bundle,不联网: +生产前先提交并推送源码,再生成、推送和部署独立运维Git版本:保留原安装脚本,加入`panel-acceptance.php`及其`operations.php`,还有stage/promote/rollback/readiness/verify-node入口;面板、网关和目标节点的对应运维目录必须为计划中完整`provision_commit`且无工作区差异。配置文件和凭据不入Git。独立快照由正式`bundle-ops.py`生成,保持基础运维树、工作目录和真实Git索引不变,仅复制10个明确的已提交运维文件,默认拒绝已有分支或bundle,不联网: ```sh python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base FULL_OPS_BASE_COMMIT --branch codex/node-tools-BATCH --bundle /private/task/provision.bundle ``` -随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 +同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 @@ -92,9 +92,9 @@ SSH会话仅在本次进程、同一工作线程内复用,线程不共用可 ## 动态验收和测试用户 -准备阶段先按正式组、banned、额度和有效期选既有账号,不把管理员身份自动排除;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤。没有可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 +准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 -新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 +新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 @@ -109,3 +109,11 @@ preflight/publish/audit始终现场执行。只有先前状态为done、收到 ## 验证范围 新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。 + +隔离回归入口(不使用项目默认PHPUnit配置): + +```sh +python3 -B -m unittest discover -s tests/node-onboarding -p 'test_*.py' -q +php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php +php vendor/bin/phpunit --no-configuration tests/node-onboarding/AcceptanceUserIsolatedTest.php +``` diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index c3a4f98..42d4c5a 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -8,7 +8,7 @@ 续跑保留全批前检及publish/audit现场关口,以请求/代码/当前依赖摘要和有效期复用已完成步骤,前驱变化向下游失效;事件输出替代重复状态轮询。原服务以unit/程序/配置/监听和进程信息区分实际冲突与待调查重启,原基线不覆盖。独立运维打包工具从已提交源码生成可追踪快照,保留工作区/真实索引及旧运维基础树;旧命令适配器及旧冻结任务文件不修改。 -本轮Python 30项测试通过;PHP新增9项/35断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。 +本轮Python 32项测试通过;PHP新增10项/38断言及既有9项/65断言隔离测试通过,均未加载真实.env或联网。CoDocs全库离线检查通过,前次.obsidian权限问题已由用户处理,本轮未读写其配置正文。新版本尚未部署至远端或进行真实SSH/客户端性能演练,不能把上一轮三节点验收复用为新实现线上通过,也不承诺十节点耗时。准备/部署/执行与边界见[正式适配器专题](NODE_BATCH_RUNTIME.md);后续新批次默认按该入口执行。实现源码已提交推送,独立运维分支`codex/node-runtime-20261017`已准备并经Git bundle校验;精确版本按该分支Git记录读取,仍未切换远端旧任务的运行目录。 ## 2026-10-17 三节点批量接入完成 diff --git a/scripts/node-onboarding/bundle-ops.py b/scripts/node-onboarding/bundle-ops.py index d06427c..e2ca423 100644 --- a/scripts/node-onboarding/bundle-ops.py +++ b/scripts/node-onboarding/bundle-ops.py @@ -13,7 +13,7 @@ FILES=('stage.php','promote.php','operations.php','rollback.php','readiness.php' 'verify-node.py','panel-acceptance.php','install-node.sh','renew-node.sh','dpl-node.service') -def build(repo,source,base,branch,bundle): +def build(repo,source,base,branch,bundle,advance=False): if not all(re.fullmatch('[0-9a-f]{40}',r) for r in (source,base)) or not re.fullmatch('codex/[A-Za-z0-9_/-]+',branch) or '..' in branch: raise ValueError('Explicit full commits and a new codex branch required') destination=Path(bundle) @@ -24,7 +24,11 @@ def build(repo,source,base,branch,bundle): return r.stdout git('cat-file','-e',source+'^{commit}');git('cat-file','-e',base+'^{commit}');git('check-ref-format','refs/heads/'+branch) exists=subprocess.run(['git','-C',str(repo),'show-ref','--verify','--quiet','refs/heads/'+branch]).returncode - if exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite') + expected_old='0'*40 + if exists==0 and advance: + expected_old=git('rev-parse','refs/heads/'+branch).decode().strip() + if expected_old!=base:raise ValueError('Advance requires the exact current branch head as reviewed base') + elif exists!=1:raise ValueError('Branch exists or outcome unknown; inspect it, do not overwrite') with tempfile.TemporaryDirectory(prefix='dpl-ops-index-',dir=destination.parent) as temp: env=dict(os.environ,GIT_INDEX_FILE=str(Path(temp)/'index')) git('read-tree',base,env=env) @@ -36,7 +40,7 @@ def build(repo,source,base,branch,bundle): git('update-index','--add','--cacheinfo',mode+','+sha+','+name,env=env) tree=git('write-tree',env=env).decode().strip() commit=git('commit-tree',tree,'-p',base,input=('DPL operations snapshot\n\nSource commit: '+source+'\n').encode()).decode().strip() - git('update-ref','refs/heads/'+branch,commit,'0'*40) + git('update-ref','refs/heads/'+branch,commit,expected_old) git('bundle','create',str(destination),'refs/heads/'+branch) destination.chmod(0o600) return {'commit':commit,'source_commit':source,'branch':branch,'bundle_sha256':hashlib.sha256(destination.read_bytes()).hexdigest(),'files':len(FILES),'network':False} @@ -46,9 +50,10 @@ def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--repo',default=str(Path(__file__).resolve().parents[2])) for k in ('source','base','branch','bundle'):p.add_argument('--'+k,required=True) + p.add_argument('--advance',action='store_true',help='Explicit fast-forward only from the exact --base current branch head') a=p.parse_args() try: - print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle)));return 0 + print(json.dumps(build(a.repo,a.source,a.base,a.branch,a.bundle,a.advance)));return 0 except Exception: print(json.dumps({'state':'blocked','reason':'Inspect source/base, branch and bundle outcome before retry; no network performed'}));return 2 diff --git a/scripts/node-onboarding/panel-acceptance.php b/scripts/node-onboarding/panel-acceptance.php index b09b86f..f922cdf 100644 --- a/scripts/node-onboarding/panel-acceptance.php +++ b/scripts/node-onboarding/panel-acceptance.php @@ -33,7 +33,13 @@ final class Acceptance { $j['id']=$u->id;$j['state']='active';if($apply)writeJson($path,$j); return ['state'=>'ready','id'=>$u->id,'owned'=>$j['owned']]; } - $u=User::where('group_id',$group)->where('banned',0)->whereRaw('u+dwhere(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderBy('id')->first(); + $u=null; + $candidates=User::where('group_id',$group)->where('banned',0)->whereRaw('u+dwhere(fn($q)=>$q->whereNull('expired_at')->orWhere('expired_at','>=',time()))->where('email','not like','node-qa-%')->where('email','not like','batch-acceptance-%')->orderByDesc('is_admin')->orderBy('id')->limit(20)->get(); + foreach($candidates as $candidate){ + $effective=true; + foreach(ServerService::getAvailableServers($candidate) as $s)if(!ServerService::getAvailableUsers(Server::findOrFail($s['id']))->contains('id',$candidate->id)){$effective=false;break;} + if($effective){$u=$candidate;break;} + } if(!$apply)return ['state'=>$u?'existing':'would_create']; $j=['group'=>$group,'state'=>'prepared','owned'=>!$u,'email'=>$u?->email??'batch-acceptance-'.substr(hash('sha256',$dir),0,12).'-'.Str::lower(Str::random(8)).'@example.invalid','uuid'=>$u?->uuid??(string)Str::uuid(),'token'=>$u?->token??Str::random(32),'quota'=>104857600,'expires_at'=>time()+3600]; // Persist identity before commit. A lost response resumes the same row, never another user. diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index b569b66..962e8b4 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -220,6 +220,15 @@ for attempt in range(3 if source['owned'] else 1): # Persist only nonsecret, byte-bound results for coordinator/operator review. self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) + def qa_evidence(self,n,raw,state): + # Bind the exact remote bytes, not a re-serialized nested report. Persist the latest + # online/traffic state so independent review never needs a separate task collector. + report=json.loads(raw) + self.atomic_json(self.directory/(n['key']+'-evidence.json'),{ + 'key':n['key'],'request_sha256':n['request_sha256'], + 'report_sha256':hashlib.sha256(raw).hexdigest(),'report':report, + 'panel':state,'collected_at':int(time.time())}) + @staticmethod def php_value(value): return "json_decode(base64_decode('"+base64.b64encode(json.dumps(value).encode()).decode()+"'),true)" @@ -376,20 +385,20 @@ for attempt in range(3 if source['owned'] else 1): existing=read(panel,B+'/qa-report.json') if existing: report=json.loads(existing) - if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']+s['d']>0: + if report.get('state')=='live_verified' and report.get('qa_user_id')==s['manifest']['test_user_id'] and 0<=time.time()-report.get('verified_at',0)<=900 and s['node_online'] and s['u']>0 and s['d']>0: + self.qa_evidence(n,existing,s) r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r transfer(panel,gateway,node);fetch_qa(gateway) execute(gateway,'python3 '+OPS+'/verify-node.py --qa '+Q+'/qa.json --subscription '+Q+'/subscription.json --binary '+ctx['client_binary']+' --port '+str(n['socks_port'])+' --expected-exit '+shlex.quote(n['expected_exit'])+' --expected-country '+n['country']+' --report '+Q+'/qa-report.json --run > '+Q+'/verify.stdout 2> '+Q+'/verify.stderr',300) data=read(gateway,Q+'/qa-report.json');report=json.loads(data) write(panel,B+'/qa-report.json',data) - # Reports contain endpoints and measured results, no passwords or tokens. - evidence={'key':key,'request_sha256':sha,'report':report,'panel':state(panel)} - (Path(a.state_dir)/(key+'-evidence.json')).write_text(json.dumps(evidence,ensure_ascii=False,indent=2)) for _ in range(24): s=state(panel) - if s['node_online'] and s['u']+s['d']>0: + if s['node_online'] and s['u']>0 and s['d']>0: + self.qa_evidence(n,data,s) r=receipt('ready');r['valid_until']=min(r['valid_until'],report['verified_at']+900);return r time.sleep(5) + self.qa_evidence(n,data,s) return receipt('waiting') if a.phase=='publish': s=state(panel) @@ -404,7 +413,7 @@ for attempt in range(3 if source['owned'] else 1): if not journal:return receipt('conflict') j=json.loads(journal) return receipt('ready' if j.get('manifest')==s['manifest'] and j.get('request',{}).get('mode')=='append' and j.get('request',{}).get('group')==n['formal_group_id'] and j.get('state')=='applied' else 'conflict') - if not s['node_online'] or s['u']+s['d']<=0:return receipt('waiting') + if not s['node_online'] or s['u']<=0 or s['d']<=0:return receipt('waiting') if a.action=='probe':return receipt('absent',True) cmd='cd '+APP+' && php '+OPS+'/promote.php --backup='+B+' --mode=append --group='+str(n['formal_group_id'])+' --verification='+B+'/qa-report.json' execute(panel,cmd);execute(panel,cmd+' --apply');return receipt('ready') diff --git a/tests/node-onboarding/AcceptanceUserIsolatedTest.php b/tests/node-onboarding/AcceptanceUserIsolatedTest.php index fa49600..5238867 100644 --- a/tests/node-onboarding/AcceptanceUserIsolatedTest.php +++ b/tests/node-onboarding/AcceptanceUserIsolatedTest.php @@ -35,7 +35,7 @@ final class AcceptanceUserIsolatedTest extends TestCase { Acceptance::cleanup($this->dir,true);self::assertTrue($u->fresh()->banned);self::assertTrue(App\Services\ServerService::getAvailableUsers(Server::first())->isEmpty());self::assertArrayNotHasKey('token',Acceptance::source($this->dir,1,true));$this->reject(fn()=>Acceptance::source($this->dir,1)); } public function testExistingEligibleAdminIsUsedWithoutPrivilegeOrPasswordChange():void { - $u=$this->user(['is_admin'=>1]);$before=$u->fresh()->toArray();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,$u->fresh()->toArray()); + $u=$this->user(['is_admin'=>1]);$before=(array)Capsule::table('v2_user')->where('id',$u->id)->first();$r=Acceptance::prepare($this->dir,1,true);self::assertFalse($r['owned']);self::assertSame($u->id,$r['id']);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true);self::assertSame($before,(array)Capsule::table('v2_user')->where('id',$u->id)->first()); } public function testExpiredExhaustedBannedWrongGroupAreNotReused():void { foreach([['expired_at'=>time()-1],['u'=>1000000],['banned'=>1],['group_id'=>2]] as $i=>$change)$this->user($change+['email'=>'existing'.$i.'@example.invalid']); @@ -53,6 +53,11 @@ final class AcceptanceUserIsolatedTest extends TestCase { public function testCleanupRefusesDriftAndNeverBansSomeoneElsesAccount():void { $id=Acceptance::prepare($this->dir,1,true)['id'];$u=User::find($id);$u->is_admin=true;$u->saveQuietly();$this->reject(fn()=>Acceptance::cleanup($this->dir,true));self::assertFalse($u->fresh()->banned); } + public function testNoEffectivelyEligibleExistingUserCreatesBoundedTestAccount():void { + $old=$this->user(['is_admin'=>1]);$id=$old->id; + App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>$users->where('id','!=',$id)->values()); + $r=Acceptance::prepare($this->dir,1,true);self::assertTrue($r['owned']);self::assertNotSame($id,$r['id']);self::assertFalse(User::find($r['id'])->is_admin);Acceptance::source($this->dir,1);Acceptance::cleanup($this->dir,true); + } public function testEffectivePluginExclusionBlocksAcceptanceSource():void { Acceptance::prepare($this->dir,1,true); App\Services\Plugin\HookManager::registerFilter('server.users.get',fn($users)=>collect()); diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index d64a00f..31ddc59 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -154,6 +154,63 @@ class ResumeTests(unittest.TestCase): class SiteAdapterTests(unittest.TestCase): + def test_qa_export_binds_raw_bytes_and_latest_traffic_for_independent_review(self): + from site_adapter import SiteAdapter + with tempfile.TemporaryDirectory() as d: + adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json + raw=b'{ "state": "live_verified", "qa_user_id": 9 }' + latest=dict(node_online=True,u=12,d=34,manifest={'test_user_id':9}) + adapter.qa_evidence(dict(key='new',request_sha256='request'),raw,latest) + path=Path(d)/'new-evidence.json';value=json.loads(path.read_text()) + self.assertEqual(value['report_sha256'],hashlib.sha256(raw).hexdigest()) + self.assertEqual(value['panel'],latest);self.assertEqual(path.stat().st_mode & 0o077,0) + + def test_new_live_qa_exports_new_raw_report_after_transfer_not_prior_report(self): + import io + from site_adapter import SiteAdapter + with tempfile.TemporaryDirectory() as d: + adapter=object.__new__(SiteAdapter);adapter.directory=Path(d);adapter.atomic_json=batch.atomic_json + ctx=dict(panel_vps='panel',gateway_vps='gateway',node_root='/node',ops_root='/ops',app_root='/app',node_backup_root='/backups',qa_root='/qa',client_binary='/client',subscription_url='https://sub.example.invalid') + adapter.plan={'context':ctx};adapter.ctx=ctx + raw=json.dumps(dict(state='live_verified',qa_user_id=9,verified_at=int(time.time()),results=[dict(tag='new',live=True,strict_tls=True,exit_ip='192.0.2.11',country='JP')])).encode() + class Channel: + def shutdown_write(self):pass + def recv_exit_status(self):return 0 + class In: + channel=Channel() + def write(self,data):pass + class Out(io.BytesIO):channel=Channel() + class Writable(io.BytesIO): + def __init__(self,files,path):super().__init__();self.files,self.path=files,path + def close(self):self.files[self.path]=self.getvalue();super().close() + class Sftp: + def __init__(self,files):self.files=files + def __enter__(self):return self + def __exit__(self,*args):pass + def open(self,path,mode): + if mode=='w':return Writable(self.files,path) + if path not in self.files:raise FileNotFoundError(path) + return io.BytesIO(self.files[path]) + def chmod(self,*args):pass + def posix_rename(self,old,new):self.files[new]=self.files.pop(old) + state=dict(manifest={'test_user_id':9},node_online=True,groups=['2'],qa_banned=False,u=12,d=34) + class Remote: + def __init__(self,kind):self.kind=kind;self.files={} + def open_sftp(self):return Sftp(self.files) + def exec_command(self,cmd,timeout=0): + if cmd.endswith(' && php'):return In(),Out(json.dumps(state).encode()),Out() + if 'verify-node.py' in cmd:self.files['/qa/new/qa-report.json']=raw + return In(),Out(b'{}'),Out() + remotes={key:Remote(key) for key in ['panel','gateway','node']} + remotes['panel'].files.update({'/backups/new/node-config.yml':b'fixture-config','/backups/new/node-secret.env':b'fixture-env','/backups/new/qa-private.json':b'{}'}) + class Pool: + def get(self,key):return remotes[key] + adapter.pool=Pool() + node=dict(key='new',request_sha256='request',formal_group_id=1,vps_id='node',socks_port=19011,expected_exit='192.0.2.11',country='JP') + result=adapter.call(node,'verify','probe');self.assertEqual(result['status'],'ready') + exported=json.loads((Path(d)/'new-evidence.json').read_text());self.assertEqual(exported['report_sha256'],hashlib.sha256(raw).hexdigest());self.assertEqual(exported['panel']['d'],34) + self.assertEqual(remotes['panel'].files['/backups/new/qa-report.json'],raw) + def test_preserved_service_review_binds_original_and_exact_observation(self): from site_adapter import SiteAdapter adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} @@ -297,6 +354,9 @@ class PackagingTests(unittest.TestCase): self.assertEqual(git('rev-parse','HEAD').decode().strip(),source) git('bundle','verify',str(output)) with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'second.bundle') + advanced=module.build(repo,source,result['commit'],'codex/offline-ops',root/'advanced.bundle',advance=True) + self.assertEqual(git('rev-parse',advanced['commit']+'^').decode().strip(),result['commit']) + with self.assertRaises(ValueError):module.build(repo,source,base,'codex/offline-ops',root/'stale.bundle',advance=True) def test_event_stream_preserves_final_result_and_avoids_status_polling(self): import subprocess spec=importlib.util.spec_from_file_location('event_fixture',Path(__file__).with_name('test_batch.py'));f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f) From ac9f249961da56835a0c5cd534e6cef6412308cc Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 17:22:47 +0800 Subject: [PATCH 12/25] fix(nodes): validate QA scripts and normalized formal snapshots --- docs/maintenance/STATUS.md | 6 +++ scripts/node-onboarding/acceptance.py | 2 +- scripts/node-onboarding/site_adapter.py | 20 ++++---- tests/node-onboarding/test_runtime.py | 65 +++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 42d4c5a..f56c48a 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,12 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-17 后续五节点接入(进行中) + +本批授权新增5个节点,复用一个核查验收助手。准备阶段独立审查发现并修复正式适配器的两处调用链阻断:QA订阅远端Python脚本缩进错误、标准化快照未保留协议类型导致最终audit无法重复校验。Python全套34项通过,新增覆盖实际发送的QA脚本编译,以及正式source→snapshot→audit→缓存audit链路;这些是离线回归,不表示本批线上接入或验收完成。 + +候选jp06/07/08、us03现场只读核查通过;us02因SSH主机密钥与既有信任不符停止,用户批准备用us04精确首次指纹后核查通过。5台原XrayR均在运行,新接入端口24443空闲;Debian11/10的Git依赖按各自发行版准备,不沿用硬编码bullseye源。本批账本与现场证据在忽略目录`codex/output/node-batch-five-20261017/`。正式开放、动态全量订阅、QA清理、旧资源回归及资源文档同步尚待完成。 + ## 2026-10-17 批量接入流程优化(本地验证) 已实现正式`ssh-site-v1`适配器及`configure-site.py`入口,去除临时handler中的固定日期、日本、组1和节点数量。正式验收从真实账号授权集合生成快照,完整校验新旧节点、预期出口、严格TLS、原始证据摘要、QA身份/403/禁用/下发排除及时效;集合变化停止。没有有效正式组账号时自动创建100MiB/1小时/1设备非管理员测试用户,journal恢复同一身份,正常成功/失败/等待均清理本任务用户,关闭后不复活;异常中断依靠日志恢复和时限兜底。已批准指纹匹配后才读取凭据,SSH始终经白色机,按线程复用会话、按阶段延迟连接,不自动重放断线命令。 diff --git a/scripts/node-onboarding/acceptance.py b/scripts/node-onboarding/acceptance.py index c62470a..d3aac72 100644 --- a/scripts/node-onboarding/acceptance.py +++ b/scripts/node-onboarding/acceptance.py @@ -13,7 +13,7 @@ def digest(value): def snapshot(nodes): if not isinstance(nodes, list) or not nodes: raise ValueError('Empty authorized node set') - fields = ('id', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country') + fields = ('id', 'type', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country') result = [] for node in nodes: if node.get('type') != 'anytls' or any(node.get(k) in (None, '') for k in fields): diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 962e8b4..52eb145 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -297,16 +297,16 @@ for attempt in range(3 if source['owned'] else 1): if prior is None:write(dest,target,data) def fetch_qa(gateway): script='''import json,urllib.request - +from pathlib import Path - +q=json.loads(Path(Q+'/qa.json').read_text()) - +req=urllib.request.Request(SUB+'/api/v1/client/subscribe?token='+q['subscribe_token'],headers={'User-Agent':'sing-box/1.14.3'}) - +with urllib.request.urlopen(req,timeout=30) as r: - + data=r.read(); code=r.status; headers={k.lower():v for k,v in r.headers.items()} - +import gzip - +if headers.get('content-encoding','').lower()=='gzip':data=gzip.decompress(data) - +v=json.loads(data);Path(Q+'/subscription.json').write_bytes(data);Path(Q+'/subscription.json').chmod(0o600) - +print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control':headers.get('cache-control'),'age':headers.get('age')})) - +'''.replace('\n+','\n') +from pathlib import Path +q=json.loads(Path(Q+'/qa.json').read_text()) +req=urllib.request.Request(SUB+'/api/v1/client/subscribe?token='+q['subscribe_token'],headers={'User-Agent':'sing-box/1.14.3'}) +with urllib.request.urlopen(req,timeout=30) as r: + data=r.read(); code=r.status; headers={k.lower():v for k,v in r.headers.items()} +import gzip +if headers.get('content-encoding','').lower()=='gzip':data=gzip.decompress(data) +v=json.loads(data);Path(Q+'/subscription.json').write_bytes(data);Path(Q+'/subscription.json').chmod(0o600) +print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control':headers.get('cache-control'),'age':headers.get('age')})) +''' return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script) def phase_impl(panel,gateway,node): if a.phase=='preflight': diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index 31ddc59..98d7c76 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -32,6 +32,12 @@ class EvidenceTests(unittest.TestCase): for count in [1,2,7,23]: nodes,report,qas=self.fixture(count) self.assertEqual(self.check(nodes,report,qas,after=list(reversed(nodes)))['nodes'],count) + def test_snapshot_can_be_revalidated_by_formal_audit(self): + nodes,report,qas=self.fixture(2) + normalized=snapshot(nodes) + self.assertEqual(snapshot(normalized),normalized) + self.assertTrue(all(n['type']=='anytls' for n in normalized)) + self.assertEqual(self.check(normalized,report,qas)['nodes'],2) def test_topology_change_including_old_node_is_blocked(self): nodes,r,q=self.fixture(7) for after in [nodes[:-1],nodes+self.fixture(8)[0][-1:],list(reversed(nodes[:-1]))]: @@ -194,11 +200,16 @@ class SiteAdapterTests(unittest.TestCase): def chmod(self,*args):pass def posix_rename(self,old,new):self.files[new]=self.files.pop(old) state=dict(manifest={'test_user_id':9},node_online=True,groups=['2'],qa_banned=False,u=12,d=34) + qa_programs=[] class Remote: def __init__(self,kind):self.kind=kind;self.files={} def open_sftp(self):return Sftp(self.files) def exec_command(self,cmd,timeout=0): if cmd.endswith(' && php'):return In(),Out(json.dumps(state).encode()),Out() + if cmd.startswith("python3 - <<'DPLPY'\n"): + program=cmd.split('\n',1)[1].rsplit('\nDPLPY',1)[0] + compile(program,'actual-fetch-qa-remote','exec') + qa_programs.append(program) if 'verify-node.py' in cmd:self.files['/qa/new/qa-report.json']=raw return In(),Out(b'{}'),Out() remotes={key:Remote(key) for key in ['panel','gateway','node']} @@ -208,9 +219,63 @@ class SiteAdapterTests(unittest.TestCase): adapter.pool=Pool() node=dict(key='new',request_sha256='request',formal_group_id=1,vps_id='node',socks_port=19011,expected_exit='192.0.2.11',country='JP') result=adapter.call(node,'verify','probe');self.assertEqual(result['status'],'ready') + self.assertEqual(len(qa_programs),1) + self.assertIn('urllib.request.urlopen',qa_programs[0]) exported=json.loads((Path(d)/'new-evidence.json').read_text());self.assertEqual(exported['report_sha256'],hashlib.sha256(raw).hexdigest());self.assertEqual(exported['panel']['d'],34) self.assertEqual(remotes['panel'].files['/backups/new/qa-report.json'],raw) + def test_formal_source_snapshot_audit_and_cached_audit_call_chain(self): + import contextlib + import io + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter) + ctx=dict(gateway_vps='gateway',qa_root='/qa',ops_root='/ops',subscription_url='https://sub.example.invalid',client_binary='/client',formal_socks_port=19999,node_backup_root='/backups',batch_backup_root='/batch') + candidate=dict(key='new',formal_group_id=7,relay_host='relay.example.invalid',relay_port=47002,tls_domain='n2.example.invalid') + adapter.plan={'context':ctx,'nodes':[candidate]};adapter.ctx=ctx + adapter.directory=Path('/private/tmp/formal-chain-fixture/ledger/batch') + adapter.lock=threading.RLock();adapter.resource_lock=lambda *args:contextlib.nullcontext() + adapter.formal_done={};adapter.exit_registry={f'n{i}.example.invalid':dict(expected_exit=f'192.0.2.{i}',country='JP') for i in [1,2]} + source={'user_id':1,'owned':False,'token':'fixture-only','nodes':[dict(id=i,type='anytls',tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',uuid='fixture-only') for i in [1,2]]} + accounts=[] + def account_command(group,action,apply=False,client=None): + accounts.append(action) + return copy.deepcopy(source) if action in ['source','snapshot'] else {'state':'closed'} + adapter.account_command=account_command + now=int(time.time()) + raw=json.dumps({'state':'live_verified','verified_at':now,'results':[dict(tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',exit_ip=f'192.0.2.{i}',country='JP',live=True,strict_tls=True) for i in [1,2]]}).encode() + files={};persisted=[];programs=[] + def execute(client,command,timeout=120): + if command.startswith("python3 - <<'DPLFORMAL'\n"): + program=command.split('\n',1)[1].rsplit('\nDPLFORMAL',1)[0] + compile(program,'actual-formal-remote','exec');programs.append(program) + files['/qa/formal-group-7/report.json']=raw + return '' + def php(client,body): + return {} if '$out=[]' in body else {'id':9,'token':'fixture-only','banned':True,'excluded':True} + def write(client,path,value):files[path]=value.encode() if isinstance(value,str) else value + adapter.atomic_json=lambda path,value:persisted.append(value) + class Channel: + def shutdown_write(self):pass + def recv_exit_status(self):return 0 + class In: + channel=Channel() + def write(self,data):pass + class Out(io.BytesIO):channel=Channel() + class Gateway: + def exec_command(self,command,timeout=0): + if command!='python3 -':raise AssertionError('Unexpected QA probe command') + return In(),Out(b'403\n'),Out() + gateway=Gateway() + adapter.formal(candidate,None,gateway,php,execute,lambda client,path:files.get(path),write) + self.assertEqual(len(programs),1) + self.assertEqual(len(persisted),1) + self.assertEqual(len(persisted[0]['snapshot']),2,'The old node must remain in complete acceptance') + self.assertTrue(all(n['type']=='anytls' for n in persisted[0]['snapshot'])) + self.assertEqual(persisted[0]['qas']['users']['new']['http_status'],403) + self.assertIn('cleanup',accounts) + self.assertEqual(adapter.formal(candidate,None,gateway,php,execute,lambda client,path:files.get(path),write)['nodes'],2) + self.assertEqual(len(programs),1,'A cached audit rechecks topology without rerunning the client') + def test_preserved_service_review_binds_original_and_exact_observation(self): from site_adapter import SiteAdapter adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} From 44d1c79befe6c24c9f0b743ba85f6e7b9595cf38 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 17:49:19 +0800 Subject: [PATCH 13/25] fix(nodes): restore installed Certbot AppArmor profiles on legacy hosts --- docs/maintenance/NODE_BATCH_RUNTIME.md | 2 ++ docs/maintenance/STATUS.md | 2 ++ .../dpl-certbot-apparmor-renew.conf | 7 +++++ .../dpl-certbot-apparmor.service | 13 +++++++++ .../node-onboarding/load-certbot-apparmor.sh | 27 +++++++++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 scripts/node-onboarding/dpl-certbot-apparmor-renew.conf create mode 100644 scripts/node-onboarding/dpl-certbot-apparmor.service create mode 100644 scripts/node-onboarding/load-certbot-apparmor.sh diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 6e9d6c0..c287d68 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -28,6 +28,8 @@ python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base 适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 +Certbot准备不能只检查`command -v`:还须实际运行`certbot --version`。2026-10-17后续批次两台Debian10虽有Snap Certbot、renew timer和生成的profiles,但既有AppArmor加载服务缺失,证书命令报`missing profile`。项目提供`load-certbot-apparmor.sh`、`dpl-certbot-apparmor.service`及renew drop-in作为该情形的限定修复:只校验并加载已安装Certbot/core生成的profiles,保留其原有模式;不启用全局AppArmor服务、不关闭安全校验、不重启XrayR。仅在现场确认符合该缺口的主机部署到独立Git目录`/opt/dpl-certbot-security/provision`,先`--check`、再加载并验证工具;原续期命令前重检profiles,启动时也加载。安装前保存原单位/drop-in存在性及已加载profile清单;失败不签发,回退先确认无证书进程,再撤销本任务unit/drop-in并按原清单处理本次新加载项,不卸载其他任务profile。本段修复的实际部署及验收状态见[STATUS](STATUS.md)。 + ## 本地参数文件 全部放在权限700的任务目录,文件600。基础计划沿用[NODE_BATCH](NODE_BATCH.md#参数格式),包括本批任意数量、任意已支持国家的候选节点;不固定五节点、日本、组1或任务日期。新增站点配置JSON如下,示例路径必须换为实际已核实值: diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index f56c48a..5c4683e 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -8,6 +8,8 @@ 候选jp06/07/08、us03现场只读核查通过;us02因SSH主机密钥与既有信任不符停止,用户批准备用us04精确首次指纹后核查通过。5台原XrayR均在运行,新接入端口24443空闲;Debian11/10的Git依赖按各自发行版准备,不沿用硬编码bullseye源。本批账本与现场证据在忽略目录`codex/output/node-batch-five-20261017/`。正式开放、动态全量订阅、QA清理、旧资源回归及资源文档同步尚待完成。 +现场进展:面板、网关和5台候选的运维目录已Git部署至2547ea25;修复后的本地适配器源码ac9f249已推送。日本3台QA及独立审查通过,尚未开放;美国两台节点程序已安装,但既有Snap Certbot因AppArmor profile未加载而不能签证书。已准备限定Certbot/core profiles的安全加载脚本与启动/续期unit,尚待提交推送、独立运维部署及实际验证;不将准备状态写成修复完成。 + ## 2026-10-17 批量接入流程优化(本地验证) 已实现正式`ssh-site-v1`适配器及`configure-site.py`入口,去除临时handler中的固定日期、日本、组1和节点数量。正式验收从真实账号授权集合生成快照,完整校验新旧节点、预期出口、严格TLS、原始证据摘要、QA身份/403/禁用/下发排除及时效;集合变化停止。没有有效正式组账号时自动创建100MiB/1小时/1设备非管理员测试用户,journal恢复同一身份,正常成功/失败/等待均清理本任务用户,关闭后不复活;异常中断依靠日志恢复和时限兜底。已批准指纹匹配后才读取凭据,SSH始终经白色机,按线程复用会话、按阶段延迟连接,不自动重放断线命令。 diff --git a/scripts/node-onboarding/dpl-certbot-apparmor-renew.conf b/scripts/node-onboarding/dpl-certbot-apparmor-renew.conf new file mode 100644 index 0000000..23233b2 --- /dev/null +++ b/scripts/node-onboarding/dpl-certbot-apparmor-renew.conf @@ -0,0 +1,7 @@ +[Unit] +Requires=dpl-certbot-apparmor.service +After=dpl-certbot-apparmor.service + +[Service] +# Recheck generated profiles after a snap refresh, before the existing renewal command. +ExecStartPre=/bin/bash /opt/dpl-certbot-security/provision/load-certbot-apparmor.sh --load diff --git a/scripts/node-onboarding/dpl-certbot-apparmor.service b/scripts/node-onboarding/dpl-certbot-apparmor.service new file mode 100644 index 0000000..3d96645 --- /dev/null +++ b/scripts/node-onboarding/dpl-certbot-apparmor.service @@ -0,0 +1,13 @@ +[Unit] +Description=Load existing Certbot AppArmor profiles for DPL certificate operations +After=local-fs.target +Before=snap.certbot.renew.service +ConditionPathExists=/var/lib/snapd/apparmor/profiles/snap.certbot.certbot + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/dpl-certbot-security/provision/load-certbot-apparmor.sh --load +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/scripts/node-onboarding/load-certbot-apparmor.sh b/scripts/node-onboarding/load-certbot-apparmor.sh new file mode 100644 index 0000000..cf2d196 --- /dev/null +++ b/scripts/node-onboarding/load-certbot-apparmor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail +# Restore only profiles already generated by the installed Certbot/core snaps. +# This does not enable the global AppArmor service or change profile modes. +[[ $(id -u) == 0 ]] || exit 1 +mode=${1:---load} +[[ $# -le 1 && ( $mode == --load || $mode == --check ) ]] || exit 2 +[[ $(cat /sys/module/apparmor/parameters/enabled) == Y ]] || exit 3 +directory=/var/lib/snapd/apparmor/profiles +[[ -d $directory && ! -L $directory && $(stat -c %u "$directory") == 0 ]] || exit 4 +[[ -z $(find "$directory" -maxdepth 0 -perm /022 -print) ]] || exit 4 +[[ -f $directory/snap.certbot.certbot && -f $directory/snap.certbot.renew ]] || exit 5 +shopt -s nullglob +profiles=("$directory"/snap.certbot.* "$directory"/snap-update-ns.certbot "$directory"/snap-confine.core.*) +(( ${#profiles[@]} >= 4 )) || exit 5 +# Validate every input before loading any profile. Never use complain/disable flags. +for profile in "${profiles[@]}"; do + [[ -f $profile && ! -L $profile && $(stat -c %u "$profile") == 0 ]] || exit 6 + [[ -z $(find "$profile" -maxdepth 0 -perm /022 -print) ]] || exit 6 + /usr/sbin/apparmor_parser --skip-kernel-load --skip-cache "$profile" +done +if [[ $mode == --load ]]; then + for profile in "${profiles[@]}"; do + /usr/sbin/apparmor_parser --replace --skip-cache "$profile" + done +fi +printf 'Certbot/core profiles checked: %s; mode: %s\n' "${#profiles[@]}" "$mode" From 6b0ac99d1984ff55f70aa6838b72290280af8bf1 Mon Sep 17 00:00:00 2001 From: hellcat Date: Sat, 17 Oct 2026 17:50:02 +0800 Subject: [PATCH 14/25] fix(nodes): restrict Certbot loader to current mounted core revision --- scripts/node-onboarding/dpl-certbot-apparmor.service | 1 + scripts/node-onboarding/load-certbot-apparmor.sh | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/node-onboarding/dpl-certbot-apparmor.service b/scripts/node-onboarding/dpl-certbot-apparmor.service index 3d96645..c7b568a 100644 --- a/scripts/node-onboarding/dpl-certbot-apparmor.service +++ b/scripts/node-onboarding/dpl-certbot-apparmor.service @@ -1,6 +1,7 @@ [Unit] Description=Load existing Certbot AppArmor profiles for DPL certificate operations After=local-fs.target +RequiresMountsFor=/snap/core/current /snap/certbot/current /var/lib/snapd/apparmor/profiles Before=snap.certbot.renew.service ConditionPathExists=/var/lib/snapd/apparmor/profiles/snap.certbot.certbot diff --git a/scripts/node-onboarding/load-certbot-apparmor.sh b/scripts/node-onboarding/load-certbot-apparmor.sh index cf2d196..f856c97 100644 --- a/scripts/node-onboarding/load-certbot-apparmor.sh +++ b/scripts/node-onboarding/load-certbot-apparmor.sh @@ -11,7 +11,10 @@ directory=/var/lib/snapd/apparmor/profiles [[ -z $(find "$directory" -maxdepth 0 -perm /022 -print) ]] || exit 4 [[ -f $directory/snap.certbot.certbot && -f $directory/snap.certbot.renew ]] || exit 5 shopt -s nullglob -profiles=("$directory"/snap.certbot.* "$directory"/snap-update-ns.certbot "$directory"/snap-confine.core.*) +core=$(readlink -f /snap/core/current) +[[ $core =~ ^/(var/lib/snapd/)?snap/core/([0-9]+)$ ]] || exit 5 +revision=${BASH_REMATCH[2]} +profiles=("$directory"/snap.certbot.* "$directory"/snap-update-ns.certbot "$directory/snap-confine.core.$revision") (( ${#profiles[@]} >= 4 )) || exit 5 # Validate every input before loading any profile. Never use complain/disable flags. for profile in "${profiles[@]}"; do From d085e211b3d674258da842b167b944362d73cb7d Mon Sep 17 00:00:00 2001 From: hellcat Date: Sun, 18 Oct 2026 20:13:33 +0800 Subject: [PATCH 15/25] fix(nodes): validate renewal hooks and record five-node acceptance --- docs/maintenance/NODE_BATCH_RUNTIME.md | 6 ++- docs/maintenance/NODE_ONBOARDING.md | 34 ++++++++++++-- docs/maintenance/NODE_RUNBOOK.md | 4 +- docs/maintenance/STATUS.md | 8 ++-- scripts/node-onboarding/site_adapter.py | 24 +++++++--- tests/node-onboarding/test_runtime.py | 60 +++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 17 deletions(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index c287d68..6913cc3 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -1,6 +1,6 @@ # 批量接入正式适配器 -本专题是后续批量接入的默认技术入口。适配器实现、隔离测试及文档于2026-10-17完成;**本轮没有新增线上节点、部署新运维版本或运行真实SSH/客户端验收**。上一轮三节点实际验收使用的是已冻结旧任务适配器,不能套用为新实现的生产验收。 +本专题是后续批量接入的默认技术入口。适配器实现与隔离测试于2026-10-17完成,2026-10-18首次正式批次线上验收完成;部署、动态全量验收和例外范围见[本次节点记录](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。此前三节点批次使用冻结旧任务适配器,与本实现的生产证据分开。 `batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、独立助手审核、Git运维部署及现场验收。 @@ -26,7 +26,7 @@ python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base 同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。 -适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 +适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局执行钩子、普通/Snap目录钩子或目标证书额外执行钩子均停止。目标证书只允许单个精确专用`renew_hook`或`deploy_hook`(兼容连字符别名);重复及未知执行钩子停止。2026-10-18补齐字段兼容检查,仅用于后续新计划;已完成批次的实际钩子另行只读审查,冻结源码/计划/pin不重写。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。 Certbot准备不能只检查`command -v`:还须实际运行`certbot --version`。2026-10-17后续批次两台Debian10虽有Snap Certbot、renew timer和生成的profiles,但既有AppArmor加载服务缺失,证书命令报`missing profile`。项目提供`load-certbot-apparmor.sh`、`dpl-certbot-apparmor.service`及renew drop-in作为该情形的限定修复:只校验并加载已安装Certbot/core生成的profiles,保留其原有模式;不启用全局AppArmor服务、不关闭安全校验、不重启XrayR。仅在现场确认符合该缺口的主机部署到独立Git目录`/opt/dpl-certbot-security/provision`,先`--check`、再加载并验证工具;原续期命令前重检profiles,启动时也加载。安装前保存原单位/drop-in存在性及已加载profile清单;失败不签发,回退先确认无证书进程,再撤销本任务unit/drop-in并按原清单处理本次新加载项,不卸载其他任务profile。本段修复的实际部署及验收状态见[STATUS](STATUS.md)。 @@ -108,6 +108,8 @@ preflight/publish/audit始终现场执行。只有先前状态为done、收到 原服务保存最初unit/程序/配置SHA256、监听、active及PID/启动时间/重启数。配置/程序/监听改变或不可用停止;仅进程变化分类为“需调查”,不宣称是DPL导致,也不自动刷新基线。记录观察后由操作者及助手核查生命周期、OOM、操作时间线和稳定采样;认可时在本节点私有备份登记`preserved-service-review.json`,绑定最初/本次观察摘要、原因及证据SHA256。原基线保持;再次重启不匹配旧审核,须重新调查。 +隔离stage QA与正式验收租约分开:stage创建的QA为24小时,长时间中断后必须先核对实际有效期,不能用未禁用状态代替有效账号判断。本次曾在独立审查后对仍归本任务、未banned、未推广、原身份/隔离组/额度/设备/无订单全匹配的过期QA作单次1小时续期;仅改expired_at,写前私有journal+事务行锁,重新联网测试并重审,不复用旧报告。已有续期journal再次到期或任一归属变化则停止,不自动顺延或启用关闭账号;正式推广后仍须验证banned/403/实际下发排除。此为受限恢复步骤,不授权后台长期续期。 + ## 验证范围 新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index d21c137..97ae70c 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,24 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-18 后续五节点批量接入 + +新增日本 JP - 05/06/07(node-jp06/07/08)和美国 US - 02/03(node-us03/04),展示编号独立于VPS、部署key和数据库ID。均以独立AnyTLS 24443、DNS only节点证书名和专用中转规则接入;只把新节点append到基础节点组1,未改旧443中转。node-us02因既有SSH信任密钥不符停止且未发送目标凭据,按用户批准备用node-us04精确指纹接入。 + +五台均先在各自隔离组完成完整订阅、严格TLS/SNI、预期IPv4/国家、在线及上下行上报测试;一个助手独立复核面板与网关原始报告字节摘要及精确QA身份后,主协调者按append预览/应用开放。本批QA21/23/22/24/25分别属于jp06/jp07/jp08/us03/us04;节点/机器ID为6/8/7/9/10。等待超过24小时使QA到期后,事务内核对归属、未禁用、隔离、无订单及100MiB额度,仅续原账号1小时;没有重置身份或复活已禁用用户,旧报告全部重测并重新审核。 + +正式验收动态读取有效既有管理员实际授权集合,本次为美国01至03、日本01至07共10节点,逐条完整订阅联网、严格TLS及独立预期出口/国家一致。五个QA均已禁用,订阅403且不在节点实际用户下发集合;未为正式验收新增用户或更改管理员权限。全部旧节点关键配置、套餐、原用户组/套餐关联和订阅地址均符合原基线;四个网关容器ID、启动时间、重启数保持原值。管理后台节点/服务器列表切换、名称、端口、正式权限组及在线状态已回读;未额外保存编辑表单默认值。未进行本机/手机客户端GUI、TUN、多地区、多设备并发或生产回退演练。 + +5台节点的证书签发和专用续期dry-run均通过。两台美国节点既有Snap Certbot AppArmor加载缺口已用独立运维提交ebe12d3修复,目录/loader/启动及续期unit均保持生成profiles原模式;包含当前core Snap helper,不启用全局AppArmor服务、不关闭校验。原XrayR程序、unit、配置及443监听保持;日本3台在等待/验收前检期间发生既有xray-core v1.8.20 QUIC sniffer越界panic并自动恢复,逐台日志、无OOM及稳定采样独立审查后登记精确原/现摘要回执,原基线保留。此次没有修复或升级旧XrayR。 + +本地源码6b0ac99及独立运维2547ea25、Certbot安全修复ebe12d3均已提交推送;面板/网关和5台节点仅部署运维目录,DPL生产应用仍为7e8fbf6,原工作区摘要未变。Python34项回归及既有PHP隔离19项103断言已通过(具体执行日期以原测试记录为准);本轮恢复另对受限修复、取证与文档脚本做语法及独立审查,真实业务结果以本节现场验收为准。 + +本批曾因发布probe状态未知停止,经独立现场回读确认3台已推广、2台仍隔离,未盲重放;后两台重新测试并审核。收尾时另发生新SSH握手失败,白色机聚合日志无足够证据确定原因。最终使用独立审核的任务完成控制器(SHA256见任务finish-controller-reviewed.sha256),单工作线程、已认证连接保活及工作线程内清理,仍调用冻结Coordinator.phase;全批publish屏障后统一动态audit。未修改冻结适配器源码或跳过现场关卡,不把此任务控制器写成通用调度器已自动采用的策略。 + +收尾发现Certbot 5.8把专用命令持久化为`renew_hook`。本批已另行只读核对五台实际配置:均为单个精确专用命令,两个全局配置无执行钩子,普通/Snap目录钩子为空。随后本地适配器补齐`renew_hook`/`deploy_hook`及连字符别名、重复/未知钩子与目录钩子检查,回归测试覆盖实际生成的远端检查程序,收尾完整Python 39项测试通过。该修复用于后续新计划;本批冻结运行源码已按摘要归档于`frozen-runtime/`,原计划、账本及pin未重写,也未用新代码重放已完成批次。 + +证据入口:忽略目录codex/output/node-batch-five-20261017/。原frozen.json及旧账本摘要不改;GemDocs迁移仅更换SSH凭据引用根后,新resume.json保持context/nodes/owner不变并固定新runtime pin,恢复账本为ledger/jp06-jp08-us03-us04-20261018-resume。最终formal-group-1.json、final-proof原始报告/QA拒绝/集合/绑定、final-metadata.json及后台截图共同保存验收。面板和节点备份仍为/root/codex-backups/dpl/node-batch-five-20261017,网关验收为/var/lib/dpl-sgw/node-batch-five-20261017;回退按各节点promotion-journal逐字段预览,不恢复整库或覆盖共享资源。GemDocs AI版新增5实例/5域名及相关服务器、项目引用已同步;人类版未自动写入。 + ## 2026-10-17 三个日本节点批量接入 按用户授权新增日本 JP - 02/03/04,分别部署于 node-jp03/04/05;展示编号、VPS、部署key和数据库ID独立。三台均为本次登录核实的 Debian11/x86_64、JP IPv4出口;新建独立 AnyTLS 24443、DNS only A记录及中转规则 dpl-jp03/04/05-anytls,入口47203/47204/47205,总规则25→28。收发Proxy Protocol均关闭,未改原443规则。 @@ -41,7 +59,7 @@ 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 -流量链路按部署key分列;2026-10-17正式五节点订阅逐条联网复验通过: +流量链路按部署key分列;2026-10-18正式十节点动态完整订阅逐条联网复验通过: | 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | | --- | --- | --- | --- | --- | --- | @@ -50,8 +68,13 @@ | jp03 | node-jp03 | haiwi.lshan.eu.org:47203 | 185.200.65.70:24443 | jp03.deeplink-subscribe.com | 本文2026-10-17批量接入 | | jp04 | node-jp04 | haiwi.lshan.eu.org:47204 | 85.113.70.174:24443 | jp04.deeplink-subscribe.com | 本文2026-10-17批量接入 | | jp05 | node-jp05 | haiwi.lshan.eu.org:47205 | 45.153.246.171:24443 | jp05.deeplink-subscribe.com | 本文2026-10-17批量接入 | +| jp06 | node-jp06 | haiwi.lshan.eu.org:47206 | 45.153.246.172:24443 | jp06.deeplink-subscribe.com | 本文2026-10-18批量接入 | +| jp07 | node-jp07 | haiwi.lshan.eu.org:47207 | 45.153.246.173:24443 | jp07.deeplink-subscribe.com | 本文2026-10-18批量接入 | +| jp08 | node-jp08 | haiwi.lshan.eu.org:47208 | 172.93.221.4:24443 | jp08.deeplink-subscribe.com | 本文2026-10-18批量接入 | +| us03 | node-us03 | haiwi.lshan.eu.org:47103 | 107.175.24.202:24443 | us03.deeplink-subscribe.com | 本文2026-10-18批量接入 | +| us04 | node-us04 | haiwi.lshan.eu.org:47104 | 23.94.87.178:24443 | us04.deeplink-subscribe.com | 本文2026-10-18批量接入 | -客户端 → 对应中转入口 → 对应落地 → 目标网络。五条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 +客户端 → 对应中转入口 → 对应落地 → 目标网络。十条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 | 资源 | DPL独立资源(各次接入) | 必须保留的原有资源 | | --- | --- | --- | @@ -60,7 +83,12 @@ | node-jp03 | dpl-node,TCP 24443,机器/节点 ID 4(2026-10-17) | XrayR,TCP 443;自身panic恢复例外见本次记录 | | node-jp04 | dpl-node,TCP 24443,机器/节点 ID 5(2026-10-17) | XrayR,TCP 443 | | node-jp05 | dpl-node,TCP 24443,机器/节点 ID 3(2026-10-17) | XrayR,TCP 443 | -| 中转 | 五条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-jp06 | dpl-node,TCP 24443,机器/节点 ID 6(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 | +| node-jp07 | dpl-node,TCP 24443,机器/节点 ID 8(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 | +| node-jp08 | dpl-node,TCP 24443,机器/节点 ID 7(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 | +| node-us03 | dpl-node,TCP 24443,机器/节点 ID 9(2026-10-18) | XrayR,TCP 443 | +| node-us04 | dpl-node,TCP 24443,机器/节点 ID 10(2026-10-18) | XrayR,TCP 443 | +| 中转 | 十条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index a90c38d..feaaebc 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -73,7 +73,7 @@ esac | 面板 | Git 提交、当前组和节点清单、现有订阅设置、套餐/用户分组快照、应用运行环境、脚本实际版本 | 先补证据与备份,不创建机器/节点/QA 记录 | | 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 | | 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 | -| 证书 | 已有 renewal 配置、pre/post/deploy hooks、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | +| 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | | 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 | | 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 | | 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 | @@ -138,7 +138,7 @@ bash "$NODE_PROVISION/install-node.sh" \ --no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks ``` -`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。 +`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。Certbot 5.8.0实测把`--deploy-hook`持久化为`renew_hook`;与`deploy_hook`按同一种部署钩子检查,只接受单个精确专用命令,不能把未识别字段当作无hook。全局cli.ini也须覆盖renew别名及其他可执行hook;目标出现额外pre/post或未知hook时停止。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。 续期完成条件包括:证书配置正确、调度 enabled/下一次触发可见、调度参数确实覆盖新证书及专用 hook、dry-run 退出状态和旧服务保护证据齐全。证书到期时间引用证书/域名对应记录,不把一次演练日期当作长期验收。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 5c4683e..dae0f27 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,13 +2,13 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 -## 2026-10-17 后续五节点接入(进行中) +## 2026-10-18 后续五节点接入完成 -本批授权新增5个节点,复用一个核查验收助手。准备阶段独立审查发现并修复正式适配器的两处调用链阻断:QA订阅远端Python脚本缩进错误、标准化快照未保留协议类型导致最终audit无法重复校验。Python全套34项通过,新增覆盖实际发送的QA脚本编译,以及正式source→snapshot→audit→缓存audit链路;这些是离线回归,不表示本批线上接入或验收完成。 +日本JP - 05/06/07(node-jp06/07/08)及美国US - 02/03(node-us03/04)已append基础节点组1;五份新QA经独立原始字节审查后开放并禁用QA21–25。动态正式授权集合本次10节点全部完整订阅、严格TLS和预期出口/国家联网通过,QA403/实际下发排除、旧节点/套餐/用户关联/订阅地址及四网关容器基线审计通过;后台节点和服务器列表显示/切换/保存值回读通过。GemDocs AI版已登记5实例/5域名及项目和服务器关联。 -候选jp06/07/08、us03现场只读核查通过;us02因SSH主机密钥与既有信任不符停止,用户批准备用us04精确首次指纹后核查通过。5台原XrayR均在运行,新接入端口24443空闲;Debian11/10的Git依赖按各自发行版准备,不沿用硬编码bullseye源。本批账本与现场证据在忽略目录`codex/output/node-batch-five-20261017/`。正式开放、动态全量订阅、QA清理、旧资源回归及资源文档同步尚待完成。 +面板、网关、5节点运维2547ea25,US两台Certbot安全修复ebe12d3已Git部署;源码6b0ac99已推送,生产应用仍7e8fbf6及原工作区摘要。所有新证书签发/专用续期dry-run通过。日本三台原XrayR既有QUIC sniffer panic自动恢复已独立调查并绑定精确回执,原程序/配置/unit/443保持;原基线不覆盖,未修复旧XrayR。GemDocs迁移后原冻结计划/账本不改,新resume计划保持context/nodes/owner并固定新SSH引用摘要。完整证据、边界、恢复及未测项目见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。 -现场进展:面板、网关和5台候选的运维目录已Git部署至2547ea25;修复后的本地适配器源码ac9f249已推送。日本3台QA及独立审查通过,尚未开放;美国两台节点程序已安装,但既有Snap Certbot因AppArmor profile未加载而不能签证书。已准备限定Certbot/core profiles的安全加载脚本与启动/续期unit,尚待提交推送、独立运维部署及实际验证;不将准备状态写成修复完成。 +收尾本地适配器补齐Certbot `renew_hook`及别名、重复/未知/目录执行钩子检查;Python完整39项回归通过。本批实际钩子已另行只读核对,冻结源码归档、原计划/pin不改;新修复仅用于后续新计划,无需重部署生产应用。 ## 2026-10-17 批量接入流程优化(本地验证) diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 52eb145..5064492 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -73,19 +73,31 @@ class SiteAdapter: owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256']) script=r"""from pathlib import Path import re +def hook_options(path): + for line in path.read_text().splitlines(): + match=re.match(r'^\s*([A-Za-z0-9_-]+)\s*=(.*)$',line) + if match: + key=match.group(1).lower().replace('-','_') + if key.endswith('_hook'):yield key,match.group(2).strip() for name in ['/etc/letsencrypt/cli.ini','/root/.config/letsencrypt/cli.ini']: p=Path(name) - if p.exists(): - for line in p.read_text().splitlines(): - if re.match(r'^\s*(pre[-_]hook|post[-_]hook|deploy[-_]hook)\s*=\s*\S',line):raise SystemExit(2) + if p.exists() and any(value for key,value in hook_options(p)):raise SystemExit(2) +# Both normal and Snap directory hooks need an explicit independent review. +for name in ['/etc/letsencrypt/renewal-hooks','/var/snap/certbot/common/letsencrypt/renewal-hooks']: + p=Path(name) + if p.is_symlink() or (p.exists() and any(x.is_file() or x.is_symlink() for x in p.rglob('*'))):raise SystemExit(6) domain=DOMAIN paths=[Path('/etc/letsencrypt')/kind/domain for kind in ['live','archive']]+[Path('/etc/letsencrypt/renewal')/(domain+'.conf')] if not OWNED and any(p.exists() or p.is_symlink() for p in paths):raise SystemExit(3) p=paths[-1] if p.exists(): - for line in p.read_text().splitlines(): - if re.match(r'^\s*(pre_hook|post_hook)\s*=\s*\S',line):raise SystemExit(4) - if re.match(r'^\s*deploy_hook\s*=',line) and line.split('=',1)[1].strip()!=HOOK:raise SystemExit(5) + seen_hook=False + for key,value in hook_options(p): + if key in ('deploy_hook','renew_hook'): + if seen_hook:raise SystemExit(5) + seen_hook=True + if value!=HOOK:raise SystemExit(5) + elif value:raise SystemExit(4) """ prefix='DOMAIN='+repr(n['tls_domain'])+';OWNED='+repr(owned)+';HOOK='+repr('/bin/sh '+self.ctx['node_root']+'/renew-node.sh')+'\n' execute(node,"python3 - <<'DPLCERTGATE'\n"+prefix+script+"\nDPLCERTGATE") diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index 98d7c76..fb1c0b9 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -434,4 +434,64 @@ class PackagingTests(unittest.TestCase): for x in lines[:-1]:self.assertEqual(set(x),{'batch','node','phase','status','time'}) finally:test.tearDown() +class CertificateHookGateTests(unittest.TestCase): + """Execute certificate_gate's actual remote Python against an isolated filesystem.""" + def check_gate(self, target='', global_hook='', owned=True, directory_hook=None, conflict=None, global_file='/etc/letsencrypt/cli.ini'): + import types + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter);adapter.ctx={'node_root':'/opt/dpl-node/provision'} + node={'tls_domain':'qa.example.invalid','request_sha256':'owner'} + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp) + def mapped(value):return root/str(value).lstrip('/') + if target: + path=mapped('/etc/letsencrypt/renewal/qa.example.invalid.conf');path.parent.mkdir(parents=True);path.write_text(target) + if global_hook: + path=mapped(global_file);path.parent.mkdir(parents=True,exist_ok=True);path.write_text(global_hook) + if directory_hook: + path=mapped(directory_hook);path.parent.mkdir(parents=True,exist_ok=True);path.write_text('# hook') + if conflict: + path=mapped('/etc/letsencrypt/'+conflict+'/qa.example.invalid');path.parent.mkdir(parents=True,exist_ok=True);path.mkdir() + calls=[] + def execute(_,command): + calls.append(command) + if command.startswith("python3 - <<'DPLCERTGATE'\n"): + program=command.split('\n',1)[1].rsplit('\nDPLCERTGATE',1)[0] + compile(program,'actual-generated-certificate-gate','exec') + with patch.dict(sys.modules,{'pathlib':types.SimpleNamespace(Path=mapped)}): + exec(program,{'__builtins__':__builtins__}) + owner=json.dumps({'request_sha256':'owner'}).encode() if owned else None + adapter.certificate_gate(node,None,lambda *args:owner,execute) + self.assertEqual(len(calls),2) # Tool readiness is reached only after gate success. + + def test_renew_and_deploy_hook_aliases_accept_only_exact_command(self): + command='/bin/sh /opt/dpl-node/provision/renew-node.sh' + for name in ['renew_hook','renew-hook','deploy_hook','deploy-hook']: + with self.subTest(name=name):self.check_gate(target='[renewalparams]\n'+name+' = '+command+'\n') + for second in ['renew_hook','deploy_hook']: + with self.subTest(duplicate=second),self.assertRaises(SystemExit): + self.check_gate(target='renew_hook = '+command+'\n'+second+' = '+command+'\n') + + def test_malicious_unknown_and_additional_hooks_block_before_tools(self): + command='/bin/sh /opt/dpl-node/provision/renew-node.sh' + for hook in ['renew_hook = /bin/false','renew-hook = '+command+'; touch /tmp/side-effect','deploy_hook = '+command+' extra','renew_hook = ','pre_hook = /bin/true','post-hook = /bin/true','manual_auth_hook = /bin/true','custom_hook = /bin/true','renew_hook = '+command+'\nrenew_hook = /bin/false','renew_hook = '+command+'\npost_hook = /bin/true']: + with self.subTest(hook=hook),self.assertRaises(SystemExit):self.check_gate(target=hook) + + def test_global_renew_alias_and_other_executable_hooks_block(self): + for file in ['/etc/letsencrypt/cli.ini','/root/.config/letsencrypt/cli.ini']: + for name in ['renew-hook','renew_hook','deploy_hook','pre_hook','post_hook','manual_cleanup_hook','unknown_hook']: + with self.subTest(file=file,name=name),self.assertRaises(SystemExit): + self.check_gate(global_hook=name+' = /bin/true',global_file=file) + + def test_directory_hooks_and_unowned_certificate_material_still_block(self): + for hook in ['/etc/letsencrypt/renewal-hooks/deploy/unrelated','/var/snap/certbot/common/letsencrypt/renewal-hooks/pre/unrelated']: + with self.subTest(hook=hook),self.assertRaises(SystemExit):self.check_gate(directory_hook=hook) + for kind in ['live','archive']: + with self.subTest(kind=kind),self.assertRaises(SystemExit):self.check_gate(owned=False,conflict=kind) + with self.assertRaises(SystemExit):self.check_gate(owned=False,target='authenticator = standalone') + + def test_comments_and_non_hook_options_do_not_create_false_conflicts(self): + self.check_gate(target='# renew_hook = /bin/false\n[renewalparams]\nauthenticator = standalone\nrun_deploy_hooks = True\n',global_hook='email = qa@example.invalid\n# renew-hook = /bin/false\n') + + if __name__=='__main__':unittest.main() From 4d667f37b23055dc349e1e4db427c19ff776f651 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 16:07:49 +0800 Subject: [PATCH 16/25] feat(nodes): scope final acceptance to reviewed change impact --- docs/maintenance/NODE_BATCH.md | 2 +- docs/maintenance/NODE_BATCH_RUNTIME.md | 26 ++++++- docs/maintenance/NODE_RUNBOOK.md | 4 +- docs/maintenance/STATUS.md | 4 ++ scripts/node-onboarding/acceptance.py | 52 +++++++++++--- scripts/node-onboarding/configure-site.py | 2 +- scripts/node-onboarding/site_adapter.py | 17 +++-- scripts/node-onboarding/verify-node.py | 23 +++++- tests/node-onboarding/test_runtime.py | 87 ++++++++++++++++++++++- 9 files changed, 191 insertions(+), 26 deletions(-) diff --git a/docs/maintenance/NODE_BATCH.md b/docs/maintenance/NODE_BATCH.md index bad216c..da44321 100644 --- a/docs/maintenance/NODE_BATCH.md +++ b/docs/maintenance/NODE_BATCH.md @@ -134,7 +134,7 @@ PID/启动时间漂移应先停止写入,不直接刷新基线。读取服务 1. 主协调者在生产写入前启动/复用一个核查验收助手,给出当前任务范围、必要资料入口、受保护资源、分阶段输出和完成标准。当前批次已存在对应子助手时继续使用,不在每个节点或每次续跑时重新创建。 2. 助手分阶段核查资源与审查方案;主协调者并行准备参数与调度。助手完成冲突核查和相关隔离测试/审查后,主协调者解决影响写入的缺口,固定同一参数单,再运行全批 preflight。检查发现变更时重新核对受影响项,不把旧回执沿用到不同参数。 3. 独立节点由脚本按 `--jobs` 并发,面板/DNS/中转按共享锁执行。正式开放前,核查验收助手核对本节点隔离 QA、严格 TLS、实际出口与上报证据;主协调者确认后执行 publish。助手不得另起同批调度、重复 stage、共用浏览器会话切换账户或绕开账本。 -4. 正式开放后,核查验收助手检查正式订阅、QA 禁用/拒绝、旧资源基线并报告通过或具体缺口。主协调者核实后逐节点报告完成、等待或需用户操作;批次全部完成必须有对应实际验收,不能以子助手结束作为完成证明。 +4. 正式开放后,核查验收助手按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)检查完整正式订阅、实际权限、QA 禁用/拒绝、旧资源基线及分层联网结果,并报告通过或具体缺口。轻量TLS不能写成出口/流量验收通过;影响不明或共享变化回到全量。主协调者核实后逐节点报告完成、等待或需用户操作;批次全部完成必须有对应实际验收,不能以子助手结束作为完成证明。 5. 子助手不可用、并发名额不足或失败时,主协调者明确报告,复用/恢复角色或等待名额;不自动追加更多助手、跳过必需角色或把未知写成通过。暂停和停止指令必须传达给子助手,并停止派发后续写入;已发出的远端操作仍按断点恢复规则回读。 主协调者与一个子助手分别消耗处理其输入和输出所需的 token,总量不是固定倍数。主协调者默认仅传必要背景、资料路径、节点参数与任务边界,避免复制整段历史;每项检查明确归属,汇报用结论、证据位置与缺口,不反复粘贴完整日志。独立验收保留必要的交叉核对,不为节省 token 省略验收。没有固定 token 配额或价格承诺。 diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 6913cc3..0357230 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -96,12 +96,36 @@ SSH会话仅在本次进程、同一工作线程内复用,线程不共用可 准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 -新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验,并逐条实际联网。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。旧节点同样必须覆盖。 +新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。所有新旧节点均覆盖结构、权限和必要连通检查,完整联网测试范围按下节决定。 每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 正式验收成功或失败都禁用本任务新建的正式测试用户;既有账号不被禁用或改密码/权限。进程正常退出在finally收尾,即使等待或部分前检失败也尝试清理;遇到断网/SIGKILL无法保证立即清理,记录的归属、1小时有效期与额度是兜底,恢复后先核对旧journal。已关闭用户绝不重新启用;下一次需要用户时创建新受限租约,保留历史并在最终审计核对其403/下发排除。完成要求由服务端实际集合/订阅拒绝证明,不承诺立即踢断已经建立的客户端会话。 +## 按影响范围验收(2026-10-20) + +后续新计划分两阶段,避免把隔离测试与最终验收混成重复的全量联网: + +1. 每个新节点在隔离QA阶段仍完整测试:完整订阅、严格TLS/SNI、独立预期出口/国家、实际在线及双向上报;独立复核通过后才append开放。 +2. 全部开放后,每组获取一次正式账号完整订阅,对所有节点核对集合、端点、SNI、实际密码、严格TLS设置,并检查服务端实际下发权限、QA禁用/403/排除。未受影响节点只进行经中转的TCP+严格TLS握手,不重复启动客户端查询出口。此检查不证明正式账号实际鉴权或流量上报,不得标注为完整联网通过。 +3. 受影响节点重新完整联网;共享网关/订阅生成、DNS/路由规则、中转公共配置等发生变化时,全组完整联网。影响未知、没有明确评审,或使用本任务新建正式测试账号时,保守全组完整联网(新账号需验证真实认证和用户下发传播)。旧资源、套餐/用户关联、应用版本及网关容器基线保护仍执行,异常停止,不能借轻量模式放行不符基线的变化。 + +站点JSON新增可选`acceptance_impact`,在冻结新计划前按实际变更评审填写;这不是生产操作授权: + +```json +"acceptance_impact": { + "shared_changes": [], + "affected_snis": [], + "review": "task/impact-review.json 中的变更清单、基线对比及评审结论" +} +``` + +两个空列表仅表示已核实为独立append,不能用空列表代替调查。`affected_snis`列出需重测的准确SNI(不按国家或节点数量抽样);`shared_changes`任一非空项令全组完整联网。字段省略或null保持原全量策略,格式含糊/未知SNI停止。该对象随计划摘要固定;评审依据应保存在私有任务证据中,不能仅写“无影响”而无对比材料。出现新依赖变更,停止旧计划并重新评审,不修改已冻结摘要来继续。 + +`verify-node.py`的完整expectations清单仍覆盖所有AnyTLS出站;每条可增加`verification: "full" | "tls"`,省略为full。`--qa`只走full。tls仅握手、不启动sing-box;full沿用实际客户端出口/国家测试。审计绑定逐标签范围摘要、原始报告、QA证据及完整集合;不能用tls结果满足full要求,漏节点/证据过期仍失败。正式报告保存`verification_scope`,区分轻量结果与完整联网结果。缓存复核仍绑定相同范围和集合,不复用隔离账号报告冒充正式账号认证。 + +本次仅修改本地脚本与规则,不接入节点或访问线上。后续实际使用须先按Git流程生成并部署含新版`verify-node.py`的运维提交,再生成全新计划;旧冻结批次及原验收记录不改。上一批10节点完整联网结果仍按当时已执行范围保留。 + ## 续跑与原服务调查 preflight/publish/audit始终现场执行。只有先前状态为done、收到真实前检依赖摘要、请求/代码/config未变、证据未过期,才可复用stage/dns/provision/relay/verify。前驱依赖变化使后续缓存失效;running/unknown/conflict绝不跳过。旧适配器不提供这些证据时仍按原逐阶段probe逻辑执行。生产写入仍先probe、持久化意图、apply后probe,不为速度跳过归属检查。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index feaaebc..39bbaa5 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -165,7 +165,7 @@ python3 /opt/dpl-gateway/provision/verify-node.py \ | 协议/联网 | 官方客户端配置检查与完整启动结果、严格 TLS、目标 HTTPS、出口 IP 与 EXPECTED_EXIT 一致;脚本强制比对 EXPECTED_COUNTRY;仅结构核对不产生live_verified报告 | | 订阅 | 有效用户标准/短路径按本次范围检查,JSON/gzip、private/no-store、无 HIT/Age/挑战;不要在日志保存令牌 | | 上报 | readiness.php回读节点在线、last_check/last_push及QA上下行;按显式plans/group统计,缺省不选任何套餐 | -| 正式开放 | 正式有效用户订阅与完整expectations逐一对应,并逐节点验证;不把单节点QA结果扩大成全部可用 | +| 正式开放 | 正式有效用户订阅与完整expectations逐一对应,检查实际权限及QA清理;按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)决定完整联网或轻量TLS,不能把轻量结果称为出口/鉴权通过 | | QA 清理 | QA banned、其订阅拒绝、实际下发用户集合无 QA;客户端退出、临时本地秘密副本清理,服务器审计文件保留 | | 回归 | 原节点/中转、套餐字段/用户组、订阅 URL、网关容器 ID/启动时间/重启计数与 before 快照对照 | | 浏览器 | 正确反代入口,管理列表与保存结果回读;只查看后取消不算提交验收 | @@ -181,7 +181,7 @@ GUI/手机、TUN、多地区、并发及用户明确跳过的测试分别标为 ### 正式多节点验证 -在私有文件 FORMAL_EXPECTATIONS 保存每个AnyTLS出站的明确预期,完整覆盖订阅中的全部AnyTLS标签。每条结构:`tag`、`host`、整数`port`、`sni`、`uuid`、`expected_exit`、`expected_country`;uuid是该正式用户的实际密码,不能复制进普通参数单或文档。标签必须与订阅精确一致;不重命名节点。 +在私有文件 FORMAL_EXPECTATIONS 保存每个AnyTLS出站的明确预期,完整覆盖订阅中的全部AnyTLS标签。每条结构:`tag`、`host`、整数`port`、`sni`、`uuid`、`expected_exit`、`expected_country`;uuid是该正式用户的实际密码,不能复制进普通参数单或文档。标签必须与订阅精确一致;不重命名节点。 后续批量计划按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)生成每条`verification`:full为完整客户端联网,tls仅经中转严格TLS握手;省略保持full。完整清单和权限检查不缩减,新节点隔离QA仍必须full。 ```sh python3 /opt/dpl-gateway/provision/verify-node.py \ diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index dae0f27..cdc7d31 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,10 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 验收按影响范围分层(本地实现) + +新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。 + ## 2026-10-18 后续五节点接入完成 日本JP - 05/06/07(node-jp06/07/08)及美国US - 02/03(node-us03/04)已append基础节点组1;五份新QA经独立原始字节审查后开放并禁用QA21–25。动态正式授权集合本次10节点全部完整订阅、严格TLS和预期出口/国家联网通过,QA403/实际下发排除、旧节点/套餐/用户关联/订阅地址及四网关容器基线审计通过;后台节点和服务器列表显示/切换/保存值回读通过。GemDocs AI版已登记5实例/5域名及项目和服务器关联。 diff --git a/scripts/node-onboarding/acceptance.py b/scripts/node-onboarding/acceptance.py index d3aac72..037ef67 100644 --- a/scripts/node-onboarding/acceptance.py +++ b/scripts/node-onboarding/acceptance.py @@ -31,7 +31,30 @@ def snapshot(nodes): return sorted(result, key=lambda n: n['id']) -def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900): +def validate_impact(impact): + if impact is None: + return + if not isinstance(impact, dict) or set(impact) != {'shared_changes', 'affected_snis', 'review'}: + raise ValueError('Explicit reviewed impact required') + if not isinstance(impact['review'], str) or not impact['review'].strip(): + raise ValueError('Impact review reference required') + for key in ('shared_changes', 'affected_snis'): + if not isinstance(impact[key], list) or any(not isinstance(x, str) or not x.strip() for x in impact[key]) or len(set(impact[key])) != len(impact[key]): + raise ValueError('Invalid impact inventory') + + +def verification_scope(nodes, impact=None): + """Unknown/shared impact keeps full tests; reviewed append uses light TLS probes.""" + nodes = snapshot(nodes) + if impact is None: + return {n['tag']: 'full' for n in nodes} + validate_impact(impact) + if not set(impact['affected_snis']).issubset({n['sni'] for n in nodes}): + raise ValueError('Affected node absent from current authorization set') + return {n['tag']: 'full' if impact['shared_changes'] or n['sni'] in impact['affected_snis'] else 'tls' for n in nodes} + + +def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900, scope=None): now = time.time() if now is None else now before, after = snapshot(before), snapshot(after) if before != after or binding.get('snapshot_sha256') != digest(before): @@ -45,16 +68,27 @@ def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas for stamp in (binding.get('verified_at'), report.get('verified_at'), qas.get('captured_at')): if type(stamp) not in (int, float) or not 0 <= now-stamp <= ttl: raise ValueError('Evidence expired or future dated') + modes = {n['tag']: 'full' for n in before} if scope is None else scope + if set(modes) != {n['tag'] for n in before} or any(v not in ('full', 'tls') for v in modes.values()): + raise ValueError('Incomplete verification scope') + if scope is not None and binding.get('scope_sha256') != digest(modes): + raise ValueError('Verification scope binding changed') if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before): raise ValueError('Incomplete live verification') - expected = {(n['tag'], n['relay'], n['relay_port'], n['sni'], n['expected_exit'], n['country']) for n in before} - actual = set() - for n in report['results']: - if n.get('live') is not True or n.get('strict_tls') is not True: - raise ValueError('Live strict TLS required for every node') - actual.add(tuple(n.get(k) for k in ('tag', 'relay', 'relay_port', 'sni', 'exit_ip', 'country'))) - if actual != expected: - raise ValueError('Node/endpoint/exit/country mismatch') + actual = {} + for result in report['results']: + tag = result.get('tag') + if tag in actual or tag not in modes: + raise ValueError('Unexpected or duplicate verification result') + actual[tag] = result + for node in before: + result = actual.get(node['tag'], {}) + if result.get('live') is not True or result.get('strict_tls') is not True or result.get('verification', 'full') != modes[node['tag']]: + raise ValueError('Required verification mode or strict TLS missing') + if any(result.get(k) != node[k] for k in ('relay', 'relay_port', 'sni')): + raise ValueError('Node endpoint mismatch') + if modes[node['tag']] == 'full' and (result.get('exit_ip') != node['expected_exit'] or result.get('country') != node['country']): + raise ValueError('Node exit/country mismatch') if qas.get('plan_sha256') != plan_sha or set(qas.get('users', {})) != set(expected_qas): raise ValueError('QA evidence identity mismatch') for key, identity in expected_qas.items(): diff --git a/scripts/node-onboarding/configure-site.py b/scripts/node-onboarding/configure-site.py index 7561af0..7778a9f 100644 --- a/scripts/node-onboarding/configure-site.py +++ b/scripts/node-onboarding/configure-site.py @@ -11,7 +11,7 @@ from site_adapter import SiteAdapter def configure(plan,site): - allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','formal_socks_port','gateway_containers','client_sha256'} + allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'} if set(site)-allowed:raise ValueError('Unexpected site configuration fields') library=importlib.import_module('paramiko') if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment') diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 5064492..189280d 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -3,7 +3,7 @@ import base64,hashlib,json,os,shlex,threading,time from pathlib import Path from types import SimpleNamespace from ssh_pool import Pool,private_json -from acceptance import audit,digest,snapshot,service_change,provider_state +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact class SiteAdapter: def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None): @@ -17,6 +17,7 @@ class SiteAdapter: value=self.ctx.get(field,'') if not isinstance(value,str) or not value.startswith('/') or '..' in value.split('/') or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-' for c in value): raise ValueError('Explicit safe absolute site path required: '+field) + validate_impact(self.ctx.get('acceptance_impact')) config=private_json(self.ctx['ssh_config']) for candidate in plan['nodes']: if config['hosts'].get(candidate['vps_id'],{}).get('host')!=candidate['node_address']:raise ValueError('SSH target differs from frozen candidate address') @@ -162,19 +163,21 @@ print(json.dumps({'unit_sha256':h(v['FragmentPath']),'binary_sha256':h(s['binary values.append(dict(item,**self.exit_registry[item['sni']])) return snapshot(values) before=public(source) + owned=self.formal_done.get(group,{}).get('owned',source.get('owned',False)) + scope=verification_scope(before,None if owned else ctx.get('acceptance_impact')) # Do not run final verification while any candidate in this group is still isolated. cohort=[x for x in self.plan['nodes'] if x['formal_group_id']==group] wanted={(x['relay_host'],x['relay_port'],x['tls_domain']) for x in cohort} if not wanted.issubset({(x['relay'],x['relay_port'],x['sni']) for x in before}):raise RuntimeError('formal_group_not_ready') if group in self.formal_done: prior=self.formal_done[group] - return audit(prior['before'],before,prior['report'],prior['qas'],prior['binding'],digest(self.plan),prior['expected_qas']) + return audit(prior['before'],before,prior['report'],prior['qas'],prior['binding'],digest(self.plan),prior['expected_qas'],scope=scope) root=ctx['qa_root']+'/formal-group-'+str(group) execute(gateway,'install -d -m 700 '+root) expectations=[] for node in before: original=next(x for x in source['nodes'] if x['id']==node['id']) - expectations.append({'tag':node['tag'],'host':node['relay'],'port':node['relay_port'],'sni':node['sni'],'uuid':original['uuid'],'expected_exit':node['expected_exit'],'expected_country':node['country']}) + expectations.append({'tag':node['tag'],'host':node['relay'],'port':node['relay_port'],'sni':node['sni'],'uuid':original['uuid'],'expected_exit':node['expected_exit'],'expected_country':node['country'],'verification':scope[node['tag']]}) # Tokens/UUIDs never leave memory except the gateway's private task files. write(gateway,root+'/source-private.json',json.dumps({'token':source['token'],'expectations':expectations,'owned':source['owned']})) script="""import gzip,json,urllib.request,subprocess,os,time @@ -225,12 +228,12 @@ for attempt in range(3 if source['owned'] else 1): if o.channel.recv_exit_status():raise RuntimeError('qa_rejection_probe_failed') users[key]={'id':value['id'],'banned':value['banned'],'excluded':value['excluded'],'http_status':int(out.strip())} qas=json.dumps({'users':users,'captured_at':int(time.time()),'plan_sha256':digest(self.plan)},sort_keys=True).encode() - binding={'plan_sha256':digest(self.plan),'snapshot_sha256':digest(before),'report_sha256':hashlib.sha256(report).hexdigest(),'qa_rejections_sha256':hashlib.sha256(qas).hexdigest(),'verified_at':int(time.time())} - audit(before,after,report,qas,binding,digest(self.plan),identities) + binding={'plan_sha256':digest(self.plan),'snapshot_sha256':digest(before),'report_sha256':hashlib.sha256(report).hexdigest(),'qa_rejections_sha256':hashlib.sha256(qas).hexdigest(),'verified_at':int(time.time()),'scope_sha256':digest(scope)} + audit(before,after,report,qas,binding,digest(self.plan),identities,scope=scope) write(gateway,root+'/qa-rejections.json',qas);write(gateway,root+'/binding.json',json.dumps(binding));write(gateway,root+'/snapshot.json',json.dumps(before)) - self.formal_done[group]={'before':before,'report':report,'qas':qas,'binding':binding,'expected_qas':identities} + self.formal_done[group]={'before':before,'report':report,'qas':qas,'binding':binding,'expected_qas':identities,'owned':owned} # Persist only nonsecret, byte-bound results for coordinator/operator review. - self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) + self.atomic_json(self.directory/('formal-group-'+str(group)+'.json'),{'snapshot':before,'verification_scope':scope,'report':json.loads(report),'qas':json.loads(qas),'binding':binding}) def qa_evidence(self,n,raw,state): # Bind the exact remote bytes, not a re-serialized nested report. Persist the latest diff --git a/scripts/node-onboarding/verify-node.py b/scripts/node-onboarding/verify-node.py index e08a948..22871da 100644 --- a/scripts/node-onboarding/verify-node.py +++ b/scripts/node-onboarding/verify-node.py @@ -14,6 +14,7 @@ import os from pathlib import Path import shutil import socket +import ssl import subprocess import tempfile import time @@ -53,6 +54,7 @@ def validate(config, expectations, exact=True): except ValueError as exc: raise VerificationError('Invalid expected exit IP') from exc require(len(spec['expected_country']) == 2 and spec['expected_country'].isalpha() and spec['expected_country'].isupper(), 'Expected country must be two uppercase letters') + require(spec.get('verification', 'full') in ('full', 'tls'), 'Unsupported verification mode') tag = spec['tag'] require(tag in nodes and tag not in selected, 'Missing/duplicate selected node') node = nodes[tag] @@ -151,6 +153,23 @@ def run_node(config, spec, binary, port, diagnostic_dir): stop(proc) +def run_tls(spec): + """Reach the relay and validate the node certificate; no exit/traffic claim.""" + context = ssl.create_default_context() + with socket.create_connection((spec['host'], spec['port']), timeout=10) as stream: + with context.wrap_socket(stream, server_hostname=spec['sni']) as secure: + protocol = secure.version() + require(protocol in ('TLSv1.2', 'TLSv1.3'), 'Unexpected TLS protocol') + return {'tag': spec['tag'], 'strict_tls': True, 'live': True, + 'relay': spec['host'], 'relay_port': spec['port'], 'sni': spec['sni'], + 'verification': 'tls', 'node_tls': protocol} + + +def run_specs(config, specs, binary, port, folder): + return [run_tls(spec) if spec.get('verification', 'full') == 'tls' + else run_node(config, spec, binary, port, folder) for spec in specs] + + def write_report(path, report): destination = Path(path) require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0, @@ -212,6 +231,7 @@ def main(): specs = [{'tag': candidates[0]['tag'], 'host': qa.get('host'), 'port': qa.get('port'), 'sni': qa.get('sni'), 'uuid': qa.get('uuid'), 'expected_exit': args.expected_exit, 'expected_country': args.expected_country}] + require(bool(args.expectations) or all(s.get('verification', 'full') == 'full' for s in specs), 'Isolated QA requires full verification') tags = validate(config, specs, exact=bool(args.expectations)) results = [] if args.run: @@ -219,8 +239,7 @@ def main(): binary = str(Path(args.binary).resolve()) folder = Path(source).resolve().parent require(folder.stat().st_mode & 0o077 == 0, 'Live diagnostics directory must be private') - for spec in specs: - results.append(run_node(config, spec, binary, args.port, str(folder))) + results = run_specs(config, specs, binary, args.port, str(folder)) report = {'state': 'live_verified' if args.run else 'structure_validated', 'verified_at': int(time.time()) if args.run else None, 'qa_user_id': qa_user_id, 'selected_nodes': tags, 'results': results, diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index fb1c0b9..cbb2f5d 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -13,7 +13,7 @@ from unittest.mock import patch SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding' sys.path.insert(0,str(SCRIPTS)) -from acceptance import audit,digest,snapshot,service_change,provider_state +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope from ssh_pool import Pool,private_json,credentials import batch @@ -81,6 +81,71 @@ class EvidenceTests(unittest.TestCase): self.assertEqual(service_change(before,dict(before,active=False)),'conflict') self.assertEqual(service_change(before,dict(before,binary_sha256=None)),'unknown') +class ImpactAcceptanceTests(unittest.TestCase): + fixture=EvidenceTests.fixture + check=EvidenceTests.check + def scoped(self, nodes, report, qas, scope, after=None, binding_scope=None): + raw=json.dumps(report).encode();qa_raw=json.dumps(qas).encode() + binding=dict(plan_sha256='plan',snapshot_sha256=digest(snapshot(nodes)),report_sha256=hashlib.sha256(raw).hexdigest(),qa_rejections_sha256=hashlib.sha256(qa_raw).hexdigest(),verified_at=1000,scope_sha256=digest(scope if binding_scope is None else binding_scope)) + return audit(nodes,nodes if after is None else after,raw,qa_raw,binding,'plan',{'new':9},now=1100,scope=scope) + + def test_unknown_and_shared_impact_require_full_while_append_uses_tls(self): + nodes,report,qas=self.fixture(7) + review={'shared_changes':[],'affected_snis':[],'review':'task/impact-review.json'} + self.assertEqual(set(verification_scope(nodes).values()),{'full'}) + self.assertEqual(set(verification_scope(nodes,review).values()),{'tls'}) + self.assertEqual(set(verification_scope(nodes,dict(review,shared_changes=['subscription renderer'])).values()),{'full'}) + scope=verification_scope(nodes,dict(review,affected_snis=[nodes[0]['sni']])) + self.assertEqual(scope['node-1'],'full');self.assertEqual(scope['node-7'],'tls') + for bad in [{},dict(review,review=''),dict(review,affected_snis=['unknown.invalid']),dict(review,shared_changes='none')]: + with self.assertRaises(ValueError):verification_scope(nodes,bad) + + def test_light_results_cannot_claim_exit_and_do_not_skip_set_or_qa_checks(self): + nodes,report,qas=self.fixture(7) + scope={n['tag']:'tls' for n in nodes} + for result in report['results']: + result['verification']='tls';result.pop('exit_ip');result.pop('country') + self.assertEqual(self.scoped(nodes,report,qas,scope)['nodes'],7) + with self.assertRaises(ValueError):self.scoped(nodes,report,qas,scope,after=nodes[:-1]) + with self.assertRaises(ValueError):self.scoped(nodes,dict(report,results=report['results'][:-1]),qas,scope) + with self.assertRaises(ValueError):self.scoped(nodes,report,qas,scope,binding_scope={'node-1':'tls'}) + wrong=copy.deepcopy(qas);wrong['users']['new']['banned']=False + with self.assertRaises(ValueError):self.scoped(nodes,report,wrong,scope) + full=dict(scope,**{'node-1':'full'}) + with self.assertRaises(ValueError):self.scoped(nodes,report,qas,full) + with self.assertRaises(ValueError):self.check(nodes,report,qas) # Old/default audits remain full. + + def test_mixed_scope_requires_full_exit_validation_only_where_affected(self): + nodes,report,qas=self.fixture(2);scope={'node-1':'full','node-2':'tls'} + report['results'][1].update(verification='tls');report['results'][1].pop('exit_ip');report['results'][1].pop('country') + self.assertEqual(self.scoped(nodes,report,qas,scope)['nodes'],2) + report['results'][0]['exit_ip']='198.51.100.10' + with self.assertRaises(ValueError):self.scoped(nodes,report,qas,scope) + + +class ProbeDispatchTests(unittest.TestCase): + def verifier(self): + spec=importlib.util.spec_from_file_location('scope_verifier',SCRIPTS/'verify-node.py') + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + + def test_mixed_probes_dispatch_without_starting_client_for_tls_nodes(self): + verifier=self.verifier();specs=[{'tag':'old','verification':'tls'},{'tag':'affected'}] + with patch.object(verifier,'run_tls',return_value={'verification':'tls'}) as light,patch.object(verifier,'run_node',return_value={'live':True}) as full: + self.assertEqual(len(verifier.run_specs({},specs,'binary',19080,'private')),2) + light.assert_called_once_with(specs[0]);full.assert_called_once_with({},specs[1],'binary',19080,'private') + + def test_tls_probe_checks_hostname_and_emits_no_exit_claim(self): + from unittest.mock import MagicMock + verifier=self.verifier();context=MagicMock();stream=MagicMock() + context.wrap_socket.return_value.__enter__.return_value.version.return_value='TLSv1.3' + with patch.object(verifier.ssl,'create_default_context',return_value=context),patch.object(verifier.socket,'create_connection',return_value=stream): + report=verifier.run_tls({'tag':'old','host':'relay.invalid','port':1234,'sni':'node.invalid'}) + self.assertEqual(context.wrap_socket.call_args.kwargs['server_hostname'],'node.invalid') + self.assertEqual(report['verification'],'tls');self.assertNotIn('exit_ip',report);self.assertNotIn('country',report) + with patch.object(verifier.ssl,'create_default_context',return_value=context),patch.object(verifier.socket,'create_connection',side_effect=OSError('offline')): + with self.assertRaises(OSError):verifier.run_tls({'host':'relay.invalid','port':1234,'sni':'node.invalid'}) + + class TransportTests(unittest.TestCase): def test_reuse_and_every_nonwhite_host_has_jump(self): calls=[] @@ -224,18 +289,19 @@ class SiteAdapterTests(unittest.TestCase): exported=json.loads((Path(d)/'new-evidence.json').read_text());self.assertEqual(exported['report_sha256'],hashlib.sha256(raw).hexdigest());self.assertEqual(exported['panel']['d'],34) self.assertEqual(remotes['panel'].files['/backups/new/qa-report.json'],raw) - def test_formal_source_snapshot_audit_and_cached_audit_call_chain(self): + def test_formal_source_snapshot_audit_and_cached_audit_call_chain(self, impact=None, owned=False): import contextlib import io from site_adapter import SiteAdapter adapter=object.__new__(SiteAdapter) ctx=dict(gateway_vps='gateway',qa_root='/qa',ops_root='/ops',subscription_url='https://sub.example.invalid',client_binary='/client',formal_socks_port=19999,node_backup_root='/backups',batch_backup_root='/batch') + if impact is not None:ctx['acceptance_impact']=impact candidate=dict(key='new',formal_group_id=7,relay_host='relay.example.invalid',relay_port=47002,tls_domain='n2.example.invalid') adapter.plan={'context':ctx,'nodes':[candidate]};adapter.ctx=ctx adapter.directory=Path('/private/tmp/formal-chain-fixture/ledger/batch') adapter.lock=threading.RLock();adapter.resource_lock=lambda *args:contextlib.nullcontext() adapter.formal_done={};adapter.exit_registry={f'n{i}.example.invalid':dict(expected_exit=f'192.0.2.{i}',country='JP') for i in [1,2]} - source={'user_id':1,'owned':False,'token':'fixture-only','nodes':[dict(id=i,type='anytls',tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',uuid='fixture-only') for i in [1,2]]} + source={'user_id':1,'owned':owned,'token':'fixture-only','nodes':[dict(id=i,type='anytls',tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',uuid='fixture-only') for i in [1,2]]} accounts=[] def account_command(group,action,apply=False,client=None): accounts.append(action) @@ -243,6 +309,12 @@ class SiteAdapterTests(unittest.TestCase): adapter.account_command=account_command now=int(time.time()) raw=json.dumps({'state':'live_verified','verified_at':now,'results':[dict(tag='node-'+str(i),relay='relay.example.invalid',relay_port=47000+i,sni=f'n{i}.example.invalid',exit_ip=f'192.0.2.{i}',country='JP',live=True,strict_tls=True) for i in [1,2]]}).encode() + expected_scope=verification_scope([dict(x,**adapter.exit_registry[x['sni']]) for x in source['nodes']],None if owned else impact) + payload=json.loads(raw) + for result in payload['results']: + if expected_scope[result['tag']]=='tls': + result['verification']='tls';result.pop('exit_ip');result.pop('country') + raw=json.dumps(payload).encode() files={};persisted=[];programs=[] def execute(client,command,timeout=120): if command.startswith("python3 - <<'DPLFORMAL'\n"): @@ -269,6 +341,9 @@ class SiteAdapterTests(unittest.TestCase): adapter.formal(candidate,None,gateway,php,execute,lambda client,path:files.get(path),write) self.assertEqual(len(programs),1) self.assertEqual(len(persisted),1) + self.assertEqual(persisted[0]['verification_scope'],expected_scope) + private=json.loads(files['/qa/formal-group-7/source-private.json']) + self.assertEqual({n['tag']:n['verification'] for n in private['expectations']},expected_scope) self.assertEqual(len(persisted[0]['snapshot']),2,'The old node must remain in complete acceptance') self.assertTrue(all(n['type']=='anytls' for n in persisted[0]['snapshot'])) self.assertEqual(persisted[0]['qas']['users']['new']['http_status'],403) @@ -276,6 +351,12 @@ class SiteAdapterTests(unittest.TestCase): self.assertEqual(adapter.formal(candidate,None,gateway,php,execute,lambda client,path:files.get(path),write)['nodes'],2) self.assertEqual(len(programs),1,'A cached audit rechecks topology without rerunning the client') + def test_reviewed_append_formal_chain_uses_light_checks_and_preserves_all_nodes(self): + self.test_formal_source_snapshot_audit_and_cached_audit_call_chain({'shared_changes':[],'affected_snis':[],'review':'task/review'}) + + def test_owned_formal_account_forces_full_tests_even_for_reviewed_append(self): + self.test_formal_source_snapshot_audit_and_cached_audit_call_chain({'shared_changes':[],'affected_snis':[],'review':'task/review'},owned=True) + def test_preserved_service_review_binds_original_and_exact_observation(self): from site_adapter import SiteAdapter adapter=object.__new__(SiteAdapter);adapter.ctx={'preserved_service':dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)} From d331f45b0e817482c5d744123990a14ab1d07d3f Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 16:24:46 +0800 Subject: [PATCH 17/25] feat(nodes): exclude small XrayR hosts and use registered credential fields --- AGENTS.md | 3 ++ docs/maintenance/NODE_BATCH_RUNTIME.md | 8 ++- docs/maintenance/NODE_RUNBOOK.md | 1 + docs/maintenance/STATUS.md | 4 ++ scripts/node-onboarding/acceptance.py | 7 +++ scripts/node-onboarding/configure-site.py | 2 + scripts/node-onboarding/site_adapter.py | 24 ++++++++- scripts/node-onboarding/ssh_pool.py | 9 ++++ tests/node-onboarding/test_runtime.py | 62 ++++++++++++++++++++++- 9 files changed, 117 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2552251..e5c017e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,4 +18,7 @@ 这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。 +- 新增候选按[容量筛选](docs/maintenance/NODE_BATCH_RUNTIME.md#候选容量筛选):物理内存小于1GB且已安装XrayR则排除,不以停服务绕过,不追溯停用已接入节点。 +- 接入所需邮箱验证码,按用户2026-10-20长期授权,先用已登记的对应邮箱自行登录并只读取本次验证邮件;凭据仍走GemDocs统一字段入口,不改账号安全设置、不读取无关邮件。只有资料缺失、认证失败或必须人工完成的验证才请求用户协助;账号切换及网站访问边界继续适用。 + 本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。 diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 0357230..41530e6 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -14,6 +14,12 @@ | `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 | | `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 | +## 候选容量筛选 + +用户2026-10-20指定:物理总内存小于1GB且已安装XrayR的服务器,不作为DPL新节点。两条件须同时成立;已装但未运行、inactive或masked同样计算,不能临时停止/卸载旧服务来绕过。采用`/proc/meminfo`的MemTotal乘1024,1GB=1,000,000,000 bytes;不把1GiB误当门槛,不使用MemAvailable、空闲内存或swap。容量或安装证据不明时暂停核查,不按未安装放行。该规则筛选新增候选,不自动迁移、停用已接入节点。 + +预选时先核实;正式适配器每次preflight在准备正式测试账号和stage前再次执行`capacity_gate`,本地600回执绑定请求、字节数、安装状态和采集时间。小内存且已装时拒绝进入接入阶段;不能为了凑批次数忽略。满足本规则只代表未被该条件排除,仍须通过余下资源、原服务、端口与容量检查。 + ## 准备与Git部署 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 @@ -56,7 +62,7 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version` 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 -`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。默认JSON支持password/key_filename/passphrase;既有CoDocs Markdown可设`credential_format=codocs-password`,仅按原文件中唯一的“密码:”字段在内存读取,不能为了适配而复制或改写原凭据正文。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 +`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。新计划统一设`credential_format=gemdocs-resource`及准确`credential_resource=VPS-xxx`,通过GemDocs `tools/credentials.py`的`get_field`按登记格式读取password,原样交给SSH组件,不自行剥标签或trim。configure同时固定credentials.py/resources.py摘要;格式或字段异常停止,不回退全文。旧JSON/codocs-password分支仅保留旧冻结版本兼容,不用于新计划;原始凭据不改写、不复制。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index 39bbaa5..3dab026 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -74,6 +74,7 @@ esac | 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 | | 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 | | 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | +| 候选内存 | 按[容量筛选](NODE_BATCH_RUNTIME.md#候选容量筛选)核对物理MemTotal及XrayR是否已安装(不限active) | 小于1GB且已装XrayR则排除,未知先核查;不改动旧服务绕过 | | 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 | | 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 | | 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index cdc7d31..e81c44c 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,10 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 三节点新批次准备 + +新增候选门禁:MemTotal小于1,000,000,000 bytes且已安装XrayR则拒绝,在任何验收账号准备/stage前现场复查;未知不放行。新计划SSH凭据统一按GemDocs准确资源ID读取,值不trim,并固定读取模块摘要。50项Python离线回归通过。已批准sg02/03/04精确SSH指纹,三台实际总内存均8,294,055,936 bytes、XrayR已装、SG出口且24443空闲;候选筛选通过不等于接入完成。生产写入/最终验收待本批继续,本条不得作为已部署证明。 + ## 2026-10-20 验收按影响范围分层(本地实现) 新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。 diff --git a/scripts/node-onboarding/acceptance.py b/scripts/node-onboarding/acceptance.py index 037ef67..b9a907f 100644 --- a/scripts/node-onboarding/acceptance.py +++ b/scripts/node-onboarding/acceptance.py @@ -31,6 +31,13 @@ def snapshot(nodes): return sorted(result, key=lambda n: n['id']) +def candidate_eligibility(total_memory_bytes, xrayr_installed): + """Use physical MemTotal, excluding swap and currently free memory. 1 GB = 10**9 bytes.""" + if type(total_memory_bytes) is not int or total_memory_bytes <= 0 or type(xrayr_installed) is not bool: + raise ValueError('Unknown candidate memory or XrayR installation') + return not (total_memory_bytes < 1_000_000_000 and xrayr_installed) + + def validate_impact(impact): if impact is None: return diff --git a/scripts/node-onboarding/configure-site.py b/scripts/node-onboarding/configure-site.py index 7778a9f..f3e6738 100644 --- a/scripts/node-onboarding/configure-site.py +++ b/scripts/node-onboarding/configure-site.py @@ -23,6 +23,8 @@ def configure(plan,site): plan.pop('command_profile',None) plan['adapter']=[str(Path(sys.executable).resolve()),str(base/'command-adapter.py')] paths=[base/x for x in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]+[Path(site[k]) for k in ('ssh_config','exit_registry')] + if any(h.get('credential_format')=='gemdocs-resource' for h in adapter.pool.config['hosts'].values()): + paths += [Path('/Users/hellcat/Documents/GemDocs/AI版/tools')/name for name in ('credentials.py','resources.py')] plan['runtime']={'kind':'ssh-site-v1','python':str(Path(sys.executable).resolve()),'paramiko_version':library.__version__,'files':{str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}} return plan diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 189280d..19f6e4f 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -3,7 +3,7 @@ import base64,hashlib,json,os,shlex,threading,time from pathlib import Path from types import SimpleNamespace from ssh_pool import Pool,private_json -from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact,candidate_eligibility class SiteAdapter: def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None): @@ -69,6 +69,26 @@ class SiteAdapter: for spec in source['nodes']: if spec['sni'] not in self.exit_registry:raise ValueError('Missing old-node exit expectation') + def capacity_gate(self,node,execute): + script=r"""from pathlib import Path +import json,shutil,subprocess,re +text=Path('/proc/meminfo').read_text() +match=re.search(r'^MemTotal:\s+(\d+) kB$',text,re.M) +if not match:raise RuntimeError('memory_unknown') +units=[] +for unit in ['XrayR','xrayr']: + data=subprocess.check_output(['systemctl','show',unit,'-p','LoadState'],text=True).strip() + if data not in ['LoadState=loaded','LoadState=not-found','LoadState=masked','LoadState=bad-setting','LoadState=error','LoadState=merged']:raise RuntimeError('xrayr_installation_unknown') + if data!='LoadState=not-found':units.append(unit) +paths=[p for p in ['/usr/local/XrayR/XrayR','/usr/local/bin/XrayR','/usr/bin/XrayR','/etc/XrayR/config.yml'] if Path(p).exists()] +installed=bool(units or paths or shutil.which('XrayR') or shutil.which('xrayr')) +print(json.dumps({'total_memory_bytes':int(match.group(1))*1024,'xrayr_installed':installed})) +""" + observation=json.loads(execute(node,"python3 - <<'DPLCAPACITY'\n"+script+"\nDPLCAPACITY")) + if not candidate_eligibility(observation.get('total_memory_bytes'),observation.get('xrayr_installed')): + raise RuntimeError('candidate_low_memory_with_xrayr') + return observation + def certificate_gate(self,n,node,read,execute): owner=read(node,'/etc/dpl-node/batch-owner.json') owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256']) @@ -325,6 +345,8 @@ print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control' return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script) def phase_impl(panel,gateway,node): if a.phase=='preflight': + capacity=self.capacity_gate(node,execute) + self.atomic_json(self.directory/(key+'-capacity.json'),dict(capacity,request_sha256=sha,checked_at=int(time.time()))) with self.lock, self.resource_lock(self.directory.parent,ctx['panel_vps']+':panel'): execute(panel,'install -d -m 700 '+ctx['batch_backup_root']) execute(panel,'test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"') diff --git a/scripts/node-onboarding/ssh_pool.py b/scripts/node-onboarding/ssh_pool.py index 35b602a..a5e2692 100644 --- a/scripts/node-onboarding/ssh_pool.py +++ b/scripts/node-onboarding/ssh_pool.py @@ -19,6 +19,15 @@ def private_json(path): def credentials(spec): + if spec.get('credential_format') == 'gemdocs-resource': + import importlib.util,sys + root=Path('/Users/hellcat/Documents/GemDocs/AI版') + resource=spec.get('credential_resource','') + if not re.fullmatch(r'VPS-[0-9]{3}',resource):raise ValueError('Exact GemDocs VPS resource required') + if str(root/'tools') not in sys.path:sys.path.insert(0,str(root/'tools')) + module_spec=importlib.util.spec_from_file_location('gemdocs_credentials',root/'tools/credentials.py') + module=importlib.util.module_from_spec(module_spec);module_spec.loader.exec_module(module) + return {'password':module.get_field(root,resource,'password')} if not spec.get('credential_ref'):return {} if spec.get('credential_format','json')=='json':return private_json(spec['credential_ref']) if spec['credential_format']=='codocs-password': diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index cbb2f5d..c03a02a 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -13,7 +13,7 @@ from unittest.mock import patch SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding' sys.path.insert(0,str(SCRIPTS)) -from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,candidate_eligibility from ssh_pool import Pool,private_json,credentials import batch @@ -146,6 +146,55 @@ class ProbeDispatchTests(unittest.TestCase): with self.assertRaises(OSError):verifier.run_tls({'host':'relay.invalid','port':1234,'sni':'node.invalid'}) +class CapacityGateTests(unittest.TestCase): + def test_both_conditions_and_decimal_boundary(self): + self.assertFalse(candidate_eligibility(512*1024*1024,True)) + self.assertFalse(candidate_eligibility(999_999_999,True)) + self.assertTrue(candidate_eligibility(1_000_000_000,True)) + self.assertTrue(candidate_eligibility(1010876*1024,True)) # Kernel-visible RAM on nominal 1GB host. + self.assertTrue(candidate_eligibility(512*1024*1024,False)) + for amount,installed in [(None,True),(0,True),(True,True),(512000000,None),(512000000,'inactive')]: + with self.assertRaises(ValueError):candidate_eligibility(amount,installed) + + def test_gate_rejects_low_memory_or_unknown_and_has_no_write_command(self): + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter);commands=[] + def execute(node,command): + commands.append(command) + program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0];compile(program,'capacity-remote','exec') + return json.dumps({'total_memory_bytes':512*1024*1024,'xrayr_installed':True}) + with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):adapter.capacity_gate(None,execute) + self.assertEqual(len(commands),1);self.assertIn('/proc/meminfo',commands[0]);self.assertNotIn('systemctl stop',commands[0]) + with self.assertRaises(ValueError):adapter.capacity_gate(None,lambda *args:'{}') + + + def test_generated_probe_handles_inactive_masked_missing_and_unknown(self): + import types,io,contextlib + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter) + def observation(memory, states, installed_path=False): + result=[] + class FakePath: + def __init__(self,value):self.value=value + def read_text(self):return memory + def exists(self):return installed_path and self.value=='/usr/local/XrayR/XrayR' + def execute(node,command): + program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0] + output=io.StringIO() + with patch.dict(sys.modules,{'pathlib':types.SimpleNamespace(Path=FakePath),'shutil':types.SimpleNamespace(which=lambda _:None),'subprocess':types.SimpleNamespace(check_output=lambda argv,**kw:'LoadState='+states[argv[2]]+'\n')}),contextlib.redirect_stdout(output): + exec(compile(program,'actual-capacity-probe','exec'),{}) + result.append(json.loads(output.getvalue()));return output.getvalue() + adapter.capacity_gate(None,execute);return result[0] + absent={'XrayR':'not-found','xrayr':'not-found'} + self.assertFalse(observation('MemTotal: 500000 kB\n',absent)['xrayr_installed']) + for state in ['loaded','masked']: + with self.subTest(state=state),self.assertRaisesRegex(RuntimeError,'candidate_low_memory'): + observation('MemTotal: 500000 kB\n',dict(absent,XrayR=state)) + with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):observation('MemTotal: 500000 kB\n',absent,True) + self.assertTrue(observation('MemTotal: 1010876 kB\n',dict(absent,XrayR='loaded'))['xrayr_installed']) + with self.assertRaisesRegex(RuntimeError,'memory_unknown'):observation('SwapTotal: 9000000 kB\n',absent) + with self.assertRaisesRegex(RuntimeError,'xrayr_installation_unknown'):observation('MemTotal: 9000000 kB\n',dict(absent,XrayR='unexpected')) + class TransportTests(unittest.TestCase): def test_reuse_and_every_nonwhite_host_has_jump(self): calls=[] @@ -176,6 +225,17 @@ class TransportTests(unittest.TestCase): for t in threads:t.start() for t in threads:t.join(timeout=5) self.assertEqual(len(made),2);self.assertIsNot(made[0],made[1]);pool.close() + def test_gemdocs_reader_passes_exact_field_and_stops_on_error(self): + import types + secret=' fixture:with#spaces$ \n' + reader=types.SimpleNamespace(get_field=lambda root,rid,field:secret) + loader=types.SimpleNamespace(exec_module=lambda module:None) + with patch('importlib.util.spec_from_file_location',return_value=types.SimpleNamespace(loader=loader)),patch('importlib.util.module_from_spec',return_value=reader): + self.assertEqual(credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'}),{'password':secret}) + reader.get_field=lambda *args:(_ for _ in ()).throw(ValueError('missing field')) + with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'}) + with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'other-account'}) + def test_existing_codocs_credentials_are_read_without_copy_or_rewrite(self): with tempfile.TemporaryDirectory() as d: p=Path(d)/'vps.md';body='# Fixture\n密码:test-only-phrase\n';p.write_text(body);p.chmod(0o600) From 155ee103707250e4c420490280829fb71e03a74c Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 17:04:00 +0800 Subject: [PATCH 18/25] docs: record three Singapore nodes and scoped acceptance --- docs/maintenance/NODE_ONBOARDING.md | 26 +++++++++++++++++++++++--- docs/maintenance/STATUS.md | 8 +++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index 97ae70c..ff71f93 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,20 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-20 三个新加坡节点批量接入 + +新增展示新加坡 SG - 01/02/03,分别部署于node-sg02/03/04(VPS-030/031/032),数据库节点/机器ID为11/13/12。三台MemTotal均8,294,055,936 bytes,已有XrayR,符合本批新增候选门禁:总内存小于1,000,000,000 bytes且已安装XrayR才排除;未知不放行。该规则不追溯移除已有节点。 + +新服务独立AnyTLS 24443;新部署TLS名称dpl-sg02/03/04.gemcook.net按全局域名规则使用gemcook.net,由Namecheap BasicDNS新增DNS only A记录,保留旧节点域名。中转新增dpl-sg02/03/04-anytls,haiwi.lshan.eu.org:47402/47403/47404分别转发45.128.222.173/174/175:24443,收发Proxy Protocol关闭;原443规则保持。 + +三个隔离QA29/31/30逐台完整订阅联网、严格TLS/SNI、SG独立预期出口、在线及双向上报通过,一个助手独立核对面板/网关原始报告字节、精确QA身份及有效期后批准;主协调统一append至基础组1。三个QA均已禁用、订阅403且在实际下发集合中排除。正式验收动态读取既有有效账号应得13节点(美国3、日本7、新加坡3),全量订阅结构、密码、权限及逐条严格TLS通过;本批独立append已评审为无共享变化,正式阶段使用tls范围,未重复13条完整出口联网,不能把此结果写成13节点全live。旧节点关键配置、全部套餐、正式用户关联及订阅地址、四个网关容器ID/启动时间/重启计数均与原基线一致。 + +源码d331f45已提交推送,50项Python离线测试通过;独立运维64c3b087已推送并以Git bundle精确部署到面板/网关运维目录及三新节点。生产应用7e8fbf6与原工作区摘要保持原值,未发布应用代码。本批Debian11官方索引引用的安全包池返回404,改用官方HTTPS归档取得相同版本包,仍验证Debian签名索引、Packages SHA256及每包版本/架构/大小/SHA256,再离线安装固定Git依赖;不关闭签名或有效期检查、不改全局APT源、不全量升级。 + +证据入口:codex/output/node-batch-three-20261020/,冻结计划frozen.json不变;共享查重账本在node-batch-five-20261017/ledger/sg02-sg04-20261020。面板和节点备份/root/codex-backups/dpl/node-batch-three-20261020,网关验收/var/lib/dpl-sgw/node-batch-three-20261020;推广回退按各promotion-journal逐字段预览,不恢复整库。用户本次仅批准Git推送,DPL管理页面验收明确暂不做;未执行本机/手机GUI、TUN或多设备并发验收。 + +三台证书有效期均至2027-01-18 UTC(精确时分秒见final-metadata.json)。各自单条精确专用renew_hook、无全局或目录执行钩子、专用续期dry-run成功;原XrayR程序/unit/配置/443监听/PID/启动时间/重启计数保持。VPS-032额外xray也逐字段独立核对保留。任务完成器复用冻结Coordinator.phase,先全批publish再统一audit,未改原计划或跳过证据时效。GemDocs AI版已同步三实例、三域名和服务器/项目/中转关联,离线结构检查通过;人类版未自动写入。 + ## 2026-10-18 后续五节点批量接入 新增日本 JP - 05/06/07(node-jp06/07/08)和美国 US - 02/03(node-us03/04),展示编号独立于VPS、部署key和数据库ID。均以独立AnyTLS 24443、DNS only节点证书名和专用中转规则接入;只把新节点append到基础节点组1,未改旧443中转。node-us02因既有SSH信任密钥不符停止且未发送目标凭据,按用户批准备用node-us04精确指纹接入。 @@ -59,7 +73,7 @@ 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 -流量链路按部署key分列;2026-10-18正式十节点动态完整订阅逐条联网复验通过: +流量链路按部署key分列;旧十节点2026-10-18完整联网,本批新三节点2026-10-20隔离完整联网、正式13节点动态集合及严格TLS复核通过: | 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | | --- | --- | --- | --- | --- | --- | @@ -73,8 +87,11 @@ | jp08 | node-jp08 | haiwi.lshan.eu.org:47208 | 172.93.221.4:24443 | jp08.deeplink-subscribe.com | 本文2026-10-18批量接入 | | us03 | node-us03 | haiwi.lshan.eu.org:47103 | 107.175.24.202:24443 | us03.deeplink-subscribe.com | 本文2026-10-18批量接入 | | us04 | node-us04 | haiwi.lshan.eu.org:47104 | 23.94.87.178:24443 | us04.deeplink-subscribe.com | 本文2026-10-18批量接入 | +| sg02 | node-sg02 | haiwi.lshan.eu.org:47402 | 45.128.222.173:24443 | dpl-sg02.gemcook.net | 本文2026-10-20批量接入 | +| sg03 | node-sg03 | haiwi.lshan.eu.org:47403 | 45.128.222.174:24443 | dpl-sg03.gemcook.net | 本文2026-10-20批量接入 | +| sg04 | node-sg04 | haiwi.lshan.eu.org:47404 | 45.128.222.175:24443 | dpl-sg04.gemcook.net | 本文2026-10-20批量接入 | -客户端 → 对应中转入口 → 对应落地 → 目标网络。十条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 +客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 | 资源 | DPL独立资源(各次接入) | 必须保留的原有资源 | | --- | --- | --- | @@ -88,7 +105,10 @@ | node-jp08 | dpl-node,TCP 24443,机器/节点 ID 7(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 | | node-us03 | dpl-node,TCP 24443,机器/节点 ID 9(2026-10-18) | XrayR,TCP 443 | | node-us04 | dpl-node,TCP 24443,机器/节点 ID 10(2026-10-18) | XrayR,TCP 443 | -| 中转 | 十条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-sg02 | dpl-node,TCP 24443,机器/节点 ID 11(2026-10-20) | XrayR,TCP 443 | +| node-sg03 | dpl-node,TCP 24443,机器/节点 ID 13(2026-10-20) | XrayR,TCP 443 | +| node-sg04 | dpl-node,TCP 24443,机器/节点 ID 12(2026-10-20) | XrayR,TCP 443;额外xray也保留 | +| 中转 | 十三条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index e81c44c..809498d 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,13 +2,11 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 -## 2026-10-20 三节点新批次准备 +## 2026-10-20 三个新加坡节点接入完成 -新增候选门禁:MemTotal小于1,000,000,000 bytes且已安装XrayR则拒绝,在任何验收账号准备/stage前现场复查;未知不放行。新计划SSH凭据统一按GemDocs准确资源ID读取,值不trim,并固定读取模块摘要。50项Python离线回归通过。已批准sg02/03/04精确SSH指纹,三台实际总内存均8,294,055,936 bytes、XrayR已装、SG出口且24443空闲;候选筛选通过不等于接入完成。生产写入/最终验收待本批继续,本条不得作为已部署证明。 +新加坡SG - 01/02/03(node-sg02/03/04)已append基础组1;三个隔离QA完整联网、严格TLS/SG出口、在线与双向上报经一个助手独立审核,QA29/31/30已禁用、订阅403及下发排除。正式动态13节点订阅结构/密码/权限及逐条严格TLS通过,本批无共享变化采用分层tls范围,不表示13节点全部重新测出口;旧节点/套餐/用户关联/订阅地址、四网关容器、生产应用版本与工作区摘要保持。原XrayR和VPS032额外xray逐字段保留。三证书签发、专用钩子及续期演练通过。用户本次明确跳过DPL页面验收。 -## 2026-10-20 验收按影响范围分层(本地实现) - -新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。 +新增低内存且已装XrayR的候选排除门禁、GemDocs统一凭据读取及长期授权邮箱验证码处理规则已实现;50项Python离线回归通过。源码d331f45已推送,运维64c3b087以Git部署五台相关服务器运维目录。DNS采用新部署规则的gemcook.net/Namecheap,不迁移旧节点。精确证据、备份及未测范围见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-三个新加坡节点批量接入),分层策略唯一正文见[NODE_BATCH_RUNTIME](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。GemDocs已同步三实例/三域名与关联;正式工具build及check_docs离线检查通过(412文档、3134链接、367对象),该检查不替代上述现场验收。 ## 2026-10-18 后续五节点接入完成 From 852b38c25d6926c989e2b0d6c1332dd33d908ab6 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 17:11:51 +0800 Subject: [PATCH 19/25] docs: use server-side node onboarding without DPL page acceptance --- AGENTS.md | 2 +- docs/maintenance/NODE_BATCH_RUNTIME.md | 6 ++++++ docs/maintenance/NODE_RUNBOOK.md | 2 +- docs/maintenance/README.md | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5c017e..dc3aa28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - 修改功能时同步相应维护说明与验收状态。原有业务约束见 docs/maintenance/BUSINESS_RULES.md。 - 文档放 docs/maintenance/;任务截图、日志与临时脚本放 codex/output/;密钥和私密配置不提交 Git。 - 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。 -- 后台变更实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。 +- 后台界面功能变更仍实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。节点接入按用户2026-10-20长期要求,不再默认登录DPL或进行DPL页面验收;经白色机使用服务器侧正式脚本、应用模型/事务操作并回读线上数据,仍须完成订阅、联网/上报、权限、QA清理和旧资源回归。执行范围唯一入口见[服务器侧接入](docs/maintenance/NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。 ## 批量节点接入:默认启动一个子助手 diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 41530e6..72b7e0d 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -4,6 +4,12 @@ `batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、独立助手审核、Git运维部署及现场验收。 +## 服务器侧接入与验收 + +用户2026-10-20长期指定:新增节点不再默认登录DPL管理页面或进行页面验收。通过白色机SSH在面板应用根目录运行正式stage/promote/readiness及验收脚本,使用应用模型、业务服务、事务和操作journal读写线上数据;保留写前基线、参数/身份校验、幂等恢复和精确回退,不临时绕过业务逻辑写裸SQL。此前批次的节点记录本就由这些服务器侧脚本写入,浏览器仅承担额外回读。 + +完成标准仍包括新增资源参数回读、隔离完整订阅联网/严格TLS/预期出口和双向上报、append正式组、动态正式订阅按影响范围验收、QA禁用403/下发排除、旧节点/套餐/用户关联/订阅设置/网关容器及原服务保护。DPL页面验收不再列为节点接入未完成项;未做的界面/手机/TUN测试仍按事实注明。此规则不覆盖真正的后台界面功能修改,不授权本机网站访问或第三方后台写入越界。 + ## 文件与职责 | 文件 | 职责 | diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index 3dab026..aa9836b 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -169,7 +169,7 @@ python3 /opt/dpl-gateway/provision/verify-node.py \ | 正式开放 | 正式有效用户订阅与完整expectations逐一对应,检查实际权限及QA清理;按[影响范围](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)决定完整联网或轻量TLS,不能把轻量结果称为出口/鉴权通过 | | QA 清理 | QA banned、其订阅拒绝、实际下发用户集合无 QA;客户端退出、临时本地秘密副本清理,服务器审计文件保留 | | 回归 | 原节点/中转、套餐字段/用户组、订阅 URL、网关容器 ID/启动时间/重启计数与 before 快照对照 | -| 浏览器 | 正确反代入口,管理列表与保存结果回读;只查看后取消不算提交验收 | +| DPL数据回读 | 用户2026-10-20长期指定无需登录DPL或页面验收;经白色机正式脚本/应用模型回读节点、机器、组及保存值,并绑定实际链路证据。边界见[服务器侧接入](NODE_BATCH_RUNTIME.md#服务器侧接入与验收) | 面板回读命令入口如下;它只覆盖该脚本实际返回的节点/QA 字段,其他行须另留证据: diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md index 47f23c8..db7adae 100644 --- a/docs/maintenance/README.md +++ b/docs/maintenance/README.md @@ -7,7 +7,7 @@ 1. 按全局规则先读 CoDocs 项目总览、首次会话的持续维护协议与 dpl 档案,再读项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。 2. 确认所在仓库、分支、工作区差异与远端;分别识别用户既有修改、本次任务与其他聊天修改。不要覆盖、清理或混入其他任务。 3. 按 [架构与配置](ARCHITECTURE.md) 沿实际调用链检查问题;按 [业务约束](BUSINESS_RULES.md) 判断影响。 -4. 修改后运行与影响范围相关的检查;后台界面必须浏览器验收,包括页面显示、保存、切换、权限。HTTP 200 不代表前端可用。 +4. 修改后运行与影响范围相关的检查;后台界面功能修改必须浏览器验收,包括页面显示、保存、切换、权限。节点接入按[服务器侧接入](NODE_BATCH_RUNTIME.md#服务器侧接入与验收)完成数据与实际链路验收,用户2026-10-20长期指定无需登录DPL或页面验收。HTTP 200 不代表前端可用。 5. 本地实现、提交、推送、部署、真实联调分别报告。发布按 [发布与恢复](DEPLOYMENT.md) 执行。 6. 完成后同步本目录说明与 [验收状态](STATUS.md)。记录事实、证据、限制与下一步,不存密钥或用户原始邮件。 From 9d153c7409b2c8773b525a314b1cf1b5d0990c88 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 17:29:00 +0800 Subject: [PATCH 20/25] docs: prefer server-side operations throughout node maintenance --- AGENTS.md | 2 ++ docs/maintenance/NODE_BATCH_RUNTIME.md | 2 ++ docs/maintenance/NODE_RUNBOOK.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dc3aa28..b3face2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ - 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。 - 后台界面功能变更仍实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。节点接入按用户2026-10-20长期要求,不再默认登录DPL或进行DPL页面验收;经白色机使用服务器侧正式脚本、应用模型/事务操作并回读线上数据,仍须完成订阅、联网/上报、权限、QA清理和旧资源回归。执行范围唯一入口见[服务器侧接入](docs/maintenance/NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。 +- 节点维护全流程(候选盘点、查重、DNS/中转配置、部署、验收和回退)优先在服务器侧完成,默认不打开线上网站;优先使用经白色机的SSH、已登记凭据及服务接口。第三方缺少可用接口或凭据且确需网页时,先说明具体缺口,仅按现有访问规则处理必要范围;不为补页面验收打开网站,不绕过强制验证。 具体流程见[服务器侧接入](docs/maintenance/NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。 + ## 批量节点接入:默认启动一个子助手 后续新批次默认使用 [正式适配器入口](docs/maintenance/NODE_BATCH_RUNTIME.md),由 `configure-site.py` 固定计划,再由 `batch.py` 的 `ssh-site-v1` 运行;动态读取验收账号应得节点集合,不固定节点数量。没有有效验收账号时自动创建本任务受限测试用户并负责禁用收尾;SSH始终经已登记白色机。具体实现、证据续跑和原服务调查边界见该专题;旧冻结计划及其处理脚本不改摘要迁就新实现。仅维护脚本/文档不触发真实接入或线上创建测试用户。 diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 72b7e0d..65495d9 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -6,6 +6,8 @@ ## 服务器侧接入与验收 +用户2026-10-20进一步指定:节点维护全流程(候选盘点、查重、DNS/中转配置、部署、验收和回退)优先在服务器侧完成,默认不打开线上网站;优先使用经白色机的SSH、已登记凭据及服务接口。第三方缺少可用接口或凭据且确需网页时,先说明具体缺口,仅按现有访问规则处理必要范围;不为补页面验收打开网站,不绕过强制验证。 线上业务操作、凭据读取、首次SSH信任和账号切换边界仍按全局规则执行。 + 用户2026-10-20长期指定:新增节点不再默认登录DPL管理页面或进行页面验收。通过白色机SSH在面板应用根目录运行正式stage/promote/readiness及验收脚本,使用应用模型、业务服务、事务和操作journal读写线上数据;保留写前基线、参数/身份校验、幂等恢复和精确回退,不临时绕过业务逻辑写裸SQL。此前批次的节点记录本就由这些服务器侧脚本写入,浏览器仅承担额外回读。 完成标准仍包括新增资源参数回读、隔离完整订阅联网/严格TLS/预期出口和双向上报、append正式组、动态正式订阅按影响范围验收、QA禁用403/下发排除、旧节点/套餐/用户关联/订阅设置/网关容器及原服务保护。DPL页面验收不再列为节点接入未完成项;未做的界面/手机/TUN测试仍按事实注明。此规则不覆盖真正的后台界面功能修改,不授权本机网站访问或第三方后台写入越界。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index aa9836b..a132112 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -2,6 +2,8 @@ 入口:[节点关系与历史验收](NODE_ONBOARDING.md)。本手册定义后续授权任务的步骤。2026-10-16 已补齐本地脚本草稿;用户要求测试交给奶油,本轮未运行脚本、语法检查或功能测试,未触及线上。**新脚本必须先在隔离环境测试,再按Git流程审阅发布;本地存在不代表线上已更新。**已有节点展示命名按原入口的[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号),此处不另建规则。 +节点维护默认全流程采用[服务器侧方式](NODE_BATCH_RUNTIME.md#服务器侧接入与验收),不打开线上网站;包括DNS和中转也先检查可用服务器接口,网页仅用于确实无法服务器侧处理的必要例外。 + ## 先选择变更模式 | 模式 | 适用情形 | 允许修改的范围 | From 4397e1958b2a7d2392df79e0d44817eebc9b9747 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 17:46:29 +0800 Subject: [PATCH 21/25] docs: record sg05 onboarding and server-side provider workflow --- docs/maintenance/NODE_BATCH_RUNTIME.md | 14 +++++++++++--- docs/maintenance/NODE_ONBOARDING.md | 18 ++++++++++++++++-- docs/maintenance/STATUS.md | 6 ++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 65495d9..40f7b3a 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -2,7 +2,7 @@ 本专题是后续批量接入的默认技术入口。适配器实现与隔离测试于2026-10-17完成,2026-10-18首次正式批次线上验收完成;部署、动态全量验收和例外范围见[本次节点记录](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。此前三节点批次使用冻结旧任务适配器,与本实现的生产证据分开。 -`batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、独立助手审核、Git运维部署及现场验收。 +`batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、Git运维部署及现场验收;批量按AGENTS启动独立核查助手,单节点按执行手册由操作者完成精确证据复核,不自动启动子助手,也不得把操作者审核写成独立助手审核。 ## 服务器侧接入与验收 @@ -12,6 +12,14 @@ 完成标准仍包括新增资源参数回读、隔离完整订阅联网/严格TLS/预期出口和双向上报、append正式组、动态正式订阅按影响范围验收、QA禁用403/下发排除、旧节点/套餐/用户关联/订阅设置/网关容器及原服务保护。DPL页面验收不再列为节点接入未完成项;未做的界面/手机/TUN测试仍按事实注明。此规则不覆盖真正的后台界面功能修改,不授权本机网站访问或第三方后台写入越界。 +### DNS与中转的服务器侧路径 + +2026-10-20单节点任务首次实际使用白色机访问中转站自身接口(不是新建平台权限):正常登录`hw.lshan.eu.org/api/v1/user/login`,凭据从`ACC-RELAY-HW`统一读取,经私密stdin传给白色机认证组件;先按当前站点登录合同检查验证码要求,强制验证则停止求助。令牌只保留在该进程内存,不导出浏览器会话。 + +转发先`POST /api/v1/forward/list`核对全表,对名称、入口端口和目标查重;仅当均无冲突才`POST /api/v1/forward/create`新增独立规则,随后重新list回读平台ID、隧道、地址、状态及Proxy Protocol两端配置,并对照写前快照确认旧规则所核字段保持。存在完全匹配项只回读,结果未知先probe,不盲重发create。当前合同来自站点本身前端,未宣称为稳定公开OpenAPI;后续须按当时站点合同核实,不能猜接口或跳过认证/验证码。 + +gemcook.net为Namecheap BasicDNS,`ACC-002`目前只有已登记网页登录密码、无DNS API密钥。不得为避免网页自动开通新API权限或借用其他账号。本次新增A记录因此仅此步骤使用必要网页并回读旧记录;部署与验收继续服务器侧。后续有可用且获授权的DNS接口凭据时改走服务器侧。DNS运行值可从白色机核对当前NS和权威A应答;不能把超时、不完整或非权威应答记为通过。 + ## 文件与职责 | 文件 | 职责 | @@ -92,7 +100,7 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version` } ``` -`captured_at`填实际采集Unix时间。观察文件可以刷新,不能把静态勾选当现场证据。资源存在但参数/归属不符停止;尚未创建的DNS/中转仍按注册后台批量操作、更新观察后续跑。本实现没有新增Cloudflare/中转官方API写入权限,也不默认直连其源站。 +`captured_at`填实际采集Unix时间。观察文件可以刷新,不能把静态勾选当现场证据。DNS写入先按真实后台或获授权接口回读;后续只读续跑可由白色机查询当前NS并核对两台权威服务器的AA应答,刷新DNS运行观察,不能用普通递归缓存或只改时间戳代替。中转可以使用上述正常服务接口的实际list回读。资源存在但参数/归属不符停止;尚未创建的DNS/中转按服务器侧优先选择已获授权的正常接口,或确实必要的登记后台,保存后更新观察再续跑。调度器不自动创建平台API权限或扩大本次业务授权;本机访问路线仍按全局规则,不默认直连源站。 ## 执行与等待 @@ -110,7 +118,7 @@ SSH会话仅在本次进程、同一工作线程内复用,线程不共用可 准备阶段先按正式组、banned、额度和有效期选既有账号,优先管理员且最多检查20个候选,避免全表巡检客户凭据;检查真实`ServerService::getAvailableServers`及`getAvailableUsers`,包括插件过滤;候选被实际节点下发过滤时不算可用。未找到可用账号时自动创建本任务测试用户:非管理员、100MiB、1小时、1设备、无订单/套餐改写及通知消息。私有journal在DB提交前记录身份,丢失回包时恢复同一用户,不重复创建或接管同名用户。 -新节点仍沿用各自隔离QA→严格TLS/出口/上报→独立助手按精确报告审核→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。助手完成独立审核后,主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。所有新旧节点均覆盖结构、权限和必要连通检查,完整联网测试范围按下节决定。 +新节点仍沿用各自隔离QA→严格TLS/出口/上报→按任务规模复核精确报告(批量独立助手、单节点操作者)→append开放并禁用QA。适配器直接生成`key-evidence.json`,包含原始报告字节SHA256、请求摘要、QA身份、报告时间及最新在线/双向上报状态,600原子保存,不再需要任务专用采集脚本。完成上述对应角色审核后,操作者或主协调者在同目录记录`key-reviewed.json`:`request_sha256`、`report_sha256`、`qa_user_id`、`verified_at`必须与该报告完全相同;未审核不得自动填批准文件。全部本组候选开放后,正式验收按账号**当前实际应获得的集合**生成完整快照,与客户端完整订阅逐条校验。集合绑定节点ID、标签、端点、SNI、独立预期出口/国家;验收后再取一次集合,增加、删除或改变任一项均阻止旧报告通过,不固定节点数。所有新旧节点均覆盖结构、权限和必要连通检查,完整联网测试范围按下节决定。 每组正式客户端串行运行,保留完整订阅DNS/路由,只给诊断目的地固定出站。新建测试用户可能等待节点拉取用户列表;只对通用客户端运行失败最多做3轮诊断读测试,间隔15秒且共用900秒预算。结构、TLS或出口断言错误不重试;不重放生产写入、不另建用户来绕过失败。证据绑定完整计划摘要、节点集合摘要、原始报告及QA拒绝文件SHA256、QA实际ID、403、banned、实际下发排除及900秒时效。任一缺项或摘要/集合变化失败。旧节点关键配置、全部套餐、原用户关联和订阅地址按批次原始基线核对,网关容器ID/启动时间/重启数再次核对。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index ff71f93..6c1ffdb 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,18 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-20 单节点服务器侧接入 sg05 + +新增展示新加坡 SG - 04,部署key/VPS为sg05/node-sg05(VPS-033),节点/机器ID为14/14,基础组1。现场MemTotal 8,294,055,936 bytes且已安装XrayR,容量门禁通过;Debian11/x86_64、SG出口45.128.222.176,独立AnyTLS 24443。SNI dpl-sg05.gemcook.net,中转规则5168(dpl-sg05-anytls)为haiwi.lshan.eu.org:47405 ->45.128.222.176:24443,Proxy Protocol收发关闭。 + +本次DPL数据经白色机SSH运行正式stage/promote及应用模型/事务写入,未登录DPL或进行线上页面验收。中转首次改为白色机正常登录站点自身接口、list查重、create新增再逐字段回读,原36条所核配置保持,总数37;无中转网页操作,认证令牌仅进程内存。Namecheap缺少已登记DNS API凭据,仅DNS新增使用必要网页,新增后旧记录逐项保持;再由白色机核对当前NS及两台权威A应答。未自动开通API权限。 + +隔离QA32完整订阅联网、严格TLS/SNI、独立预期SG出口、在线和双向上报通过;单节点由操作者按原始字节SHA256、请求、精确身份及900秒时效复核,无独立子助手。正式append后QA已禁用、订阅403并从实际下发用户排除。正式有效既有账号动态应得14节点,全量订阅结构、密码、权限及逐条严格TLS通过;本批无共享配置变化采用tls范围,没有重复14条完整出口联网。旧节点关键参数、套餐、正式用户关联、订阅地址和四个网关容器ID/启动时间/重启计数符合原基线。 + +原XrayR程序/unit/配置/443监听、PID/启动时间/重启数及旧证书/私钥/renewal摘要保持。新证书有效至Jan 18 08:39:40 2027 GMT,单个精确专用renew_hook、无全局/目录执行钩子,续期dry-run通过。缺少Git时用独立APT索引、Debian签名与精确包摘要校验安装9项固定依赖,未改全局源或升级旧XrayR。首次前检因观察文件权限不符停止,收紧600后现场probe通过,未绕过保护。 + +复用已推送运维64c3b087部署到新节点,面板/网关沿用同版本;生产应用7e8fbf6及原工作区摘要保持。服务器侧优先规则852b38c、9d153c7已推送,接口合同和DNS例外唯一正文见[NODE_BATCH_RUNTIME](NODE_BATCH_RUNTIME.md#服务器侧接入与验收)。证据:codex/output/node-single-sg05-20261020/;冻结计划和共享账本node-batch-five-20261017/ledger/sg05-20261020不改旧批次。面板/节点备份/root/codex-backups/dpl/node-single-sg05-20261020,网关/var/lib/dpl-sgw/node-single-sg05-20261020;回退沿promotion-journal逐字段预览,不恢复整库。未做页面、手机客户端/TUN、多设备并发或生产回退演练。GemDocs AI版已同步本实例/TLS域名/中转规则5168与项目、服务器、父域关系,离线结构检查通过;人类版未自动写入。 + ## 2026-10-20 三个新加坡节点批量接入 新增展示新加坡 SG - 01/02/03,分别部署于node-sg02/03/04(VPS-030/031/032),数据库节点/机器ID为11/13/12。三台MemTotal均8,294,055,936 bytes,已有XrayR,符合本批新增候选门禁:总内存小于1,000,000,000 bytes且已安装XrayR才排除;未知不放行。该规则不追溯移除已有节点。 @@ -73,7 +85,7 @@ 订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。 -流量链路按部署key分列;旧十节点2026-10-18完整联网,本批新三节点2026-10-20隔离完整联网、正式13节点动态集合及严格TLS复核通过: +流量链路按部署key分列;旧十节点2026-10-18完整联网,本日三节点批次及sg05单节点均隔离完整联网;sg05追加后正式14节点动态集合及严格TLS复核通过: | 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 | | --- | --- | --- | --- | --- | --- | @@ -90,6 +102,7 @@ | sg02 | node-sg02 | haiwi.lshan.eu.org:47402 | 45.128.222.173:24443 | dpl-sg02.gemcook.net | 本文2026-10-20批量接入 | | sg03 | node-sg03 | haiwi.lshan.eu.org:47403 | 45.128.222.174:24443 | dpl-sg03.gemcook.net | 本文2026-10-20批量接入 | | sg04 | node-sg04 | haiwi.lshan.eu.org:47404 | 45.128.222.175:24443 | dpl-sg04.gemcook.net | 本文2026-10-20批量接入 | +| sg05 | node-sg05 | haiwi.lshan.eu.org:47405 | 45.128.222.176:24443 | dpl-sg05.gemcook.net | 本文2026-10-20单节点接入 | 客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 @@ -108,7 +121,8 @@ | node-sg02 | dpl-node,TCP 24443,机器/节点 ID 11(2026-10-20) | XrayR,TCP 443 | | node-sg03 | dpl-node,TCP 24443,机器/节点 ID 13(2026-10-20) | XrayR,TCP 443 | | node-sg04 | dpl-node,TCP 24443,机器/节点 ID 12(2026-10-20) | XrayR,TCP 443;额外xray也保留 | -| 中转 | 十三条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-sg05 | dpl-node,TCP 24443,机器/节点 ID 14(2026-10-20) | XrayR,TCP 443;旧证书保持 | +| 中转 | 14条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 809498d..c30c76d 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,12 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 sg05单节点接入与服务器侧优先 + +新加坡SG - 04(node-sg05,VPS033)已加入基础组1,节点/机器14,QA32完整订阅严格TLS/SG出口/双向上报通过,单节点操作者精确审核后开放,QA禁用403/下发排除。动态正式14节点结构/密码/权限/逐条严格TLS及旧资源基线通过,不代表14节点重新测出口。原XrayR及原证书保持,新证书专用续期演练通过。 + +节点维护全流程优先服务器侧规则已写入并推送;本次DPL写入、中转接口创建/查重/回读、部署及验收均服务器侧,只有无DNS API凭据的Namecheap新增使用必要网页,再以权威NS复核。已完成服务器侧验收,未做线上页面验收;不将其列为未完成项。具体证据与恢复见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-单节点服务器侧接入-sg05)。GemDocs已同步实例、域名、中转规则5168及关联;离线检查通过(415文档、3171链接、370对象),不替代上述现场验收。 + ## 2026-10-20 三个新加坡节点接入完成 新加坡SG - 01/02/03(node-sg02/03/04)已append基础组1;三个隔离QA完整联网、严格TLS/SG出口、在线与双向上报经一个助手独立审核,QA29/31/30已禁用、订阅403及下发排除。正式动态13节点订阅结构/密码/权限及逐条严格TLS通过,本批无共享变化采用分层tls范围,不表示13节点全部重新测出口;旧节点/套餐/用户关联/订阅地址、四网关容器、生产应用版本与工作区摘要保持。原XrayR和VPS032额外xray逐字段保留。三证书签发、专用钩子及续期演练通过。用户本次明确跳过DPL页面验收。 From 4661c997062b159cae8f56c2846b0d3329af3465 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 18:19:55 +0800 Subject: [PATCH 22/25] docs: record three Hong Kong nodes and server-side acceptance --- docs/maintenance/NODE_ONBOARDING.md | 24 +++++++++++++++++++++++- docs/maintenance/STATUS.md | 6 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index 6c1ffdb..0fef991 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,22 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-20 三个香港节点服务器侧接入 + +| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 | +| --- | --- | --- | --- | --- | --- | --- | +| 🇭🇰 香港 HK - 01 | node-hk04 | VPS-026 | 17/17 | 35 | 47304 | 45.128.220.145 | +| 🇭🇰 香港 HK - 02 | node-hk05 | VPS-027 | 16/16 | 34 | 47305 | 45.128.220.146 | +| 🇭🇰 香港 HK - 03 | node-hk06 | VPS-028 | 15/15 | 33 | 47306 | 85.113.71.89 | + +三机均通过物理内存/XrayR容量门禁,hk04为Debian12,hk05/hk06为Debian11。经白色机以固定Git运维64c3b087部署,独立AnyTLS监听24443,TLS使用dpl-hk04/hk05/hk06.gemcook.net;中转规则5169/5170/5171对应47304/47305/47306,Proxy Protocol收发关闭。服务器侧正常接口查重、新增和精确回读,旧37条所核字段保持,总数40;原443转发不改。Namecheap无已登记DNS API凭据,只有DNS新增使用必要网页,保存后旧记录对比保持,两台权威NS验证新A解析。 + +三节点分别完成隔离有效订阅、严格TLS/SNI、独立预期HK出口、在线及双向流量上报;一个子助手核对原始字节摘要、请求和QA身份及时效后才开放到基础组1。QA随后禁用,实际订阅403并从下发用户排除。正式账号动态应得17节点,全量订阅结构/密码/权限和逐条严格TLS通过;本批无共享配置变化,正式范围为tls,没有重新测试17条客户端出口。原节点/套餐/正式用户关联/订阅配置及四个网关容器保持;生产应用版本及原工作区摘要保持。三机原XrayR程序/unit/配置/443进程及旧renewal配置经独立终审保持,新证书专用续期dry-run通过。 + +本次有效登记node服务器24台,接入由14增至17,剩7台未接:us02、hk01、hk02、hk03、hk07、vn01、ru01。此为登记差集,不代表7台均符合接入条件;us02有历史SSH密钥冲突,hk02/ru01登记512MB但尚未现场确认XrayR。已退役sg01不计入候选,vn01按用户登记地区不凭名称改类。 + +证据:codex/output/node-batch-hk04-hk06-20261020/;冻结计划/共享账本node-batch-five-20261017/ledger/hk04-hk06-20261020。服务器备份/root/codex-backups/dpl/node-batch-hk04-hk06-20261020,网关验收/var/lib/dpl-sgw/node-batch-hk04-hk06-20261020;回退沿各promotion-journal逐字段预览,不恢复整库。已完成服务器侧验收,未做线上页面验收;手机客户端/TUN、多设备并发及生产回退演练未做。GemDocs实例/域名/中转规则及关联已同步,离线检查通过(425文档、3336链接、380对象);不自动更新人类版。 + ## 2026-10-20 单节点服务器侧接入 sg05 新增展示新加坡 SG - 04,部署key/VPS为sg05/node-sg05(VPS-033),节点/机器ID为14/14,基础组1。现场MemTotal 8,294,055,936 bytes且已安装XrayR,容量门禁通过;Debian11/x86_64、SG出口45.128.222.176,独立AnyTLS 24443。SNI dpl-sg05.gemcook.net,中转规则5168(dpl-sg05-anytls)为haiwi.lshan.eu.org:47405 ->45.128.222.176:24443,Proxy Protocol收发关闭。 @@ -103,6 +119,9 @@ | sg03 | node-sg03 | haiwi.lshan.eu.org:47403 | 45.128.222.174:24443 | dpl-sg03.gemcook.net | 本文2026-10-20批量接入 | | sg04 | node-sg04 | haiwi.lshan.eu.org:47404 | 45.128.222.175:24443 | dpl-sg04.gemcook.net | 本文2026-10-20批量接入 | | sg05 | node-sg05 | haiwi.lshan.eu.org:47405 | 45.128.222.176:24443 | dpl-sg05.gemcook.net | 本文2026-10-20单节点接入 | +| hk04 | node-hk04 | haiwi.lshan.eu.org:47304 | 45.128.220.145:24443 | dpl-hk04.gemcook.net | 本文2026-10-20香港批次 | +| hk05 | node-hk05 | haiwi.lshan.eu.org:47305 | 45.128.220.146:24443 | dpl-hk05.gemcook.net | 本文2026-10-20香港批次 | +| hk06 | node-hk06 | haiwi.lshan.eu.org:47306 | 85.113.71.89:24443 | dpl-hk06.gemcook.net | 本文2026-10-20香港批次 | 客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 @@ -122,7 +141,10 @@ | node-sg03 | dpl-node,TCP 24443,机器/节点 ID 13(2026-10-20) | XrayR,TCP 443 | | node-sg04 | dpl-node,TCP 24443,机器/节点 ID 12(2026-10-20) | XrayR,TCP 443;额外xray也保留 | | node-sg05 | dpl-node,TCP 24443,机器/节点 ID 14(2026-10-20) | XrayR,TCP 443;旧证书保持 | -| 中转 | 14条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-hk04 | dpl-node,TCP 24443,机器/节点 ID 17/17(2026-10-20) | XrayR,TCP 443保留 | +| node-hk05 | dpl-node,TCP 24443,机器/节点 ID 16/16(2026-10-20) | XrayR,TCP 443保留 | +| node-hk06 | dpl-node,TCP 24443,机器/节点 ID 15/15(2026-10-20) | XrayR,TCP 443保留 | +| 中转 | 17条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index c30c76d..eb415f0 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,12 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 香港HK01–03接入完成 + +node-hk04/hk05/hk06展示香港HK - 01/02/03,已加入基础组1。三台隔离完整订阅、严格TLS、HK出口、双向上报及一个助手独立审核通过,QA禁用403/下发排除;正式动态17节点结构/密码/权限/逐条严格TLS通过,不代表17条重测出口。原XrayR/443进程、旧renewal配置、旧资源与四容器保持,新证书续期演练通过。 + +有效登记24台、已接17台、剩7台未接,低内存及SSH异常需后续按实际门禁核实。DPL数据、中转和部署/验收均经白色机服务器侧;只有Namecheap DNS新增使用必要网页。已完成服务器侧验收,未做线上页面验收。GemDocs同步及离线检查通过(425文档、3336链接、380对象);详情见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-三个香港节点服务器侧接入)。 + ## 2026-10-20 sg05单节点接入与服务器侧优先 新加坡SG - 04(node-sg05,VPS033)已加入基础组1,节点/机器14,QA32完整订阅严格TLS/SG出口/双向上报通过,单节点操作者精确审核后开放,QA禁用403/下发排除。动态正式14节点结构/密码/权限/逐条严格TLS及旧资源基线通过,不代表14节点重新测出口。原XrayR及原证书保持,新证书专用续期演练通过。 From 66f30449821efb2a2504228385b2a008895ed72e Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 18:33:04 +0800 Subject: [PATCH 23/25] fix: preserve actual per-host service configuration in node batches --- docs/maintenance/NODE_BATCH_RUNTIME.md | 2 ++ scripts/node-onboarding/configure-site.py | 2 +- scripts/node-onboarding/site_adapter.py | 21 ++++++++++++++++- tests/node-onboarding/test_runtime.py | 28 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 40f7b3a..9eed3d9 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -76,6 +76,8 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version` } ``` +各主机原服务路径不同且已通过实际进程参数核实时,可在site配置增加 `preserved_service_overrides`,例如 `{"VPS-023":{"config":"/usr/local/XrayR/config.yml"}}`。只允许候选主机ID,值为单独config或完整unit/binary/config/port四字段;路径必须为安全绝对路径。默认preserved_service保持不变,按当前主机合并保护规格,不搬动、复制或改写旧服务配置以适配脚本。现场依赖准备及收尾采集也必须使用同一已核实规格,不能仍硬编码默认路径。 + 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 `ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。新计划统一设`credential_format=gemdocs-resource`及准确`credential_resource=VPS-xxx`,通过GemDocs `tools/credentials.py`的`get_field`按登记格式读取password,原样交给SSH组件,不自行剥标签或trim。configure同时固定credentials.py/resources.py摘要;格式或字段异常停止,不回退全文。旧JSON/codocs-password分支仅保留旧冻结版本兼容,不用于新计划;原始凭据不改写、不复制。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 diff --git a/scripts/node-onboarding/configure-site.py b/scripts/node-onboarding/configure-site.py index f3e6738..621174f 100644 --- a/scripts/node-onboarding/configure-site.py +++ b/scripts/node-onboarding/configure-site.py @@ -11,7 +11,7 @@ from site_adapter import SiteAdapter def configure(plan,site): - allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'} + allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','preserved_service_overrides','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'} if set(site)-allowed:raise ValueError('Unexpected site configuration fields') library=importlib.import_module('paramiko') if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment') diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 19f6e4f..90638d1 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -13,6 +13,7 @@ class SiteAdapter: self.atomic_json,self.resource_lock=atomic_writer,resource_lock self.plan,self.directory=plan,Path(directory) self.ctx=plan['context'];self.lock=threading.RLock();self.leases={};self.formal_done={} + self.validate_service_overrides(self.ctx,plan['nodes']) for field in ('ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','client_binary'): value=self.ctx.get(field,'') if not isinstance(value,str) or not value.startswith('/') or '..' in value.split('/') or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-' for c in value): @@ -124,8 +125,26 @@ if p.exists(): execute(node,"python3 - <<'DPLCERTGATE'\n"+prefix+script+"\nDPLCERTGATE") execute(node,'command -v git >/dev/null && command -v curl >/dev/null && command -v certbot >/dev/null && command -v ss >/dev/null && command -v sha256sum >/dev/null && command -v openssl >/dev/null') + @staticmethod + def validate_service_overrides(context,nodes): + overrides=context.get('preserved_service_overrides',{}) + if not isinstance(overrides,dict):raise ValueError('Invalid preserved service overrides') + hosts={n['vps_id'] for n in nodes} + for host,value in overrides.items(): + if host not in hosts or not isinstance(value,dict) or set(value) not in ({'config'},{'unit','binary','config','port'}): + raise ValueError('Unknown preserved service override host or fields') + for field in ('binary','config'): + if field not in value:continue + path=value[field] + if not isinstance(path,str) or not __import__('re').fullmatch('/[A-Za-z0-9_./-]+',path) or any(part in ('','.','..') for part in path.split('/')[1:]): + raise ValueError('Unsafe preserved service override path') + if 'unit' in value and (not isinstance(value['unit'],str) or not __import__('re').fullmatch('[A-Za-z0-9_@.-]+',value['unit'])): + raise ValueError('Unsafe preserved service override unit') + if 'port' in value and (type(value['port']) is not int or not 1<=value['port']<=65535): + raise ValueError('Unsafe preserved service override port') + def guard_service(self,node,backup,read,write,execute): - spec=self.ctx['preserved_service'] + spec={**self.ctx['preserved_service'],**self.ctx.get('preserved_service_overrides',{}).get(getattr(node,'rid',None),{})} for field in ('unit','binary','config','port'): if field not in spec:raise ValueError('Explicit preserved service specification required') if not all(isinstance(spec[k],str) and __import__('re').fullmatch('[A-Za-z0-9_./@-]+',spec[k]) for k in ('unit','binary','config')) or type(spec['port']) is not int: diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index c03a02a..fa48428 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -432,6 +432,27 @@ class SiteAdapterTests(unittest.TestCase): current['config_sha256']='changed' with self.assertRaisesRegex(RuntimeError,'conflict'):adapter.guard_service(None,'/backup',read,write,execute) self.assertEqual(json.loads(files['/backup/preserved-service-original.json']),original) + + def test_guard_service_selects_only_the_lazy_host_override(self): + import ast + from site_adapter import SiteAdapter,Lazy + adapter=object.__new__(SiteAdapter) + default=dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443) + adapter.ctx={'preserved_service':default,'preserved_service_overrides':{'VPS-A':{'config':'/usr/local/XrayR/config.yml'}}} + observed=[] + def execute(client,command): + if not command.startswith('python3'):return '' + script=command.split('\n',1)[1].rsplit('\n',1)[0] + spec=ast.literal_eval(next(n.value for n in ast.parse(script).body if isinstance(n,ast.Assign) and n.targets[0].id=='s')) + observed.append(spec) + return json.dumps(dict(active=True,listeners=['*:443'])) + for rid in ['VPS-A','VPS-B']: + adapter.guard_service(Lazy(None,rid),'/backup',lambda *a:None,lambda *a:None,execute) + self.assertEqual(observed,[dict(default,config='/usr/local/XrayR/config.yml'),default]) + self.assertEqual(adapter.ctx['preserved_service'],default) + del adapter.ctx['preserved_service_overrides'] + adapter.guard_service(Lazy(None,'VPS-A'),'/backup',lambda *a:None,lambda *a:None,execute) + self.assertEqual(observed[-1],default) def test_generated_remote_python_and_group_independent_php_compile(self): import ast import subprocess @@ -533,6 +554,13 @@ class SiteAdapterTests(unittest.TestCase): base=original.plan;node=base['nodes'][0];node['node_address']='192.0.2.11';node['expected_exit']='192.0.2.11';base['context'].update(jump_vps='VPS-001',panel_vps='VPS-002',gateway_vps='VPS-003');registry.write_text(json.dumps({node['tls_domain']:{'expected_exit':node['expected_exit'],'country':node['country']}})) site=dict(client_sha256='d'*64,ops_root='/ops',node_root='/node',batch_backup_root='/backup/batch',node_backup_root='/backup/nodes',qa_root='/qa/batch',provider_snapshot=str(root/'provider.json'),exit_registry=str(registry),ssh_config=str(ssh),preserved_service=dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443),formal_socks_port=19999,gateway_containers=['gateway-a']) with patch.dict(sys.modules,{'paramiko':types.SimpleNamespace(__version__='5.0.0')}): + host=node['vps_id'] + for override in [{'config':'/usr/local/XrayR/config.yml'},dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)]: + configured=module.configure(base,dict(site,preserved_service_overrides={host:override})) + self.assertEqual(configured['context']['preserved_service_overrides'],{host:override}) + for overrides in [{'VPS-UNKNOWN':{'config':'/safe.yml'}},{host:{'unknown':'/safe.yml'}},{host:{'binary':'/bin/xrayr'}},{host:{'config':'relative.yml'}},{host:{'config':'/etc/../secret'}},{host:{'config':'/etc/config;id'}},{host:{'config':'/etc//config'}},{host:dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=True)}]: + with self.subTest(overrides=overrides),self.assertRaises(ValueError): + module.configure(base,dict(site,preserved_service_overrides=overrides)) plan=module.configure(base,site);self.assertEqual(plan['runtime']['kind'],'ssh-site-v1');self.assertFalse((root/'ledger').exists()) runner=batch.Coordinator(plan,root/'ledger',jobs=1);runner.runtime.close();runner.check_runtime() ssh.write_text(ssh.read_text()+'\n') From 82902e7ab6e96c05d24b1a79297055c7fd5816b6 Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 19:24:57 +0800 Subject: [PATCH 24/25] docs: record HK04 to HK06 node onboarding and acceptance --- docs/maintenance/NODE_ONBOARDING.md | 26 +++++++++++++++++++++++++- docs/maintenance/STATUS.md | 6 ++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index 0fef991..6a83a71 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,24 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-20 香港第二批HK04至06接入 + +| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 | +| --- | --- | --- | --- | --- | --- | --- | +| 🇭🇰 香港 HK - 04 | node-hk01 | VPS-023 | 19/19 | 37 | 47301 | 192.131.142.21 | +| 🇭🇰 香港 HK - 05 | node-hk03 | VPS-025 | 20/20 | 38 | 47303 | 192.131.142.220 | +| 🇭🇰 香港 HK - 06 | node-hk07 | VPS-029 | 18/18 | 36 | 47307 | 85.113.71.90 | + +三台容量/XrayR门禁通过,hk01实际Debian10、hk03/hk07 Debian11,原服务保留,新AnyTLS独立24443。中转规则5172/5173/5174对应47301/47303/47307,Proxy Protocol收发关闭;白色机接口精确新增/回读,原40条所核字段保持,总数43。新SNI为dpl-hk01/hk03/hk07.gemcook.net;仅Namecheap DNS新增使用必要网页,旧记录保持,两台权威NS回读通过。 + +本批发现hk01实际运行配置为/usr/local/XrayR/config.yml,另外两台为/etc/XrayR/config.yml,未搬动/复制/修改旧配置。源码66f3044增加按候选VPS覆盖保护规格,51项隔离回归通过并已推送;本批冻结preserved_service_overrides仅指定VPS023真实config。两台Git准备因附加签名缺钥导致gpgv整体exit2而暂停;已有官方发行VALIDSIG已独立核实,任务验证器逐签名拒绝坏/过期/撤销/不明状态,8项正反测试通过,保留Release/Packages/逐包SHA与元数据核验及固定版本安装,未关闭APT签名或ValidUntil。下载曾卡在官方CDN的IPv6 SYN连接,断链后两轮任务进程仍存活;独立核对PID/启动时刻/父命令精确源码并确认无apt/dpkg后仅TERM这4个任务进程,单次以IPv4 HTTPS及原签名哈希验证完成依赖。初轮部署两机在证书已签发、enable前断链,现场确认无残留命令/owner匹配后按原冻结probe恢复,以阶段边界SSH保活继续,未重签或手写成功凭据。后续只读回读再次遇到SSH会话丢失,先独立确认三台真实部署与原服务状态,未盲重放写入;白色机无重启或MaxStartups丢弃证据,根因未确认。最终采用单并发持续会话和阶段保活,旧QA原始字节/审核归档保留,三台均重新实际联网并获新独立审核,再在同一会话完成开放、动态审计与账号收尾。 + +三个隔离QA完整订阅联网、严格TLS/SNI、独立HK预期出口及在线/双向上报通过,由一个子助手独立核对原始字节、身份、摘要和时效后开放到组1。QA已禁用,实际订阅403并从下发用户排除。正式动态20节点完整订阅结构/密码/权限及逐条严格TLS通过;本批仅独立追加且无共享配置变化,正式范围tls,不代表20条重新测试客户端出口。旧节点、套餐、正式用户关联、订阅配置、应用版本和四网关容器基线保持。原XrayR进程/配置/程序/443及旧renewal配置经独立终审保持,新证书专用续期dry-run通过。hk01旧renew.service在开工前已failed,旧日志为ACME connection refused,未证实AppArmor问题;本次不修复或宣称旧证书续期成功。 + +当前有效登记24台、已接20台、剩4台未接:us02、hk02、vn01、ru01。us02有历史SSH密钥冲突;hk02/ru01登记512MB但尚未现场确认XrayR,不能仅凭登记断言最终排除;vn01按用户登记属HK,未改服务器代号。 + +证据codex/output/node-batch-hk01-hk03-hk07-20261020/;冻结计划与共享账本node-batch-five-20261017/ledger/hk01-hk03-hk07-20261020,不改旧批次摘要。服务器备份/root/codex-backups/dpl/node-batch-hk01-hk03-hk07-20261020,网关/var/lib/dpl-sgw/node-batch-hk01-hk03-hk07-20261020;回退按各promotion-journal逐字段预览,不恢复整库。已完成服务器侧验收,未做线上页面验收;手机客户端/TUN、多设备并发和生产回退演练未做。GemDocs本批实例/域名/中转规则与关联已同步,离线校验通过:12项目、435文档、3515链接、390资源对象及11岗位/13科室绑定;不代替线上业务验收,不自动更新人类版。 + ## 2026-10-20 三个香港节点服务器侧接入 | 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 | @@ -122,6 +140,9 @@ | hk04 | node-hk04 | haiwi.lshan.eu.org:47304 | 45.128.220.145:24443 | dpl-hk04.gemcook.net | 本文2026-10-20香港批次 | | hk05 | node-hk05 | haiwi.lshan.eu.org:47305 | 45.128.220.146:24443 | dpl-hk05.gemcook.net | 本文2026-10-20香港批次 | | hk06 | node-hk06 | haiwi.lshan.eu.org:47306 | 85.113.71.89:24443 | dpl-hk06.gemcook.net | 本文2026-10-20香港批次 | +| hk01 | node-hk01 | haiwi.lshan.eu.org:47301 | 192.131.142.21:24443 | dpl-hk01.gemcook.net | 本文2026-10-20香港第二批 | +| hk03 | node-hk03 | haiwi.lshan.eu.org:47303 | 192.131.142.220:24443 | dpl-hk03.gemcook.net | 本文2026-10-20香港第二批 | +| hk07 | node-hk07 | haiwi.lshan.eu.org:47307 | 85.113.71.90:24443 | dpl-hk07.gemcook.net | 本文2026-10-20香港第二批 | 客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 @@ -144,7 +165,10 @@ | node-hk04 | dpl-node,TCP 24443,机器/节点 ID 17/17(2026-10-20) | XrayR,TCP 443保留 | | node-hk05 | dpl-node,TCP 24443,机器/节点 ID 16/16(2026-10-20) | XrayR,TCP 443保留 | | node-hk06 | dpl-node,TCP 24443,机器/节点 ID 15/15(2026-10-20) | XrayR,TCP 443保留 | -| 中转 | 17条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-hk01 | dpl-node,TCP 24443,机器/节点 ID 19/19(2026-10-20) | 原XrayR 443保留 | +| node-hk03 | dpl-node,TCP 24443,机器/节点 ID 20/20(2026-10-20) | 原XrayR 443保留 | +| node-hk07 | dpl-node,TCP 24443,机器/节点 ID 18/18(2026-10-20) | 原XrayR 443保留 | +| 中转 | 20条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index eb415f0..3e74729 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,12 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 香港HK04–06接入完成 + +node-hk01/hk03/hk07新增展示HK - 04/05/06。三台完整隔离联网/HK出口/双向上报和独立助手审查通过,QA禁用403/下发排除;正式动态20节点结构/密码/权限/严格TLS通过。原服务、旧renewal、旧业务及四容器保持,新证书续期演练通过。hk01旧证书续期在开工前已失败,未修改或声称修复。 + +按主机真实保护路径的源码66f3044已推送,51项隔离回归通过;任务签名验证器8项正反测试通过,没有关闭APT签名/时效校验。当前有效登记24、已接20、剩4(含低内存/SSH异常待核实)。GemDocs已同步;离线校验通过:12项目、435文档、3515链接、390资源对象及11岗位/13科室绑定;不代替线上业务验收。已完成服务器侧验收,未做线上页面验收。详情见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-20-香港第二批hk04至06接入)。 + ## 2026-10-20 香港HK01–03接入完成 node-hk04/hk05/hk06展示香港HK - 01/02/03,已加入基础组1。三台隔离完整订阅、严格TLS、HK出口、双向上报及一个助手独立审核通过,QA禁用403/下发排除;正式动态17节点结构/密码/权限/逐条严格TLS通过,不代表17条重测出口。原XrayR/443进程、旧renewal配置、旧资源与四容器保持,新证书续期演练通过。 From a49881f620e9b02850078df43f7db5b40ac05782 Mon Sep 17 00:00:00 2001 From: hellcat Date: Wed, 21 Oct 2026 15:39:15 +0800 Subject: [PATCH 25/25] docs: record remaining eligible node onboarding and tool safeguards --- docs/maintenance/NODE_BATCH_RUNTIME.md | 8 +++++++ docs/maintenance/NODE_ONBOARDING.md | 32 +++++++++++++++++++++++++- docs/maintenance/STATUS.md | 6 +++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 9eed3d9..789c371 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -36,6 +36,14 @@ gemcook.net为Namecheap BasicDNS,`ACC-002`目前只有已登记网页登录密 预选时先核实;正式适配器每次preflight在准备正式测试账号和stage前再次执行`capacity_gate`,本地600回执绑定请求、字节数、安装状态和采集时间。小内存且已装时拒绝进入接入阶段;不能为了凑批次数忽略。满足本规则只代表未被该条件排除,仍须通过余下资源、原服务、端口与容量检查。 +### 无旧XrayR与发行版工具差异 + +2026-10-21本批ru01实际小于1GB但未安装XrayR,不命中两条件排除。正式适配器已有完整`preserved_service_overrides`:按现场实际unit、程序、配置和端口保护SSH等原服务,不创建虚假的XrayR文件或跳过原服务门禁;无原443监听及原22/53全监听另外留前后证据。各主机工具、续期timer和收尾采样也必须按实际选择,不能把Snap timer或XrayR写死后宣称通过。 + +Ubuntu20.04自带Certbot0.40缺少当前验收所需参数,不能以删掉dry-run/deploy-hook检查兼容。可在独立venv安装经过实际验证的官方版本并让部署命令与续期服务使用同一运行时,保留原unit/drop-in/入口存在性、原字节摘要和精确回退。Python3.8的本批组合为Certbot/ACME2.11.0、pyOpenSSL24.3.0、cryptography44.0.3、josepy1.15.0;本批兼容组合不等于最新版或未来兼容保证。验证依赖导入、pip check、实际插件registry参数解析和工具版本;隐藏参数不能仅凭help文本是否展示判断,`--help`提前退出也不替代真实参数解析。注册结果未知先回读已有账户,不重放注册;账户JSON并不保证包含status字段。 + +Debian官方Release可能带新版本共签。任务验证器仍须至少有对应发行版的必需官方签名,新增共签仅按官方精确指纹单独允许,不能让共签单独替代必需签名;异常/未知签名仍拒绝。保留Release→Packages→固定版本deb哈希链与无删除/无意外升级模拟。详见本批[NODE_ONBOARDING](NODE_ONBOARDING.md),旧冻结任务源码和计划不改摘要追认。 + ## 准备与Git部署 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 diff --git a/docs/maintenance/NODE_ONBOARDING.md b/docs/maintenance/NODE_ONBOARDING.md index 6a83a71..9446805 100644 --- a/docs/maintenance/NODE_ONBOARDING.md +++ b/docs/maintenance/NODE_ONBOARDING.md @@ -4,6 +4,30 @@ 首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。 +## 2026-10-21 剩余候选三节点接入 + +| 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测出口 | +| --- | --- | --- | --- | --- | --- | --- | +| 🇺🇸 美国 US - 04 | node-us02 | VPS-013 | 21/21 | 46 | 47102 | 107.172.62.233 | +| 🇭🇰 香港 HK - 07 | node-vn01 | VPS-034 | 22/22 | 47 | 47308 | 96.9.228.135 | +| 🇷🇺 俄罗斯 RU - 01 | node-ru01 | VPS-035 | 23/23 | 48 | 47501 | 45.140.169.230 | + +用户要求接入剩余四台:us02、vn01、ru01符合组合容量门禁;hk02现场MemTotal为516,222,976 bytes且已安装运行XrayR,按既定规则排除,未部署或改旧服务。us02为1,023,815,680 bytes且已装XrayR,vn01为8,294,055,936 bytes且已装XrayR,ru01为484,175,872 bytes但未装XrayR,后者不命中“小于1GB且已安装”的两条件规则。vn01按用户登记及实测出口归HK,未改服务器key为hk或按vn字面设VN。 + +us02/ru01使用用户更新的独立SSH凭据成功认证。us02旧主机密钥冲突由用户明确批准接受本次精确新指纹,严格匹配后登录;旧指纹和信任基线保留,批准不等于独立核验服务商密钥来源。连接始终经白色机。旧临时SSH库被清空后本次从官方PyPI恢复隔离Paramiko5.0.0,未改变系统Python或补造旧冻结环境。 + +三台独立AnyTLS24443,中转5175/5176/5177分别47102/47308/47501,PP收发关闭;旧43条所核字段保持,当前46条。us02旧46102、vn01旧46601的443转发保持;ru01原本没有443转发,任务规则显式old_port=null且对baseline/current所有归属均要求不存在该目标443,不能借用其他旧规则。原7恢复查重测试及新增反例合计12项通过。中转一次SSH握手阶段失败后先只读probe确认候选absent才执行,不盲重放创建。 + +Namecheap仅必要DNS网页新增dpl-us02/vn01/ru01.gemcook.net三条A;正常登录及设备邮件验证,真人验证由用户手动完成。旧记录逐条比较、刷新保存和两权威NS/AA解析核验通过。其余接入/验收全在服务器侧,不登录DPL网页。 + +Debian11的Git使用隔离官方索引、发行版必需签名与精确可选共签,11项正反测试及真实gpgv状态复核通过,保留Release/Packages/deb哈希链及固定版本安装。原拒绝是合法Debian12共签不在任务白名单,未关闭签名校验;共签单独不能满足必需签名。us02新增3包、vn01新增9包,旧XrayR程序/配置/进程/443保持。 + +ru01无旧XrayR,完整保护规格按实际ssh unit、/usr/sbin/sshd、/etc/ssh/sshd_config、22设置,原DNS53与全监听另验收。Ubuntu20.04 Apt Certbot0.40缺必需参数,先装官方Certbot2.11隔离venv,固定兼容pyOpenSSL24.3.0/cryptography44.0.3/josepy1.15.0,依赖导入、pip check及真实插件registry参数解析通过;/usr/local/bin入口与certbot.service定向drop-in使用同一运行时,原certbot.timer enabled/active。原unit/drop-in字节摘要、入口存在性及精确回滚脚本保存;初次依赖前未采SSH unit SHA,后来只读元数据表明unit时间早于依赖,不将后来摘要写成先前采集。ACME正常注册后只读show_account确认,因账户JSON无status导致后置检查失败时未重放注册。vn01旧renewal字节相对昨日基线发生变化:mtime与原Snap timer于今日03:59执行及not_due日志一致,旧证书未签新版本;推断为自然续期元数据更新,因基线仅留hash不能重建精确字段差异,未回写覆盖。us02旧Snap续期服务本批前已failed,本次保留旧renewal,不声称修复;新三证书专用续期dry-run均通过。 + +三台隔离完整订阅联网、严格TLS/SNI、准确US/HK/RU出口及在线/上下行上报由唯一助手独立复核双端原始字节、请求/身份/时效后才开放;QA均已禁用,订阅403且下发排除。正式动态23节点完整结构/密码/权限与逐节点严格TLS通过,不代表23条重跑完整客户端出口/流量。旧节点、套餐、正式用户关联、订阅设置、应用版本及四容器基线保持。 + +当前有效登记24台,已接23台,未接的hk02为本次明确不符合条件的排除项,不作为等待接入的合格候选。证据codex/output/node-batch-remaining-20261020/(跨日恢复,实际完成2026-10-21),共享账本node-batch-five-20261017/ledger/remaining-20261020。服务器备份/root/codex-backups/dpl/node-batch-remaining-20261020、网关/var/lib/dpl-sgw/node-batch-remaining-20261020;回退按各promotion-journal逐字段预览,不恢复整库。GemDocs资源及hk02排除事实同步,本批15份资源与20份派生视图已更新;全库build/check仍报告无关fnb项目SEO.md失效链接,未更改该项目或声称全库通过。已完成服务器侧验收,未做线上页面验收;手机/TUN、多设备并发和生产回退演练未做。 + ## 2026-10-20 香港第二批HK04至06接入 | 展示名 | VPS名称 | 资源 | 节点/机器ID | 隔离QA | 中转端口 | 实测HK出口 | @@ -143,6 +167,9 @@ | hk01 | node-hk01 | haiwi.lshan.eu.org:47301 | 192.131.142.21:24443 | dpl-hk01.gemcook.net | 本文2026-10-20香港第二批 | | hk03 | node-hk03 | haiwi.lshan.eu.org:47303 | 192.131.142.220:24443 | dpl-hk03.gemcook.net | 本文2026-10-20香港第二批 | | hk07 | node-hk07 | haiwi.lshan.eu.org:47307 | 85.113.71.90:24443 | dpl-hk07.gemcook.net | 本文2026-10-20香港第二批 | +| us02 | node-us02 | haiwi.lshan.eu.org:47102 | 107.172.62.233:24443 | dpl-us02.gemcook.net | 本文2026-10-21剩余候选批次 | +| vn01 | node-vn01 | haiwi.lshan.eu.org:47308 | 96.9.228.135:24443 | dpl-vn01.gemcook.net | 本文2026-10-21剩余候选批次 | +| ru01 | node-ru01 | haiwi.lshan.eu.org:47501 | 45.140.169.230:24443 | dpl-ru01.gemcook.net | 本文2026-10-21剩余候选批次 | 客户端 → 对应中转入口 → 对应落地 → 目标网络。以上链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。 @@ -168,7 +195,10 @@ | node-hk01 | dpl-node,TCP 24443,机器/节点 ID 19/19(2026-10-20) | 原XrayR 443保留 | | node-hk03 | dpl-node,TCP 24443,机器/节点 ID 20/20(2026-10-20) | 原XrayR 443保留 | | node-hk07 | dpl-node,TCP 24443,机器/节点 ID 18/18(2026-10-20) | 原XrayR 443保留 | -| 中转 | 20条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | +| node-us02 | dpl-node,TCP 24443,机器/节点 ID 21/21(2026-10-21) | 原XrayR 443保持 | +| node-vn01 | dpl-node,TCP 24443,机器/节点 ID 22/22(2026-10-21) | 原XrayR 443保持 | +| node-ru01 | dpl-node,TCP 24443,机器/节点 ID 23/23(2026-10-21) | 原SSH22/DNS53保持,开工无XrayR/443 | +| 中转 | 23条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 | | gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin | | 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 | | Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 3e74729..2bf659c 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,12 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-21 剩余合格节点接入完成 + +新增us02/vn01/ru01展示US - 04、HK - 07、RU - 01,已接23台;hk02现场小于1GB且运行XrayR,按组合规则排除。三台完整隔离联网/各自出口/双向上报和助手独立审查通过,QA禁用403/排除;正式动态23节点结构/密码/权限及严格TLS通过。旧服务、既有业务及四容器保持,新三证书续期演练通过。RU实际保护SSH/DNS且使用已验证隔离Certbot2.11与既有certbot.timer,未假造XrayR/Snap。 + +GemDocs资源及排除项已同步,本批15份资源与20份派生视图已更新;全库build/check仍报告无关fnb项目SEO.md失效链接,未更改该项目或声称全库通过。服务器侧验收完成,未做DPL页面验收。工具兼容及签名修复范围、旧续期失败未修复与证据边界见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-21-剩余候选三节点接入)。 + ## 2026-10-20 香港HK04–06接入完成 node-hk01/hk03/hk07新增展示HK - 04/05/06。三台完整隔离联网/HK出口/双向上报和独立助手审查通过,QA禁用403/下发排除;正式动态20节点结构/密码/权限/严格TLS通过。原服务、旧renewal、旧业务及四容器保持,新证书续期演练通过。hk01旧证书续期在开工前已失败,未修改或声称修复。