Compare commits

...

11 Commits

23 changed files with 1098 additions and 9 deletions

View File

@ -1,5 +1,9 @@
# 业务约束
## 节点命名
节点展示编号与 VPS 代号、数据库 ID 和部署 key 的关系,统一遵循[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号)。新增、迁移或改名时必须先核对生产清单;具体格式和分配方法只在该节维护。
## 邮件渠道
- 验证码、登录链接、工单回应邮件通知优先 Resend;工单通知须有有效用户邮箱。额度耗尽或明确无法使用时走常规邮箱。

View File

@ -0,0 +1,91 @@
# Cloudflare 订阅配置与调优审查
核对日期:2026-10-11。依据用户最初的 DNS/橙云、独立网关、AnyTLS 节点、共享资源保护和追加真实 IP 自动同步要求重新建立检查项,并补查订阅客户端兼容性及性能设置。随后用户追加授权原机场 fu101.gembox-sub.com 的 Cloudflare 调优,并要求缓存规则覆盖将来更换的订阅子域名。不是只重验原聊天留下的待办。
初次审查为只读;后续按用户追加授权修改了 Cloudflare 缓存和配置规则,并完成有效订阅复验。网关、面板、节点代码及数据库配置没有变更,服务没有重启。现状可以确认;不能仅凭现状证明某项过去未做一定由浏览器权限造成,或保证所有潜在优化都已穷尽。
## 现场结果
| 项目 | 本轮证据 | 结论 |
| --- | --- | --- |
| DNS/代理边界 | c2 A=65.99.193.129、已代理;us01 A=64.44.115.18、仅 DNS | 符合订阅走 CF、原生 AnyTLS 不走普通橙云的设计 |
| 严格回源 TLS | dpl 区域页面 Full (strict),新增的 BIC 配置规则未设置 SSL | 已复核 |
| 边缘证书 | *.deeplink-subscribe.com 和根域名通用证书有效,至 2027-01-09 | 覆盖 c2 |
| 源站证书/续期 | 网关证书域名、有效期匹配,独立 Certbot 根目录、禁用目录钩子及定时器配置存在 | 本轮只读核对;续期 dry-run 沿用此前结果,没有重跑 |
| 个性化订阅缓存 | 唯一活动规则 Bypass cache - deeplink-subscribe.com (all hosts),所有传入请求,顺序 1 | 已按用户最新要求扩大到整个订阅区域,无后续缓存资格规则覆盖 |
| 规则部署后响应 | 正式有效订阅连续标准路径请求和短路径均 200、有效 JSON、private/no-store、DYNAMIC,无 Age/HIT/cf-mitigated | 重新验收通过;不是禁用用户的错误页测试 |
| TLS/HTTP 性能 | TLS 1.3、HTTP/2、HTTP/3、HTTP/2 to Origin 页面启用 | 基础协议项已启用;源站未启用 h2,仅配置支持状态不代表实际 h2 回源 |
| HTTP/压缩实测 | 额外有效订阅协商 HTTP/2,gzip 响应自动解码后仍为有效 JSON;Alt-Svc 广告 h3 | H2/gzip 通过;未强制 H3 或实测 Brotli/Zstd |
| IPv6 与原始 IP | IPv6 开启,Pseudo IPv4=off,删除访问者 IP 标头的托管转换关闭 | 未覆盖或移除 CF-Connecting-IP |
| Worker/转换覆盖 | 无 Worker 路由、请求/响应标头转换、源站规则或旧式页面规则;配置规则仅有本次 BIC 例外 | 未发现这些方式导致订阅改写 |
| Bot Fight/Under Attack | Bot Fight 关闭,I'm under attack 关闭 | 本轮请求没有挑战页 |
| 自定义防火墙/限流 | 自定义 WAF 和速率限制规则为零;托管规则列表未创建 | 不等于关闭 Cloudflare 平台自带 DDoS/自动防护 |
| BIC | 全区域默认开启;Subscription client compatibility - c2 对精确主机下的订阅 API 和 /s/ 路径关闭 | 已经用户明确批准后实施;其他路径保留默认检查 |
| 最低边缘 TLS | 仍为默认 TLS 1.0 | 有可评估的安全收紧;尚未实施,不影响当前现代客户端成功请求 |
| HTML/JS 优化 | Rocket Loader、Speed Brain、Fonts、Early Hints、RUM 关闭 | 不把网页渲染优化误用于 JSON/YAML 订阅;本轮保持原值 |
| 真实 IP 同步 | 新旧相关 Nginx vhost 引用受管理文件,22 段与本轮官方 HTTPS 列表逐集合一致,定时器 active | 同步实际接通;每天执行,非实时推送;无主动失败告警和故障注入验收 |
| 共享资源保护 | 新旧网关容器运行、独立上游/卷/网络;原 XrayR PID/启动时间保持;Nginx -t 成功 | 此次 CF 调优没有重启网关。历史首次签发曾触发原网关一次重启,不能改写为从未影响 |
Cloudflare 返回 DYNAMIC 与显式 Bypass cache 并不矛盾,官方说明支持这一表现。[缓存规则设置](https://developers.cloudflare.com/cache/how-to/cache-rules/settings/)
## 已实施的兼容规则与原机场调优
BIC 只筛查请求头,并非订阅令牌认证。关闭会减少一层请求头筛查,不能说风险完全不变。当前有效订阅本来就可通过,不能把 BIC 认定为历史地区失败的根因。自动审批最初拒绝部署,要求具体授权;用户在获知风险后明确回复“批准”,之后正常浏览器部署成功,没有采用绕过审批的控制方式。[BIC 官方说明](https://developers.cloudflare.com/waf/tools/browser-integrity-check/)、[配置规则设置](https://developers.cloudflare.com/rules/configuration-rules/settings/)
以下两条 Configuration Rule 均只添加 Browser Integrity Check=Off,不跳过全部 WAF,不改网关 UA/IP 过滤、限流或令牌校验:
```text
Subscription client compatibility - c2
(http.host eq "c2.deeplink-subscribe.com" and
(http.request.uri.path eq "/api/v1/client/subscribe" or starts_with(http.request.uri.path, "/s/")))
Subscription client compatibility - fu101
(http.host eq "fu101.gembox-sub.com" and
(starts_with(http.request.uri.path, "/sub/") or
http.request.uri.path eq "/api/v1/client/subscribe" or
starts_with(http.request.uri.path, "/api/v1/client/subscribe/")))
```
原订阅主机兼有管理入口,因此不能把 BIC 例外扩大到该主机所有路径。区域默认 BIC 仍开启,管理路径不匹配以上表达式。
| 原机场项目 | 现场结果与本次操作 |
| --- | --- |
| DNS | fu101 A=65.99.193.129,已代理;未更改记录 |
| 回源 TLS | 区域原为 Full/自动模式;新增 Strict origin TLS - fu101.gembox-sub.com,仅精确主机 SSL=Strict,顺序 1,活动;区域默认及其他主机保持原值 |
| 源站证书 | fu101 证书 SAN 匹配,有效至 2026-12-17;已在 Strict 规则启用后成功拉取有效订阅 |
| 缓存 | 新增后再按用户要求扩大:Bypass cache - gembox-sub.com (all hosts),所有传入请求,绕过缓存,顺序 1,唯一活动规则 |
| 客户端兼容 | 上述 fu101 BIC 例外为配置规则顺序 2,活动;不覆盖管理入口,不修改 SSL |
| 真实响应 | 原生 /sub/ 路径的 sing-box JSON 与 Clash YAML 均 200、有效配置、no-store、DYNAMIC,无 Age/HIT/cf-mitigated;gzip JSON 解码通过 |
| 保留网关过滤 | 无 UA 的兼容 API 请求仍为 403;现场 map 明确将空 UA 标记为拒绝对象。本次没有关闭该过滤,不把该响应认作 CF 挑战或全客户端验收通过 |
| 网络与安全默认 | IPv6 开,Pseudo IPv4=Off;区域默认 BIC 开,Bot Fight/Precursor 关;Cloudflare 平台自动/DDoS 防护保留 |
| 协议与网页项 | 页面 HTTP/2、HTTP/3、HTTP/2 to Origin、TLS 1.3 开;0-RTT、Early Hints、Speed Brain 关;原有 RUM 开,未更改。未证明实际 h2 回源或 H3 客户端协商 |
| 共享服务 | 四个新旧网关/管理容器运行,ID/启动时间与变更前一致,Nginx 语法通过;没有操作原上游、卷、端口或服务重启 |
原机场生产项目由当前 Nginx 确认为 `/www/wwwroot/fuuuu.cloud`;不能照搬 CoDocs 中旧的 `/www/wwwroot/fuuu.cloud` 路径。原应用为 SSPanel-Uim,订阅入口是 `/sub/{token}/{subtype}`,不是 dpl 的标准 API。有效令牌仅在原生产服务器的进程内存使用。
## 订阅域名轮换与缓存范围
用户明确要求同一区域更换订阅前缀时自动覆盖。两个区域的 Cache Rule 已分别改为“所有传入请求”(表达式 `true`),动作仅 Bypass cache;没有新增重复规则、浏览器 TTL 覆盖或修改源站 no-store 标头。这是区域级规则:覆盖该区域根域和当前/未来子域名经 CF 代理的 HTTP 请求,不覆盖另一个新购区域,不影响 DNS only 的节点流量。
同一区域从 a 改到 b 后无需改缓存规则;仍须完成 DNS/代理、Nginx vhost、证书与续期、网关允许的 Host/上游、面板订阅 URL,以及精确主机范围的 BIC/TLS 规则调整,再做有效订阅复验。缓存配置不会自动让新主机上线,也不会解除地区封锁。后续若在这两个专用订阅区域部署静态站点,全部请求也会绕过 CF 边缘缓存,届时须单独设计例外和规则顺序。[缓存规则顺序](https://developers.cloudflare.com/cache/how-to/cache-rules/order/)
## 回退
- 仅回退本次缓存范围扩大:在原规则 ID 上恢复原名称及主机精确匹配,仍为 Bypass cache;变更前值保存在 `codex/output/cloudflare-audit-20261011/cache-rules-before-expansion.json`。dpl 的缓存规则原先已存在,不能直接删除来回退。
- 回退 BIC:停用本次两条 Subscription client compatibility 配置规则,恢复区域默认检查。不操作管理入口或网关过滤。
- 回退原机场 Strict:停用本次 Strict origin TLS - fu101.gembox-sub.com,回到原区域 Full/自动模式;不更改区域设置或证书。
- 回到原机场本轮调优前:另行停用本轮新增的原机场 Cache Rule。勿删除其他区域或后来新增规则;先核对当前状态仍为本轮结果。
## 仍未实施的可选调优
1. 核对实际客户端和该区域其他代理主机后,评估把 dpl 最低边缘 TLS 从 1.0 提至 1.2。免费套餐的普通设置覆盖区域全部代理主机;按主机单独设置需要 ACM。本轮没有购买产品或改变 TLS 下限。[Minimum TLS 官方说明](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/)
2. 日常观测订阅失败率和边缘/源站耗时后,才评估付费路由或额外性能产品。缓存加速不适用于含用户凭据的个性化订阅;0-RTT 关闭、HSTS 未启用、边缘 Always Use HTTPS 未启用不自动等同漏做。当前正式订阅 URL 使用 HTTPS,源站 HTTP 已 301 到 HTTPS。没有点击“启用所有可用设置”,没有宣称实测性能收益。[HTTP/2](https://developers.cloudflare.com/speed/optimization/protocol/http2/)、[HTTP/3](https://developers.cloudflare.com/speed/optimization/protocol/http3/)、[内容压缩](https://developers.cloudflare.com/speed/optimization/content/compression/)
3. 官方 IP 段同步每天在宿主机时间 02:00 后随机最多 15 分钟执行,当前配置与官方列表相符;变化后最多等待下一轮成功同步,下载失败保留最后有效值。未来可单独评估更频繁同步和失败告警,本轮没有改共享定时器,不能保证 IP 调整时绝无短暂识别波动。
## 验收与数据边界
有效用户令牌只在生产服务器进程内存中使用,没有输出、复制或保存订阅令牌/正文;只输出白名单响应头和非私密节点参数。未改变用户、未恢复临时用户、未运行代理客户端产生正式用户节点流量。QA 不在当前下发用户集合,四个授权套餐关联正确,套餐其他字段与精确备份一致。
本轮请求来自生产服务器;手机 GUI、系统 TUN、多地区、IPv6 端到端、H3、不同客户端 UA 全覆盖、同步故障注入与真实告警均未执行。本地节点连通按用户要求继续跳过。两区域缓存扩大、两订阅路径 BIC 例外、原机场 Strict 已部署,保存后的真实订阅与共享服务复验通过。文档本地已更新,Git 提交/推送状态以当轮交付记录为准。
页面证据保存于 `codex/output/cloudflare-audit-20261011/`:`c2-cache-zone-enabled.png`、`old-cache-zone-enabled.png`、`c2-bic-enabled.png`、`old-bic-enabled.png`、`old-strict-enabled.png`。它们对应不同配置阶段,旧的精确主机缓存截图不代表最终缓存范围。

View File

@ -0,0 +1,87 @@
# 网关管理域名与白色机反代
用户指定日期: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`)。最终文档另随根仓库提交,不改变此运维版本。
- 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 |
| dpl 网关管理 | getway-dpl.gemcook.net | getway-dpl.tomshell.online |
| 新版域名区域 | gemcook.net;同前缀一级子域 | tomshell.online;同前缀一级子域 |
| 旧版域名区域 | diaodu.us;同前缀一级子域 | tomshell.com.de;同前缀一级子域 |
| dpl 主站 | dp-link.net | dpl.tomtojapan.online |
| fuc 主站 | 福云.com(ASCII:xn--9kqy16h.com) | fuc.tomtojapan.online |
主域名映射不会自行完成子域 DNS、证书或 Nginx 匹配。本次泛映射按同一前缀对应源站,拒绝不属于指定区域和多级子域的转发;多级主机需额外证书和登记。现有专用匹配优先保留作为兼容入口,不能把已有 TTC、Git、日语服务或旧网关入口送往错误上游。
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 或另一机器代抓取补本地验证。
## 网关与共享资源
两个现有管理容器保持独立,不改账号、密码、订阅上游、卷或原业务监听端口。gateway-01 新增精确管理 vhost,后端仍为旧 64444 和新回环 16444。内部 HTTPS 分别按 fu101.gembox-sub.com、c2.deeplink-subscribe.com 的既有有效证书校验,不使用 proxy_ssl_verify off。
旧管理入口的私有路径在服务器运行时从既有容器配置提取,并与旧 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 自动续期。
## 隔离证书与运维入口
- gateway-01:`issue-gateway-admin.sh` 使用既有已接受协议的 LE 账户的同机副本,仅以 webroot 签发两个新增域名;`gateway-admin-cert.timer` 每日检查,隔离目录 `/etc/gateway-admin-letsencrypt`。不读取或运行原全局续期钩子。
- 白色机:`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 --no-random-sleep-on-renew` 验证,部署钩子只检查/重载 Nginx;挑战 TXT 已清理。证书服务超时分别 15/30 分钟,timer 自带错峰,Certbot 不再额外随机长等待。
## Git 发布与回退
发布源码先按项目正常 Git 流程提交/推送,只包含本次脚本和维护正文,保留工作区原有 AGENTS/DEPLOYMENT/README 修改。服务器检出明确运维提交;凭据、私钥、CSR 临时产物和备份不进入 Git。必要时使用已推送提交的 Git bundle fetch,不打包覆盖项目。
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 应继续验收。

View File

@ -1,6 +1,23 @@
# 节点与订阅网关接入
核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 后台 TLS 后验和显式缓存规则仍受浏览器权限阻塞,整体尚未全部完成。凭据、机器令牌、用户 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 - 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。
- 运维代码通过已推送 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、多地区或并发验收;不把服务器侧通过写成这些项目通过。
## 当前连接关系
@ -25,15 +42,13 @@
Namecheap 已委托至 bradley.ns.cloudflare.com、jewel.ns.cloudflare.com。导入的原有记录保留。c2 指向网关并启用代理;us01 指向节点,DNS only。两张源站证书有效期至 2027-01-09,公网订阅 HTTPS 可用。
Cloudflare Full (strict) 曾提交保存,但保存后的账户状态因网站权限拦截未复核;显式订阅 Cache Rule 尚未创建,不能据公网 HTTPS 正常写成已完成。当前实际订阅返回 CF-Cache-Status: DYNAMIC、Cache-Control: private, no-store。
2026-10-11 本聊天通过正常浏览器访问直接复核:dpl 区域 SSL/TLS 为 Full (strict)。初次只读审查没有配置/源站/请求或响应标头转换/URL 改写/重定向规则,旧式 Page Rules 为零。后续按具体授权新增订阅路径 BIC 配置规则,未设置 SSL,严格模式保持。
恢复浏览器权限后的收尾配置已确定,尚未执行:
订阅 Cache Rule 最初由用户另行创建的聊天部署,旧名为 `Bypass cache - c2.deeplink-subscribe.com`、精确匹配 c2。用户随后要求考虑订阅子域名更换,现已在原规则上改为 `Bypass cache - deeplink-subscribe.com (all hosts)`,所有传入请求(`true`),Bypass cache、活动、顺序 1、唯一生效;不添加重复规则。该范围仅是此区域经 CF 代理的 HTTP 请求,不把 DNS only 的节点流量接入橙云。DNS 页面确认 c2 的 A=65.99.193.129、已代理;us01 的 A=64.44.115.18、仅 DNS。
1. 在 deeplink-subscribe.com 的 SSL/TLS 页面读取保存后的模式,确认 Full (strict),同时核对是否有覆盖 c2 的 Configuration Rule。源站已有有效域名证书;不能只看浏览器锁标志判断回源校验模式。
2. 在 Cache Rules 新增 `dpl-c2-subscription-bypass`,Custom filter expression 为 `http.host eq "c2.deeplink-subscribe.com"`,Cache eligibility 选择 Bypass cache。c2 专用于订阅,因此整个该主机绕过缓存;其他主机不匹配。
3. 检查既有规则,将这条规则置于可能匹配 c2 的缓存资格规则之后,避免后续规则覆盖。部署后刷新页面复核名称、条件、动作及顺序,再核对订阅响应仍为 private/no-store,且没有 HIT 或 Age。
初次只读审查之后实际保存了上述缓存调整和 BIC 规则。BIC 仅在精确 c2 主机的 `/api/v1/client/subscribe` 与 `/s/` 路径关闭,其他路径/主机保留区域默认检查,源站过滤与限流不改。配置最终保存后,已授权套餐的有效用户令牌只在生产服务器进程内存使用:标准路径连续两次和短路径均 200、有效 sing-box JSON、DYNAMIC、private/no-store,无 Age/HIT/cf-mitigated;没有输出、复制或保存令牌,也未启用临时用户。gzip 解码通过,先前独立请求实测 HTTP/2 并广告 h3。当前聊天访问成功不代表源聊天保存的拒绝记录已清除。
依据 Cloudflare 官方说明:[Full (strict)](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/)、[创建 Cache Rule](https://developers.cloudflare.com/cache/how-to/cache-rules/create-dashboard/)、[规则顺序](https://developers.cloudflare.com/cache/how-to/cache-rules/order/)。上述内容是待执行配置,不是完成记录。
依据 Cloudflare 官方说明:[Full (strict)](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/)、[创建 Cache Rule](https://developers.cloudflare.com/cache/how-to/cache-rules/create-dashboard/)、[规则顺序](https://developers.cloudflare.com/cache/how-to/cache-rules/order/)。上述页面配置及本轮响应验收均已完成;这些请求来自生产服务器,不代表手机、TUN 或多地区验收。
宿主 Nginx 只信任 Cloudflare 官方网段的 CF-Connecting-IP,再向新容器发送清洗后的真实 IP。新容器仅信任本项目网桥 172.28.31.1。实际公网请求的 Cloudflare trace IP 与新网关日志已逐值比对一致,文档不保留个人 IP。
@ -51,11 +66,34 @@ dpl-cloudflare-ips.timer 每日宿主机时间 02:00 执行,随机延迟最多
原网关重启后运行正常,原订阅路由回归通过,容器 ID 保持原值;之后的新证书钩子及 IP 同步未再次重启原容器。不能据后续回归通过声称整个任务中原网关从未重启,或证明当时所有用户均无感。
## Cloudflare 扩展审查与后续复用
2026-10-11 用户进一步要求核查交接清单本身的遗漏,尤其是 Cloudflare 调优。本轮从订阅 API 的使用场景重建检查项,不以既有待办清单作为完整性证明。详见 [Cloudflare 审查记录](CLOUD_FLARE_AUDIT.md)。实际确认严格 TLS、缓存隔离、边缘证书、HTTP/2、HTTP/3、TLS 1.3、IPv6、真实 IP 请求头保留、Worker/转换规则和安全挑战状态;后续按追加授权实施了客户端兼容及原机场配置。
后续接入或调整 c2 时,必须同时核对:有效订阅及 UA/压缩格式、缓存规则顺序与响应头、Bot Fight/Under Attack/BIC、Pseudo IPv4 与删除访问者 IP 的托管转换、Worker 路由与规则覆盖、边缘/源站证书和续期、共享网关 IP 同步。不要把“HTTPS 200”或一份旧清单当成这些检查已完成。HTML/JS/图片优化按钮和缓存付费产品不作为订阅接口的默认调优步骤。
原机场 fu101.gembox-sub.com 的唯一活动 Cache Rule 也覆盖整个 gembox-sub.com 区域;新增仅匹配 fu101 的 SSL=Strict 规则,以及仅匹配其原生 `/sub/` 和兼容订阅 API 路径的 BIC=Off 规则。管理入口不在 BIC 例外中。原机场 sing-box/Clash 有效订阅、no-store、无缓存命中复验通过;空 UA 仍按原网关规则被拒绝。此次 CF 调优没有重启新旧容器,也未更改原上游、端口、卷或过滤。
BIC 是用户获知请求头筛查减少的风险后具体批准的兼容调整,不能认定为历史失败的根因。dpl 最低边缘 TLS 仍为 1.0,提升至 1.2 尚未实施;当前免费套餐普通设置覆盖区域全部代理主机,须核对兼容性,不得误当作只作用于 c2。
### 订阅域名更换
这两个专用订阅区域已经按用户要求使用全域 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 | 新增规则,不改原规则 |
@ -121,6 +159,7 @@ php scripts/node-onboarding/promote.php \
- 模板:从对应私密模板备份恢复该条内容并清理模板缓存,不替换整个设置表。
- 网关:只停新 Compose 项目、撤回新域名 vhost,检查后平滑重载。不能停止原项目、删原卷或改原上游;独立证书目录和定时器按本次备份回退。
- IP 同步:检查 dpl-cloudflare-ips.service、journal 和最后有效文件,不退化为信任所有来源。原 vhost 的追加授权变更可按专用备份独立回退。
- Cloudflare:缓存扩大在原规则 ID 上恢复旧主机条件;不删除原本已有的 dpl 规则。BIC/原机场 Strict 只停用本次新增规则,区域默认保留。具体名称、原条件备份与恢复顺序见 CLOUD_FLARE_AUDIT.md。
- 浏览器保存拒绝:检查 Codex Settings → Browser → Agent permissions 的 Browse 网站规则及覆盖规则;仍拦截则重新启动应用后复核。不能用其他控制方式绕过拒绝,公网结果不能替代账户设置核验。
## 节点登记及验收边界
@ -128,6 +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 - 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 和远程分流规则实际启动通过,面板已记录上下行流量。
@ -137,6 +177,6 @@ php scripts/node-onboarding/promote.php \
最终分段复核:本机与已测通网关的入口 DNS 均为 199.30.91.63,本机直连 64.44.115.18:24443 的严格 TLSv1.3 握手成功,但本机到中转 47101 仍为 TLS EOF。现有后台只有隧道 17517,新增表单未提供备用监听 IP;仅查看后取消,没有创建额外规则。用户确认本机开启代理,随后明确要求跳过本地连通测试,已停止这一排查。本地结果记为跳过且原因未定位,不再作为本次交付阻塞项,也不记为通过;用户网络可达性没有保证。
仍待完成:Cloudflare Full (strict) 后验及显式缓存规则;工具仍报告 dash.cloudflare.com 被保存的拒绝权限拦截,用户需解除实际规则或重启应用后再核验。本地连通按用户要求跳过;GUI/手机导入、系统 TUN、多地区、超额及多设备并发不在已执行验收范围内。
Cloudflare 页面和最终规则保存后的有效订阅复核已完成。全域缓存绕过、两个机场订阅路径 BIC 例外及原机场严格回源已实施;提高 dpl TLS 下限、更频繁 IP 同步/告警仍为可选未实施项,见 CLOUD_FLARE_AUDIT.md。当前聊天已正常访问,源聊天权限是否恢复未核验。本地连通按用户要求跳过;GUI/手机导入、系统 TUN、多地区、超额及多设备并发不在已执行验收范围内。
后续可直接给维护代理:“按 docs/maintenance/NODE_ONBOARDING.md 接入 dpl 的下一节点,先填参数单、复核共享资源和证书钩子,只新增,不动原机场;完整订阅实测后再开放指定套餐,更新登记与 STATUS。”本文不启动聊天结束后的自动维护。

View File

@ -20,7 +20,9 @@
| DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 |
| TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 |
| STATUS.md | 最近验证基线、已知限制与待验收项目 |
| CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 |
| NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 |
| GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 |
这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。

View File

@ -2,6 +2,30 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## 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 中转后台反代
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 反代表维护。
两个新 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 完成。
@ -113,3 +137,33 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次
尚未完成:Cloudflare Full (strict) 提交后的账户状态复核、显式订阅缓存绕过规则。dash.cloudflare.com 被浏览器保存拒绝持续拦截;用户多次修改设置后实际重试仍失败,需要清除覆盖规则或重新启动应用后再核对。公网 HTTPS 正常和 no-store 不能代替这些账户检查,整体任务尚未全部完成。
最终分段补查:本机与网关解析入口均为 199.30.91.63,本机直连落地 24443 严格 TLSv1.3 成功,但本机至入口 47101 仍在 TLS 握手阶段 EOF。已查看新增规则表单,仅有隧道 17517 且无备用监听 IP,取消未保存;没有修改原规则或创建诊断规则。网关环境的完整实际订阅已测通,不代表本机或所有用户网络已通过。用户确认本机开启代理,随后明确要求跳过本地连通测试;已停止该排查,标记为跳过而非通过,不再作为本次交付阻塞项。Cloudflare 两项仍待完成,准确配置与复核步骤已写入 NODE_ONBOARDING.md。
## 2026-10-11 Cloudflare 遗漏项复核
用户要求核对此前权限阻塞是否导致必要操作遗漏。本聊天通过正常浏览器成功访问并直接确认 Full (strict)、c2 唯一活动的缓存绕过规则(顺序 1,名称 Bypass cache - c2.deeplink-subscribe.com)、c2 橙云及 us01 DNS only。规则总览没有配置规则、源站规则或标头转换规则,旧式页面规则为零。缓存规则由用户另行创建的配置聊天先前部署,本轮没有重复创建或修改线上设置。
上文的 Cloudflare 两项“仍受权限阻塞、未完成”是历史状态,现已由上述现场核验替代;NODE_ONBOARDING 已同步。本聊天访问成功不表示源聊天旧拒绝记录已清除。尚未完成:显式规则部署后使用有效订阅重新检查 private/no-store、无 HIT 或 Age;先前响应记录不能代替本次验收。本轮没有读取正式用户订阅令牌、重启服务或恢复临时用户。服务器真实 IP、官方网段同步及证书续期沿用历史验收,本轮未重新核验。用户指定的本地连通测试仍为跳过;手机/TUN、多地区、超额、多设备和 IP 同步故障注入均未执行。
## 2026-10-11 从原始目标重建 Cloudflare 调优检查
用户指出交接清单本身可能遗漏,并强调 Cloudflare 配置和调优。本轮独立重建检查项,读区域 DNS、边缘证书、SSL/TLS、规则、网络、安全、协议/内容优化及 Worker 页面;经白色跳板只读复核网关、节点与面板。正式有效订阅(令牌仅进程内存使用)连续标准路径请求、短路径及 H2/gzip 请求通过:200、有效 JSON、DYNAMIC、private/no-store,无 Age/HIT/cf-mitigated。此前“规则部署后有效响应仍未验收”的状态已由本轮结果替代。
新增发现:BIC 仍全域开启且无 c2 主机例外,最低边缘 TLS 仍为 1.0。二者是可评估的兼容性/安全调优,尚未实施;不能从现状认定它们由权限阻塞造成或是旧地区失败根因。HTTP/2、HTTP/3、TLS 1.3、IPv6 均已开;Pseudo IPv4 与移除访问者 IP 托管转换关闭;Bot Fight、Under Attack 关闭,无 Worker 路由或相关改写规则。官方 IP 信任文件当前 22 段与官方下载一致,定时器 active,但频率为每日、无主动失败告警、未故障注入。完整表格与补充调优方案见 CLOUD_FLARE_AUDIT.md,后续接入清单已扩展。
本轮没有修改 Cloudflare/线上数据库/服务,没有重启或产生正式用户代理流量。文档本地修改尚未提交/推送/发布。原网关历史签发期间一次重启的记录继续保留;当前运行和隔离复核不能覆盖该历史事实。GUI/TUN、多地区、IPv6 端到端和 H3 等未执行,用户指定本地连通测试继续跳过。
## 2026-10-11 两机场 Cloudflare 调优与订阅域名轮换
用户追加授权原机场一起调优,并在获知关闭 BIC 会减少一层请求头筛查后明确批准只针对两机场订阅路径操作。此前自动审批拒绝具体安全调整;获得具体批准后正常浏览器部署成功。上节“BIC 未实施”是历史阶段,现已由如下结果替代:
- dpl 新增 Subscription client compatibility - c2,精确 c2 主机的标准 API 与 /s/ 路径 BIC=Off,活动;其余路径、区域默认及源站过滤保留,区域 Full (strict) 不变。
- 原机场新增 Strict origin TLS - fu101.gembox-sub.com,仅该主机 SSL=Strict,活动、顺序 1;原区域 Full/自动模式不改。新增 Subscription client compatibility - fu101,仅原生 /sub/ 与兼容订阅 API 的 BIC=Off,活动、顺序 2。管理入口保留默认检查。
- 用户要求将来更换订阅前缀不再修改缓存:两个区域分别将唯一活动 Cache Rule 改为 Bypass cache - deeplink-subscribe.com (all hosts) 和 Bypass cache - gembox-sub.com (all hosts),所有传入请求(true)、绕过缓存、顺序 1。没有新增重复规则或修改浏览器 TTL。旧规则值已记录供精确回退。
最终保存后再次用正式有效订阅复验:dpl 标准路径连续两次与短路径均 200、有效 sing-box JSON、严格 AnyTLS 参数、private/no-store、DYNAMIC;原机场 /sub/ 的 sing-box JSON、Clash YAML 均 200、正确格式、no-store、DYNAMIC;均无 Age/HIT/cf-mitigated,gzip JSON 解码通过。额外空 UA 请求仍为 403,现场原网关 map 明确拒绝空 UA,本次未削弱该过滤。不是所有 UA 或多地区验收。
新旧四个网关/管理容器仍运行,ID/启动时间与本轮变更前一致,宿主 Nginx 语法通过;没有改原上游、卷、端口、服务代码或用户配置,没有重启或产生正式节点代理流量。原机场 DNS 仍为 fu101 A=65.99.193.129、已代理;IPv6 开、Pseudo IPv4 关,区域默认 BIC 开、Bot Fight/Precursor 关;H2/H3/TLS1.3 页面已启用,未证明实际 h2 回源或 H3 协商。
同一区域换子域名前缀会自动覆盖缓存,仍需同步 DNS、vhost、证书/续期、网关 Host、面板订阅 URL、精确主机 BIC/TLS 规则并有效订阅复验;跨新区域不自动覆盖。已同步 NODE_ONBOARDING 与 CLOUD_FLARE_AUDIT 的已实施状态、轮换步骤和回退方式。最低 TLS 收紧、更频繁 IP 同步和告警等可选项未实施;本地连通按用户要求继续跳过。截图与缓存原值记录位于 codex/output/cloudflare-audit-20261011/;文档 Git 状态以本轮交付记录为准。

View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Install only approved proxy files, backing up and reverting on Nginx failure."""
import argparse
import datetime
import hashlib
import importlib.util
import ipaddress
import json
import os
import pathlib
import re
import shutil
import subprocess
spec = importlib.util.spec_from_file_location('render', pathlib.Path(__file__).with_name('render.py'))
render = importlib.util.module_from_spec(spec)
spec.loader.exec_module(render)
def sha(data):
return hashlib.sha256(data).hexdigest()
def remove_legacy_wildcards(text):
spans = []
for m in re.finditer(r'(?m)^server\s*\{', text):
end, depth = m.end(), 1
while end < len(text) and depth:
depth += (text[end] == '{') - (text[end] == '}')
end += 1
block = text[m.start():end]
if re.search(r'(?m)^\s*server_name\s+\*\.tomshell\.com\.de\s*;', block):
spans.append((m.start(), end))
if len(spans) != 2:
raise RuntimeError('Expected exactly two old wildcard server blocks; refusing ambiguous edit')
for start, end in reversed(spans):
text = text[:start] + text[end:]
return text
def update_legacy_fuc_certificate(text, directory):
for directive, filename in [('ssl_certificate', 'fullchain.pem'), ('ssl_certificate_key', 'privkey.pem')]:
old = '/etc/letsencrypt/live/tomshell.com.de/' + filename
pattern = r'(?m)^(\s*' + directive + r'\s+)' + re.escape(old) + r'(\s*;)'
text, count = re.subn(pattern, lambda m: m[1] + directory + '/' + filename + m[2], text)
if count != 1:
raise RuntimeError('Expected exactly one old fuc certificate directive; refusing ambiguous edit')
return text
def main():
parser = argparse.ArgumentParser()
parser.add_argument('role', choices=['gateway', 'white'])
parser.add_argument('--stage', choices=['http', 'https'], required=True)
parser.add_argument('--skip-legacy', action='store_true')
parser.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de')
parser.add_argument('--expected-legacy-sha256')
parser.add_argument('--expected-fuc-sha256')
parser.add_argument('--white-ip', default='104.168.30.40')
args = parser.parse_args()
ipaddress.ip_address(args.white_ip)
if not re.fullmatch(r'/[A-Za-z0-9/._-]+', args.legacy_certificate):
parser.error('Invalid certificate directory')
if os.geteuid() != 0:
parser.error('Remote deployment requires root')
os.umask(0o077)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
state_path = pathlib.Path('/var/lib/gateway-admin-proxy/state.json')
# Workers need traversal to the ACME webroot; private state stays 0600.
state_path.parent.mkdir(parents=True, mode=0o711, exist_ok=True)
state_path.parent.chmod(0o711)
old_state = json.loads(state_path.read_text()) if state_path.exists() else {'files': {}}
if old_state.get('role', args.role) != args.role:
parser.error('Deployment role differs from existing state')
if args.role == 'gateway':
legacy = ''
if args.stage == 'https':
data = json.loads(subprocess.check_output(['docker', 'inspect', 'subscribe-admin'], text=True))[0]
values = dict(x.split('=', 1) for x in data['Config']['Env'] if '=' in x)
legacy = '/' + values['ADMIN_SECRET_PATH'].strip('/') + '/'
if not re.fullmatch(r'/[A-Za-z0-9_-]+/', legacy):
parser.error('Invalid existing admin prefix')
if 'location ^~ ' + legacy not in pathlib.Path('/etc/nginx/conf.d/g1.gembox-sub.com.conf').read_text():
parser.error('Legacy admin prefix disagrees with current vhost')
files = render.render_gateway(args.stage, legacy, args.white_ip)
else:
files = render.render_white(args.stage, args.legacy_certificate, args.skip_legacy)
changes = {pathlib.Path('/etc/nginx/conf.d') / name: data.encode() for name, data in files.items()}
if args.role == 'white' and args.stage == 'https' and not args.skip_legacy:
legacy_file = pathlib.Path('/etc/nginx/conf.d/master.tomshell.my.conf')
current = legacy_file.read_bytes()
if str(legacy_file) in old_state['files']:
if sha(current) != old_state['files'][str(legacy_file)]:
parser.error('Legacy file changed after previous deployment')
changes[legacy_file] = current
else:
if not args.expected_legacy_sha256 or sha(current) != args.expected_legacy_sha256:
parser.error('Legacy file needs an exact current hash before modifying wildcard blocks')
changes[legacy_file] = remove_legacy_wildcards(current.decode()).encode()
fuc_file = pathlib.Path('/etc/nginx/conf.d/fu.tomshell.my.conf')
current = fuc_file.read_bytes()
if str(fuc_file) in old_state['files']:
if sha(current) != old_state['files'][str(fuc_file)]:
parser.error('Legacy fuc file changed after previous deployment')
changes[fuc_file] = current
else:
if not args.expected_fuc_sha256 or sha(current) != args.expected_fuc_sha256:
parser.error('Legacy fuc certificate update needs an exact current file hash')
changes[fuc_file] = update_legacy_fuc_certificate(current.decode(), args.legacy_certificate).encode()
if args.stage == 'https':
for path, data in changes.items():
if path.name in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
continue
for cert in re.findall(rb'(?m)^\s*ssl_certificate\s+([^;]+);', data):
subprocess.run(['openssl', 'x509', '-in', cert.decode(), '-checkend', '86400', '-noout'],
check=True, capture_output=True)
if path.name == 'white-old-proxy.conf':
san = subprocess.check_output(['openssl', 'x509', '-in', cert.decode(), '-noout',
'-ext', 'subjectAltName'], text=True)
if 'DNS:*.tomshell.com.de' not in san or 'DNS:tomshell.com.de' not in san:
parser.error('Legacy proxy requires root and wildcard SANs, not a single-host certificate')
for path, data in changes.items():
if path.exists() and str(path) not in old_state['files']:
if path.name not in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
raise RuntimeError('Unmanaged file already exists: ' + str(path))
elif path.exists() and sha(path.read_bytes()) != old_state['files'].get(str(path)):
raise RuntimeError('Managed file changed outside this deployment: ' + str(path))
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
backup = pathlib.Path('/root/codex-backups/gateway-admin-proxy') / (timestamp + '-' + args.role)
backup.mkdir(parents=True, mode=0o700)
originals = {}
modes = {}
for path in changes:
originals[str(path)] = path.read_bytes() if path.exists() else None
modes[str(path)] = path.stat().st_mode & 0o777 if path.exists() else 0o644
if path.exists():
shutil.copy2(path, backup / path.name)
manifest = {'role': args.role, 'stage': args.stage,
'before': {p: sha(data) if data is not None else None for p, data in originals.items()},
'after': {str(p): sha(data) for p, data in changes.items()}, 'modes': modes}
(backup / 'manifest.json').write_text(json.dumps(manifest, indent=2))
try:
for path, data in changes.items():
temp = path.with_suffix('.conf.new')
temp.write_bytes(data)
temp.chmod(modes[str(path)])
os.replace(temp, path)
pathlib.Path(render.ACME).mkdir(parents=True, mode=0o755, exist_ok=True)
pathlib.Path(render.ACME).chmod(0o755)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
except Exception:
for path, data in originals.items():
target = pathlib.Path(path)
if data is None:
target.unlink(missing_ok=True)
else:
target.write_bytes(data)
target.chmod(modes[path])
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
raise
old_state.update({'role': args.role, 'stage': args.stage, 'backup': str(backup)})
old_state['files'].update(manifest['after'])
state_new = state_path.with_suffix('.json.new')
state_new.write_text(json.dumps(old_state, indent=2))
os.replace(state_new, state_path)
print(json.dumps({'role': args.role, 'stage': args.stage, 'backup': str(backup),
'installed': [str(x) for x in changes], 'nginx_valid': True, 'reloaded': True}))
if __name__ == '__main__':
main()

View File

@ -0,0 +1,6 @@
[Unit]
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

View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily isolated gateway admin certificate renewal
[Timer]
OnCalendar=*-*-* 03:40:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Install the public CF certificate only after matching the server-held key and SANs."""
import argparse
import os
import pathlib
import subprocess
p = argparse.ArgumentParser()
p.add_argument('domain', choices=['tomshell.online', 'tomtojapan.online'])
p.add_argument('certificate', type=pathlib.Path)
a = p.parse_args()
os.umask(0o077)
root = pathlib.Path('/etc/white-proxy-origin') / a.domain
key = root / 'privatekey.pem'
if not key.exists():
p.error('Generate the key and CSR on the white machine first')
subprocess.run(['openssl', 'x509', '-in', str(a.certificate), '-checkend', '86400', '-noout'],
check=True, capture_output=True)
san = subprocess.check_output(['openssl', 'x509', '-in', str(a.certificate), '-noout', '-ext',
'subjectAltName'], text=True)
if 'DNS:*.' + a.domain not in san or 'DNS:' + a.domain not in san:
p.error('Certificate does not contain the expected root and wildcard names')
cert_pub = subprocess.check_output(['openssl', 'x509', '-in', str(a.certificate), '-noout', '-pubkey'])
key_pub = subprocess.check_output(['openssl', 'pkey', '-in', str(key), '-pubout'])
if cert_pub.strip() != key_pub.strip():
p.error('Certificate public key differs from the server-held private key')
target = root / 'fullchain.pem'
if target.exists() and target.read_bytes() != a.certificate.read_bytes():
p.error('Different certificate already exists; take an explicit backup before replacement')
temporary = root / 'fullchain.pem.new'
temporary.write_bytes(a.certificate.read_bytes())
temporary.chmod(0o600)
os.replace(temporary, target)
print('Installed matching public origin certificate: ' + a.domain + '; private key retained on server')

View File

@ -0,0 +1,17 @@
#!/bin/sh
# Reuse the already accepted gateway LE account without running global hooks.
set -eu
umask 077
test "$(id -u)" = 0
test -d /etc/dpl-letsencrypt/accounts/acme-v02.api.letsencrypt.org
install -d -m 700 /etc/gateway-admin-letsencrypt /var/lib/gateway-admin-proxy/certbot /var/log/gateway-admin-certbot
if [ ! -d /etc/gateway-admin-letsencrypt/accounts ]; then
cp -a /etc/dpl-letsencrypt/accounts /etc/gateway-admin-letsencrypt/accounts
fi
exec certbot certonly --non-interactive --webroot \
--webroot-path /var/lib/gateway-admin-proxy/acme \
--config-dir /etc/gateway-admin-letsencrypt \
--work-dir /var/lib/gateway-admin-proxy/certbot \
--logs-dir /var/log/gateway-admin-certbot \
--no-directory-hooks --cert-name gateway-admin \
-d getway-fuc.gemcook.net -d getway-dpl.gemcook.net

View File

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
umask 077
test "$(id -u)" = 0
test -d /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org
install -d -m 700 /etc/white-proxy-letsencrypt /var/lib/white-proxy-certbot /var/log/white-proxy-certbot
if [ ! -d /etc/white-proxy-letsencrypt/accounts ]; then
cp -a /etc/letsencrypt/accounts /etc/white-proxy-letsencrypt/accounts
fi
exec certbot certonly --non-interactive --manual --preferred-challenges dns \
--manual-auth-hook 'python3 /opt/gateway-admin-proxy/provision/legacy-dns-hook.py auth' \
--manual-cleanup-hook 'python3 /opt/gateway-admin-proxy/provision/legacy-dns-hook.py cleanup' \
--config-dir /etc/white-proxy-letsencrypt \
--work-dir /var/lib/white-proxy-certbot \
--logs-dir /var/log/white-proxy-certbot \
--no-directory-hooks --cert-name tomshell.com.de \
-d tomshell.com.de -d '*.tomshell.com.de'

View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Certbot DNS hook: only one approved delegation, exact TXT IDs, IPv4 API."""
import hashlib
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import time
import urllib.parse
CONFIG = pathlib.Path('/etc/white-proxy-dns/config.json')
TOKEN = pathlib.Path('/etc/white-proxy-dns/cf-token')
STATE = pathlib.Path('/var/lib/white-proxy-dns')
DOMAIN = 'tomshell.com.de'
TARGET = 'old-com-de.acme.tomshell.online'
AUTH_NS = ('jaziel.ns.cloudflare.com', 'nena.ns.cloudflare.com')
def check_private(path):
if path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
raise RuntimeError('DNS credentials must be root-owned and private')
def api(method, suffix, payload=None):
check_private(TOKEN)
check_private(CONFIG)
cfg = json.loads(CONFIG.read_text())
zone = cfg['zone_id']
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_-]{20,128}', token):
raise RuntimeError('Invalid token format')
with tempfile.TemporaryDirectory(prefix='acme-api-', dir=STATE) as folder:
body = pathlib.Path(folder) / 'request.json'
args = ['curl', '-4', '--silent', '--show-error', '--fail-with-body',
'--connect-timeout', '10', '--max-time', '30', '--config', '-',
'--request', method,
'https://api.cloudflare.com/client/v4/zones/' + zone + '/dns_records' + suffix]
if payload is not None:
body.write_text(json.dumps(payload))
args.extend(['--data-binary', '@' + str(body)])
config = 'header = "Authorization: Bearer ' + token + '"\nheader = "Content-Type: application/json"\n'
result = subprocess.run(args, input=config, capture_output=True, text=True)
if result.returncode:
raise RuntimeError('Cloudflare DNS API request failed; response intentionally not logged')
value = json.loads(result.stdout)
if not value.get('success'):
raise RuntimeError('Cloudflare DNS API rejected request')
return value['result']
def dns(name, kind, server):
return subprocess.check_output(['dig', '-4', '+time=3', '+tries=1', '+short',
'@' + server, name, kind], text=True).strip()
def matches(record, validation):
return record.get('type') == 'TXT' and record.get('name') == TARGET and record.get('content') == validation
def main():
if os.geteuid() != 0 or len(sys.argv) != 2 or sys.argv[1] not in ('auth', 'cleanup'):
raise RuntimeError('Root auth/cleanup mode required')
os.umask(0o077)
STATE.mkdir(parents=True, mode=0o700, exist_ok=True)
domain = os.environ['CERTBOT_DOMAIN'].removeprefix('*.')
validation = os.environ['CERTBOT_VALIDATION']
if domain != DOMAIN or not re.fullmatch(r'[A-Za-z0-9_-]{43}', validation):
raise RuntimeError('Challenge outside approved domain')
state = STATE / (hashlib.sha256(validation.encode()).hexdigest() + '.json')
if sys.argv[1] == 'cleanup':
if not state.exists():
return
record_id = json.loads(state.read_text())['id']
if not re.fullmatch(r'[a-f0-9]{32}', record_id):
raise RuntimeError('Invalid TXT record ID')
record = api('GET', '/' + record_id)
if not matches(record, validation):
raise RuntimeError('TXT record changed; refusing deletion')
api('DELETE', '/' + record_id)
state.unlink()
return
for ns in ('ns1.dns-parking.com', 'ns2.dns-parking.com'):
if dns('_acme-challenge.' + DOMAIN, 'CNAME', ns).rstrip('.') != TARGET:
raise RuntimeError('Approved CNAME delegation not published')
if state.exists():
raise RuntimeError('Existing challenge state; inspect before retry')
record = api('POST', '', {'type': 'TXT', 'name': TARGET, 'content': validation, 'ttl': 60,
'comment': 'Isolated legacy white-proxy ACME challenge'})
state.write_text(json.dumps({'id': record['id']}))
try:
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
if all('"' + validation + '"' in dns(TARGET, 'TXT', ns).splitlines() for ns in AUTH_NS):
# Also allow recursors to replace a cached negative answer before validation.
for _ in range(24):
if all('"' + validation + '"' in dns('_acme-challenge.' + DOMAIN, 'TXT', ns).splitlines()
for ns in ('1.1.1.1', '8.8.8.8')):
return
time.sleep(5)
raise RuntimeError('Challenge not visible through public DNS recursors')
time.sleep(5)
raise RuntimeError('Challenge propagation timed out')
except Exception:
if matches(api('GET', '/' + record['id']), validation):
api('DELETE', '/' + record['id'])
state.unlink()
raise
if __name__ == '__main__':
main()

View File

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Render the user-approved admin hostnames and bounded white-machine proxies."""
import argparse
import ipaddress
import os
import pathlib
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):
result = f''' proxy_pass https://{upstream}$request_uri;
proxy_ssl_server_name on;
proxy_ssl_name {tls_name};
proxy_ssl_verify on;
proxy_ssl_verify_depth 4;
proxy_ssl_trusted_certificate {CA};
proxy_http_version 1.1;
proxy_set_header Host {host};
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $gateway_admin_proxy_connection;
proxy_connect_timeout 15s;
proxy_read_timeout 120s;
proxy_cache_bypass 1;
proxy_no_cache 1;
'''
if alias:
result += f''' proxy_set_header X-Forwarded-Host $host;
proxy_set_header Origin $gateway_admin_origin;
proxy_set_header Referer $gateway_admin_referer;
proxy_redirect https://{host}/ https://$host/;
proxy_cookie_domain {host} $host;
'''
else:
result += ' proxy_set_header X-Forwarded-Host $host;\n'
return result
def http_server(names, redirect=True):
fallback = 'return 301 https://$host$request_uri;' if redirect else 'return 404;'
return f'''server {{
listen 80;
listen [::]:80;
server_name {names};
{private_logs()}
location ^~ /.well-known/acme-challenge/ {{
root {ACME};
try_files $uri =404;
}}
location / {{ {fallback} }}
}}
'''
def body_rewrites(source, alias, key):
# Rewrite URL hosts, preserving plain brand names such as DP-LINK.NET.
rules = f''' sub_filter_once off;
sub_filter_types text/css application/javascript application/json;
sub_filter 'https://$gateway_admin_origin_host' 'https://$host';
sub_filter 'http://$gateway_admin_origin_host' 'https://$host';
sub_filter '//$gateway_admin_origin_host' '//$host';
'''
if key in ('new', 'old'):
rules += f''' sub_filter 'https://{source}' 'https://{alias}';
sub_filter 'http://{source}' 'https://{alias}';
sub_filter '//{source}' '//{alias}';
sub_filter '.{source}/' '.{alias}/';
'''
elif key == 'fuc-site':
rules += ''' sub_filter 'https://福云.com' 'https://fuc.tomtojapan.online';
sub_filter 'http://福云.com' 'https://fuc.tomtojapan.online';
sub_filter '//福云.com' '//fuc.tomtojapan.online';
'''
return rules
def https_server(names, certificate, content, cloudflare=False, white_ip=None, key_name='privatekey.pem'):
trusted = f' include {CF_TRUST};\n' if cloudflare else ''
if white_ip:
trusted += f''' set_real_ip_from {white_ip};
real_ip_header X-Forwarded-For;
real_ip_recursive on;
'''
return f'''server {{
listen 443 ssl;
listen [::]:443 ssl;
server_name {names};
ssl_certificate {certificate}/fullchain.pem;
ssl_certificate_key {certificate}/{key_name};
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 20m;
{private_logs()}
{trusted}{content}
}}
'''
def render_gateway(stage, legacy_path, white_ip):
files = {'00-gateway-admin-proxy-maps.conf': HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection {
default upgrade;
'' close;
}
'''}
for kind, port, tls in [('fuc', 64444, 'fu101.gembox-sub.com'),
('dpl', 16444, 'c2.deeplink-subscribe.com')]:
name = f'getway-{kind}.gemcook.net'
content = http_server(name, stage == 'https')
if stage == 'https':
if kind == 'fuc':
location = f''' location = / {{ return 302 {legacy_path}; }}
location ^~ {legacy_path} {{
{proxy_lines('127.0.0.1:'+str(port), tls, name)} }}
location / {{ return 404; }}
'''
else:
location = f''' location / {{
{proxy_lines('127.0.0.1:'+str(port), tls, name)} }}
'''
# The admin upstreams retain their own certificates and authentication.
location += ' add_header Cache-Control "private, no-store" always;\n'
content += https_server(name, '/etc/gateway-admin-letsencrypt/live/gateway-admin',
location, white_ip=white_ip, key_name='privkey.pem')
files[f'gateway-admin-{kind}.conf'] = content
return files
def render_white(stage, legacy_certificate, skip_legacy=False):
files = {}
maps = HEALTH_MAP + '''map $http_upgrade $gateway_admin_proxy_connection {
default upgrade;
'' close;
}
map $host $gateway_admin_origin_host {
tomshell.online gemcook.net;
~^(?<gnew>[a-z0-9-]+)\\.tomshell\\.online$ $gnew.gemcook.net;
tomshell.com.de diaodu.us;
~^(?<gold>[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 '';
}
map "$host|$http_origin" $gateway_admin_origin {
"~^(?<grequest_origin>[a-z0-9.-]+)\\|https://\\k<grequest_origin>$" https://$gateway_admin_origin_host;
default $http_origin;
}
map "$host|$http_referer" $gateway_admin_referer {
"~^(?<grequest_referer>[a-z0-9.-]+)\\|https://\\k<grequest_referer>(?<grefpath>/.*)$" https://$gateway_admin_origin_host$grefpath;
default $http_referer;
}
'''
files['00-gateway-admin-proxy-maps.conf'] = maps
specs = [
('new', 'tomshell.online *.tomshell.online', '/etc/white-proxy-origin/tomshell.online',
'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',
'xn--9kqy16h.com', 'fuc.tomtojapan.online', True),
]
for key, names, cert, source, alias, cf in specs:
if key == 'old' and skip_legacy:
continue
data = http_server(names, stage == 'https')
if stage == 'https':
extras = ''
if key in ('new', 'old'):
source_regex = re.escape(source)
extras = f''' proxy_redirect ~^https?://([a-z0-9-]+\\.)?{source_regex}(/.*)$ https://$1{alias}$2;
proxy_cookie_domain {source} {alias};
'''
location = f''' resolver 1.1.1.1 1.0.0.1 valid=300s ipv6=off;
resolver_timeout 5s;
if ($gateway_admin_origin_host = '') {{ return 444; }}
location / {{
{proxy_lines('$gateway_admin_origin_host', '$gateway_admin_origin_host', '$gateway_admin_origin_host', alias)}{extras}
proxy_set_header Accept-Encoding "";
{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
return files
def main():
p = argparse.ArgumentParser()
p.add_argument('role', choices=['gateway', 'white'])
p.add_argument('--stage', choices=['http', 'https'], required=True)
p.add_argument('--output', type=pathlib.Path, required=True)
p.add_argument('--white-ip', default='104.168.30.40')
p.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de')
p.add_argument('--skip-legacy', action='store_true', help='Leave legacy proxy untouched until its wildcard certificate is ready')
a = p.parse_args()
ipaddress.ip_address(a.white_ip)
if not re.fullmatch(r'/[A-Za-z0-9/._-]+', a.legacy_certificate):
p.error('Invalid certificate directory')
legacy_path = os.environ.get('LEGACY_ADMIN_PATH', '')
if a.role == 'gateway' and a.stage == 'https' and not re.fullmatch(r'/[A-Za-z0-9_-]+/', legacy_path):
p.error('Supply the existing legacy admin prefix through LEGACY_ADMIN_PATH')
files = render_gateway(a.stage, legacy_path, a.white_ip) if a.role == 'gateway' else render_white(a.stage, a.legacy_certificate, a.skip_legacy)
a.output.mkdir(parents=True, exist_ok=True)
for name, content in files.items():
(a.output / name).write_text(content)
print(f'Rendered {len(files)} {a.role}/{a.stage} files; no service configuration changed')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,9 @@
#!/bin/sh
set -eu
umask 077
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 \
--no-directory-hooks \
--deploy-hook '/usr/sbin/nginx -t && /bin/systemctl reload nginx'

View File

@ -0,0 +1,9 @@
#!/bin/sh
set -eu
umask 077
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 \
--no-directory-hooks \
--deploy-hook '/usr/sbin/nginx -t && /bin/systemctl reload nginx'

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Synchronize only the new white proxy vhosts' Cloudflare trust include."""
import ipaddress
import os
import pathlib
import subprocess
target = pathlib.Path('/etc/nginx/white-proxy-cloudflare-realip.conf')
ranges = []
for version in (4, 6):
p = subprocess.run(['curl', '-4', '--fail', '--silent', '--show-error', '--max-time', '30',
f'https://www.cloudflare.com/ips-v{version}'],
check=True, capture_output=True, text=True)
nets = [ipaddress.ip_network(x.strip(), strict=True) for x in p.stdout.splitlines() if x.strip()]
if not ((5 if version == 4 else 2) <= len(nets) <= 100):
raise RuntimeError('Unexpected Cloudflare range count')
if any(n.version != version or n.prefixlen < (8 if version == 4 else 16) for n in nets):
raise RuntimeError('Invalid or overbroad Cloudflare trust range')
ranges.extend(str(n) for n in nets)
if len(ranges) != len(set(ranges)):
raise RuntimeError('Duplicate Cloudflare ranges')
data = ('# Managed new white proxy vhosts only; Cloudflare official ranges.\n' +
''.join(f'set_real_ip_from {n};\n' for n in ranges) +
'real_ip_header CF-Connecting-IP;\nreal_ip_recursive off;\n').encode()
old = target.read_bytes() if target.exists() else None
if old == data:
print('White proxy Cloudflare trust unchanged')
raise SystemExit(0)
temporary = target.with_suffix('.conf.new')
temporary.write_bytes(data)
temporary.chmod(0o644)
os.replace(temporary, target)
try:
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
except Exception:
if old is None:
target.unlink()
else:
temporary.write_bytes(old)
os.replace(temporary, target)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
raise
print(f'White proxy Cloudflare trust synchronized: {len(ranges)} ranges')

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Exercise real Nginx mapping and Origin/Referer isolation using localhost only."""
import importlib.util
import pathlib
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
spec = importlib.util.spec_from_file_location('render', pathlib.Path(__file__).with_name('render.py'))
render = importlib.util.module_from_spec(spec)
spec.loader.exec_module(render)
nginx = shutil.which('nginx')
if not nginx:
raise SystemExit('Nginx is required for this isolated integration check')
with tempfile.TemporaryDirectory(prefix='gateway-proxy-maps-') as folder:
root = pathlib.Path(folder)
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
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'),
('old', 'nasa.tomshell.com.de', 'diaodu.us', 'tomshell.com.de', 'old', 'DIAODU.US'),
]
locations = ''
for name, host, source, alias, key, brand in bodies:
upstream = ('getway-dpl.gemcook.net' if key == 'new' else
'nasa.diaodu.us' if key == 'old' else source)
body = f'<title>{brand}</title><a href="https://{upstream}/api?q=1">link</a><img src="//{upstream}/logo">'
locations += f''' location /body/{name} {{
default_type text/html;
{render.body_rewrites(source, alias, key)}
return 200 '{body}';
}}
'''
config = root / 'nginx.conf'
config.write_text(f'''daemon off;
pid {root}/nginx.pid;
error_log {root}/error.log;
events {{ worker_connections 64; }}
http {{
access_log off;
{maps}
server {{
listen 127.0.0.1:{port};
location / {{
return 200 "$gateway_admin_origin_host|$gateway_admin_origin|$gateway_admin_referer";
}}
{locations}
}}
}}
''')
subprocess.run([nginx, '-t', '-c', str(config), '-p', folder], check=True,
capture_output=True, text=True)
process = subprocess.Popen([nginx, '-c', str(config), '-p', folder],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
try:
for _ in range(40):
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
if process.poll() is not None:
raise RuntimeError(process.stderr.read())
time.sleep(.05)
cases = [
('tomshell.online', 'gemcook.net'),
('getway-fuc.tomshell.online', 'getway-fuc.gemcook.net'),
('getway-dpl.tomshell.online', 'getway-dpl.gemcook.net'),
('nasa.tomshell.com.de', 'nasa.diaodu.us'),
('tomshell.com.de', 'diaodu.us'),
('dpl.tomtojapan.online', 'dp-link.net'),
('fuc.tomtojapan.online', 'xn--9kqy16h.com'),
('not-registered.example', ''),
('two.levels.tomshell.online', ''),
]
assertions = 0
for host, upstream in cases:
for origin in ['https://' + host, 'https://other.tomshell.online', 'https://outside.example']:
referer = origin + '/example?keep=1'
req = urllib.request.Request('http://127.0.0.1:' + str(port) + '/',
headers={'Host': host, 'Origin': origin, 'Referer': referer})
with urllib.request.urlopen(req, timeout=3) as response:
actual = response.read().decode().split('|')
assert actual[0] == upstream, (host, actual[0], upstream)
own = origin == 'https://' + host
assert actual[1] == ('https://' + upstream if own else origin), (host, origin, actual[1])
assert actual[2] == ('https://' + upstream + '/example?keep=1' if own else referer)
assertions += 3
for name, host, source, alias, key, brand in bodies:
req = urllib.request.Request(f'http://127.0.0.1:{port}/body/{name}', headers={'Host': host})
with urllib.request.urlopen(req, timeout=3) as response:
actual = response.read().decode()
assert f'<title>{brand}</title>' in actual, (name, actual)
assert f'https://{host}/api?q=1' in actual, (name, actual)
assert f'//{host}/logo' in actual, (name, actual)
assertions += 3
print(f'PASS: {assertions} real-Nginx map/header checks; localhost fixture only, no origin requests')
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()

View File

@ -0,0 +1,6 @@
[Unit]
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

View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily renewal check for legacy white proxy certificate
[Timer]
OnCalendar=*-*-* 04:10:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,5 @@
[Unit]
Description=Synchronize CF ranges for the new white proxy vhosts
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/gateway-admin-proxy/provision/sync-cf-ips.py

View File

@ -0,0 +1,8 @@
[Unit]
Description=Daily white proxy CF trust refresh
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target