From 7d89ec9d91ecc7aeaf506ae397ba0540a5d75244 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 14:21:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(infra/domains):=20=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E6=B1=A0=20+=20CDN=20=E5=89=8D=E7=BD=AE=20+=20=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E7=AB=AF=E7=82=B9=E5=88=86=E5=8F=91=20(tsk=5FNU9JuUwe?= =?UTF-8?q?HWMt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domains.md: 四组域名隔离登记 + 冷备池 ≥5 + 启用流程(不含身份信息) - cdn/terraform: Cloudflare 配置即代码(WAF/bot/速率限制/代理DNS/回源鉴权注入)+ 30min 重放 Runbook - server/internal/originauth: 回源鉴权中间件,非 CDN 网段或鉴权头不符一律 403,支持双值轮换 - tools/endpoint-signer: 离线 Ed25519 签名 CLI(端点 + 公告文档,单调版本防回滚,key_id 双公钥轮换) - tools/publish-mirrors: ≥3 镜像发布 + hash 一致性校验 + 故障转移取回 - CLIENT-CONTRACT.md: schema/验签/防回滚/合并/兜底链/channel 客户端契约 - 出站独立出口要求写入部署文档;私钥/token/身份信息一律不入库 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 15 + infra/domains/CLIENT-CONTRACT.md | 115 +++++++ infra/domains/README.md | 58 ++++ infra/domains/cdn/terraform/README.md | 51 ++++ infra/domains/cdn/terraform/dns.tf | 15 + infra/domains/cdn/terraform/origin.tf | 29 ++ infra/domains/cdn/terraform/outputs.tf | 9 + infra/domains/cdn/terraform/settings.tf | 26 ++ .../cdn/terraform/terraform.tfvars.example | 22 ++ infra/domains/cdn/terraform/variables.tf | 50 ++++ infra/domains/cdn/terraform/versions.tf | 14 + infra/domains/cdn/terraform/waf.tf | 67 +++++ infra/domains/domains.md | 84 ++++++ .../examples/endpoints.payload.example.json | 25 ++ infra/domains/examples/mirrors.example.json | 22 ++ .../examples/notices.payload.example.json | 14 + infra/domains/tools/README.md | 65 ++++ .../tools/cmd/endpoint-signer/flags.go | 9 + .../domains/tools/cmd/endpoint-signer/main.go | 282 ++++++++++++++++++ .../domains/tools/cmd/publish-mirrors/main.go | 171 +++++++++++ infra/domains/tools/go.mod | 3 + .../tools/internal/endpoint/endpoint.go | 189 ++++++++++++ .../tools/internal/endpoint/endpoint_test.go | 132 ++++++++ infra/domains/tools/internal/mirror/mirror.go | 174 +++++++++++ .../tools/internal/mirror/mirror_test.go | 113 +++++++ infra/domains/tools/internal/notice/notice.go | 77 +++++ infra/domains/tools/internal/sign/keys.go | 76 +++++ infra/domains/tools/internal/sign/sign.go | 213 +++++++++++++ .../domains/tools/internal/sign/sign_test.go | 141 +++++++++ server/internal/originauth/doc.go | 27 ++ server/internal/originauth/originauth.go | 149 +++++++++ server/internal/originauth/originauth_test.go | 135 +++++++++ 32 files changed, 2572 insertions(+) create mode 100644 infra/domains/CLIENT-CONTRACT.md create mode 100644 infra/domains/README.md create mode 100644 infra/domains/cdn/terraform/README.md create mode 100644 infra/domains/cdn/terraform/dns.tf create mode 100644 infra/domains/cdn/terraform/origin.tf create mode 100644 infra/domains/cdn/terraform/outputs.tf create mode 100644 infra/domains/cdn/terraform/settings.tf create mode 100644 infra/domains/cdn/terraform/terraform.tfvars.example create mode 100644 infra/domains/cdn/terraform/variables.tf create mode 100644 infra/domains/cdn/terraform/versions.tf create mode 100644 infra/domains/cdn/terraform/waf.tf create mode 100644 infra/domains/domains.md create mode 100644 infra/domains/examples/endpoints.payload.example.json create mode 100644 infra/domains/examples/mirrors.example.json create mode 100644 infra/domains/examples/notices.payload.example.json create mode 100644 infra/domains/tools/README.md create mode 100644 infra/domains/tools/cmd/endpoint-signer/flags.go create mode 100644 infra/domains/tools/cmd/endpoint-signer/main.go create mode 100644 infra/domains/tools/cmd/publish-mirrors/main.go create mode 100644 infra/domains/tools/go.mod create mode 100644 infra/domains/tools/internal/endpoint/endpoint.go create mode 100644 infra/domains/tools/internal/endpoint/endpoint_test.go create mode 100644 infra/domains/tools/internal/mirror/mirror.go create mode 100644 infra/domains/tools/internal/mirror/mirror_test.go create mode 100644 infra/domains/tools/internal/notice/notice.go create mode 100644 infra/domains/tools/internal/sign/keys.go create mode 100644 infra/domains/tools/internal/sign/sign.go create mode 100644 infra/domains/tools/internal/sign/sign_test.go create mode 100644 server/internal/originauth/doc.go create mode 100644 server/internal/originauth/originauth.go create mode 100644 server/internal/originauth/originauth_test.go diff --git a/.gitignore b/.gitignore index d0c339e..f0dcd57 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,21 @@ deploy/backup/backup.env *.age-private* age-keypair.txt +# infra/domains —— 签名私钥与发布产物绝不入库(公钥可入库) +*.ed25519.key +infra/domains/tools/_publish/ +infra/domains/tools/_smoke/ + +# Terraform —— state/tfvars/缓存绝不入库 +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +*.tfplan +tf.plan +crash.log + # 杂项 .DS_Store *.log diff --git a/infra/domains/CLIENT-CONTRACT.md b/infra/domains/CLIENT-CONTRACT.md new file mode 100644 index 0000000..dc4b635 --- /dev/null +++ b/infra/domains/CLIENT-CONTRACT.md @@ -0,0 +1,115 @@ +# 客户端联动契约(CLIENT-CONTRACT) + +> 数据源:本任务(`infra/domains/`)产出的签名端点文档与公告文档。 +> 消费方:app 服务层(doc/01 §2 服务层 / §4 客户端断网弹性、doc/06 §5 断网矩阵)。 +> 本文件是客户端与分发面之间的**接口约定**。改 schema / 验签规则 / 兜底链顺序须双方评审。 + +## 1. 文档类型与 schema + +两类文档共用同一个**签名信封**(`sign.Envelope`),只是 `payload` 不同。 + +### 1.1 信封(envelope) + +```jsonc +{ + "version": 3, // uint64,单调递增(防回滚) + "issued_at": "2026-06-13T00:00:00Z", // RFC3339 UTC,仅供展示/审计 + "key_id": "ed25519-2026q2", // 选择验签公钥 + "payload": { /* 见下 */ }, + "sig": "base64(ed25519 签名)" +} +``` + +- 签名覆盖 **去掉 `sig` 后** 的规范化 JSON(键按字典序排序、无多余空白、数组顺序保留)。客户端验签前必须用同样的规范化算法重算签名字节。规范化定义见 `tools/internal/sign`(`Canonicalize`)。 +- 客户端**只内置公钥**,绝不内置私钥(doc/06 §3)。 + +### 1.2 端点文档(`endpoints.v1.json`)payload + +```jsonc +{ + "api_domains": ["api-a.example.com", "api-b.example.net"], // 必填,≥1,已去重排序 + "mirror_urls": ["https://.../endpoints.v1.json", "..."], // 必填,≥1,下一份文档的镜像 + "emergency_nodes_hint": ["builtin"], // 可选,指向应急节点参数的不敏感线索(非参数本身) + "notice": { /* 见 1.3,可选内联公告 */ }, + "channel": "" // 可选,渠道标识(见 §6) +} +``` + +### 1.3 公告文档(`notices.v1.json`)payload(/v1/notices 的静态镜像版) + +```jsonc +{ + "notices": [ + { + "id": "2026-q2-mirror-move", // 必填,唯一 + "level": "warning", // info | warning | critical + "title_zh": "…", "title_en": "…", // 必填(双语) + "body_zh": "…", "body_en": "…", + "url": "https://…", // 可选 + "published_at": "2026-06-13T00:00:00Z" + } + ] +} +``` + +`notice` 既可内联进端点文档(随端点更新一起到达),也可作为独立 `notices.v1.json` 单独发布。两者 schema 相同。 + +## 2. 验签规则(客户端必须实现) + +客户端接受一份文档**当且仅当**全部通过: + +1. **签名有效**:`key_id` 命中内置公钥环中的某个公钥,且签名校验通过。 +2. **防回滚**:`version` **严格大于**客户端当前已信任的 `version`(首次为 0)。等于或更小一律拒绝(防降级 / 重放攻击)。 +3. **schema 校验**:端点文档 `api_domains≥1`、`mirror_urls≥1`、域名 / URL 合法;公告 `level` 合法、双语标题与 `published_at` 齐备、`id` 唯一。 + +任一不过 → 丢弃该文档、保留当前已信任版本、记审计。参考实现:`tools/internal/endpoint`(`VerifyDocument`)。 + +## 3. 公钥内置与轮换(双公钥过渡) + +- 客户端内置一个**公钥环** `key_id → 公钥`(可含多把)。 +- 轮换时(doc/06 §6 每季):先发版让客户端**同时内置新旧两把公钥**;签名侧切到新 `key_id` 出文档;旧 key 在所有存量客户端都已带新公钥后再退役。过渡期内新旧 `key_id` 的文档都能验签。 +- `key_id` 不命中公钥环 → 视为验签失败(拒绝)。 + +## 4. 合并策略 + +验签通过后并入本地端点池: + +- `api_domains`:与本地池**并集去重**;保留客户端「成功端点置顶记忆」(doc/01 §4)——历史可用端点优先,再追加新域名。 +- `mirror_urls`:替换为文档值(镜像列表以最新签名文档为准)。 +- `emergency_nodes_hint`:覆盖式更新。 +- `notice`:按 `id` 去重入公告位;`critical` 置顶。 +- 整体只有 `version` 更大的文档才会触发合并(见 §2.2)。 + +## 5. 客户端兜底链顺序(断网弹性,doc/01 §4 + doc/06 §5) + +服务层按下面顺序逐级递降,**每一层的信任锚都在安装包内离线可用**: + +``` +1. 缓存目录 ——上次成功的节点目录 + 端点池(带 version/时间戳),启动即用 +2. 内置端点池 ——安装包内置 api_domains + IP 直连兜底(按渠道分包,§6) +3. DoH 多提供方 ——域名解析优先 DoH 轮询,绕本地 DNS 污染 +4. IP 直连兜底 ——DoH 仍失败时用内置 IP 直连 +5. 签名端点更新 ——从 mirror_urls(≥3 镜像)拉新文档,验签(§2)后合并(§4),刷新端点池/域名 +6. 应急节点 ——emergency_nodes_hint 指向的内置应急参数,低速仅够拉新目录 +7. TG 频道 ——最终人工广播渠道,「联系我们」离线可见 +``` + +要点: + +- 上层失败才降到下层;任一层成功即停止下降并回升。 +- 第 5 层取文档时按 `mirror_urls` 顺序**故障转移**:单镜像失效仍从其余镜像取(与 `tools/publish-mirrors` 的 `Fetch` 行为一致)。 +- 第 1~4、6、7 层都不依赖「正在故障的那个组件」自身恢复。 + +## 6. channel 字段(敏感参数按渠道分包轮换) + +- `channel` 标识分发渠道(如应用商店 / 官网直装 / 某分发包)。空字符串 = 适用所有渠道。 +- 用途:敏感内置参数(IP 子集、应急节点)按渠道分包轮换,**泄露一个渠道不烧全部**(doc/06 §3 客户端 / doc/01 §4 注意框)。 +- 客户端行为:携带自身 `channel` 标识;只接受 `channel` 为空或与自身匹配的文档(端点合并时按此过滤)。 + +## 7. 域名被墙检测(接口需求,本任务不实现) + +复用 **#15 探针体系**做域名级 DNS 污染 / TCP 阻断探测(doc/05 §3)。客户端 / 控制面只需消费探测结论触发兜底链与启用流程;本任务只登记接口需求,不实现探针。详见 `domains.md` §4。 + +## 8. 评审 + +本契约需与**客户端任务负责人**评审对齐后冻结。冻结后任何 schema / 验签 / 兜底链顺序变更都要双方签字并升 `version` 语义说明。 diff --git a/infra/domains/README.md b/infra/domains/README.md new file mode 100644 index 0000000..4d98af2 --- /dev/null +++ b/infra/domains/README.md @@ -0,0 +1,58 @@ +# infra/domains — 域名池 + CDN 前置 + 签名端点分发 + +配置即代码 + 离线工具 + 契约文档,落实 doc/05(CDN 前置 / 源站隐藏 / 回源鉴权 / 四组域名 + 冷备 / 被封 Runbook)与 doc/06(Ed25519 签名 + 私钥离线 + 公钥内置 + 防回滚)。 + +> **本目录不含任何私钥 / token / 身份信息。** 私钥离线两地保存,token 由 rbw / CI Secret 注入,身份线索离线台账管理。 + +## 目录结构 + +| 路径 | 内容 | +|---|---| +| `domains.md` | 四组域名隔离登记 + 冷备池 + 启用流程(不含身份信息) | +| `cdn/terraform/` | Cloudflare 配置即代码:WAF + bot + 速率限制 + DNS + 回源鉴权;30min 重放 Runbook | +| `tools/endpoint-signer/` | 离线 Ed25519 签名 CLI:端点文档 + 公告文档 | +| `tools/publish-mirrors/` | 多镜像发布 + 一致性校验 + 故障转移取回 | +| `CLIENT-CONTRACT.md` | 客户端联动契约:schema / 验签 / 防回滚 / 合并 / 兜底链 / channel | +| `examples/` | 端点 / 公告 payload 与镜像配置示例 | +| 〔关联〕`../../server/internal/originauth/` | Go 回源鉴权中间件(非 CDN 网段或鉴权头不符一律 403) | + +## 数据流 + +``` +端点池 JSON ──(endpoint-signer, 离线)──> endpoints.v1.json (Ed25519 签名) + │ + (publish-mirrors) 推 ≥3 镜像 + 校验 hash 一致 + │ + CF Pages / GitHub<独立账号> / 对象存储 + │ + 客户端按 mirror_urls 故障转移取回 ──> 验签 + 防回滚 + schema ──> 合并进端点池 + │ + (CLIENT-CONTRACT.md 兜底链第 5 层) +``` + +## 回源鉴权与轮换(doc/05 §2 / doc/06 §6) + +- CDN(`cdn/terraform/origin.tf`)在回源请求上注入 `X-Origin-Auth: <随机值>`。 +- 源站 `server/internal/originauth` 中间件:**源 IP 不在 CDN 回源网段** 或 **鉴权头不符** → 403。 +- **季度轮换(双值过渡)**: + 1. 源站先把新值加为 `Config.Previous`→`Config.Current` 之外的受信值(即同时接受新旧两值)。 + 2. CDN 侧 `terraform apply` 把 `origin_auth_value` 切到新值。 + 3. 传播完成后源站去掉旧值。 + 期间新旧两值都通(验收要求)。 + +## 部署要求:出站请求走独立出口(写入部署文档) + +源站的**出站请求不得暴露源站地址**(doc/05 §2 源站 IP 隐藏)。以下出站必须走**独立出口**(独立 NAT / 出口代理 / 独立弹性 IP),与源站入站 IP 分离: + +- **支付 webhook 回调校验**(codes 模块回访发卡店 / 收款通道)。 +- **广告回执校验**(`/v1/ads/unlock` 等向广告平台核验回执)。 +- 任何源站主动发起的第三方 API 调用。 + +理由:对端日志或证书透明度 / DNS 历史一旦记录源站出站 IP,等于泄露源站。出口 IP 与入站源站 IP 解耦后,换源站(IaC 重建)不牵连出口,反之亦然。部署时在 04 章网络拓扑落实该独立出口。 + +## 校验 + +- Go 工具:`cd tools && go test ./...` +- 回源中间件:`cd ../../server && go test ./internal/originauth/` +- Terraform:`cd cdn/terraform && terraform fmt -check && terraform validate`(需真实账号才能 `apply`) +- 仓库扫描:无私钥、无 token、无身份信息(见各文件头部约束)。 diff --git a/infra/domains/cdn/terraform/README.md b/infra/domains/cdn/terraform/README.md new file mode 100644 index 0000000..3b050e4 --- /dev/null +++ b/infra/domains/cdn/terraform/README.md @@ -0,0 +1,51 @@ +# CDN 前置(Cloudflare)配置即代码 + +把一个域名在 30 分钟内接到 Cloudflare 之后:WAF + bot 管理 + 速率限制全开、DNS 代理回源、回源鉴权 header 注入。对应 doc/05 §2 与 §5「CDN 账号风险」Runbook。 + +## 资源清单 + +| 文件 | 资源 | 作用 | +|---|---|---| +| `versions.tf` | provider | 锁 `cloudflare ~> 4.40`;token 走 `CLOUDFLARE_API_TOKEN` 环境变量 | +| `settings.tf` | `cloudflare_zone_settings_override` | TLS1.3 / SSL strict / HSTS / 高安全级 | +| `dns.tf` | `cloudflare_record` | 代理(橙云)A 记录,隐藏源站 IP | +| `waf.tf` | `cloudflare_ruleset` ×2 + `cloudflare_bot_management` | Managed+OWASP WAF、`/v1/*` 速率限制、Bot 管理 | +| `origin.tf` | `cloudflare_ruleset`(late_transform) | 回源注入 `X-Origin-Auth` | +| `outputs.tf` | — | 代理后的主机名清单 | + +## 绝不入库的东西 + +- **API token**:`export CLOUDFLARE_API_TOKEN=...`(本机由 rbw 取;CI 由 Secret 注入)。 +- **回源鉴权值**:`export TF_VAR_origin_auth_value=...`。 +- `terraform.tfvars`、`*.tfstate`、`.terraform/`:均 gitignore。 + +## 30 分钟重放流程(CDN 账号风险应急) + +> 目标:旧 CDN 账号被封 / 要求实名时,在新账号 + 新(或同)域名上 30 分钟内重建全部防护并切流量。 + +```bash +# 0. 准备(离线已存):新账号 API token、zone_id、account_id、origin_ip、回源鉴权值 +export CLOUDFLARE_API_TOKEN=... # 新账号 token(rbw get) +export TF_VAR_origin_auth_value=... # 当前回源鉴权值 + +cd infra/domains/cdn/terraform +cp terraform.tfvars.example terraform.tfvars # 填 zone_id/account_id/origin_ip/api_hostnames + +# 1. 初始化 + 计划 + 应用(首次约 3–8 分钟) +terraform init +terraform plan -out tf.plan +terraform apply tf.plan + +# 2. 在新账号把域名 NS 指过去(注册商处改 NS),等待生效 +# 用多镜像签名端点更新把新 API 域名/镜像下发给客户端(见 ../../tools) + +# 3. 验证回源鉴权(应 200)与直连源站(应 403,见 server/internal/originauth) +``` + +把每次重放的**实测耗时**记到本目录 `REPLAY-LOG.md`(验收要求 ≤30min)。 + +## 注意 + +- `cloudflare_bot_management` 需要账号套餐支持(Pro+/Bot Management),免费账号把 `enable_bot_management=false`,改用面板 Bot Fight Mode。 +- WAF managed ruleset 的 `id` 为 Cloudflare 公开稳定 ID(Managed / OWASP)。 +- 本目录无法在本仓库 CI 内 `apply`(需真实账号);提交前至少 `terraform fmt -check && terraform validate`。 diff --git a/infra/domains/cdn/terraform/dns.tf b/infra/domains/cdn/terraform/dns.tf new file mode 100644 index 0000000..7db6330 --- /dev/null +++ b/infra/domains/cdn/terraform/dns.tf @@ -0,0 +1,15 @@ +# Proxied DNS records. proxied = true keeps the origin IP hidden behind the CDN +# (doc/05 §2 源站 IP 隐藏). The origin must NEVER have had an unproxied A record +# (historic DNS is the most common leak path). +resource "cloudflare_record" "api" { + for_each = toset(var.api_hostnames) + + zone_id = var.zone_id + name = each.value + type = "A" + content = var.origin_ip + proxied = true + ttl = 1 # 1 = automatic (required when proxied) + + comment = "pangolin api/distribution endpoint (managed by terraform)" +} diff --git a/infra/domains/cdn/terraform/origin.tf b/infra/domains/cdn/terraform/origin.tf new file mode 100644 index 0000000..ac7b7b8 --- /dev/null +++ b/infra/domains/cdn/terraform/origin.tf @@ -0,0 +1,29 @@ +# Inject the origin-auth header on every request the CDN forwards to the origin +# (doc/05 §2 回源鉴权). The Go originauth middleware rejects any request lacking +# this header or coming from a non-CDN source IP. +# +# The value is sensitive and supplied via TF_VAR_origin_auth_value; it is never +# stored in the repo. During quarterly rotation the origin accepts both the old +# and new value (Config.Previous), so apply the new value here first, then retire +# the old one on the origin after propagation. +resource "cloudflare_ruleset" "origin_auth" { + zone_id = var.zone_id + name = "pangolin-origin-auth" + kind = "zone" + phase = "http_request_late_transform" + + rules { + action = "rewrite" + description = "Inject origin-auth header on origin requests" + enabled = true + expression = "true" + + action_parameters { + headers { + name = var.origin_auth_header + operation = "set" + value = var.origin_auth_value + } + } + } +} diff --git a/infra/domains/cdn/terraform/outputs.tf b/infra/domains/cdn/terraform/outputs.tf new file mode 100644 index 0000000..2dda342 --- /dev/null +++ b/infra/domains/cdn/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "proxied_hostnames" { + description = "Hostnames now proxied through Cloudflare." + value = [for r in cloudflare_record.api : r.name] +} + +output "origin_auth_header" { + description = "Header name the origin must enforce (value is secret, not exported)." + value = var.origin_auth_header +} diff --git a/infra/domains/cdn/terraform/settings.tf b/infra/domains/cdn/terraform/settings.tf new file mode 100644 index 0000000..917d966 --- /dev/null +++ b/infra/domains/cdn/terraform/settings.tf @@ -0,0 +1,26 @@ +# Zone-wide security/TLS baseline (doc/05 §2 常规 Web 安全基线). +resource "cloudflare_zone_settings_override" "this" { + zone_id = var.zone_id + + settings { + ssl = "strict" # full (strict): verify origin cert + min_tls_version = "1.2" + tls_1_3 = "on" + always_use_https = "on" + automatic_https_rewrites = "on" + opportunistic_encryption = "on" + brotli = "on" + security_level = "high" + challenge_ttl = 1800 + browser_check = "on" + + # HSTS (doc/05 §2 全站 HSTS). + security_header { + enabled = true + max_age = 31536000 + include_subdomains = true + preload = true + nosniff = true + } + } +} diff --git a/infra/domains/cdn/terraform/terraform.tfvars.example b/infra/domains/cdn/terraform/terraform.tfvars.example new file mode 100644 index 0000000..42ba5f9 --- /dev/null +++ b/infra/domains/cdn/terraform/terraform.tfvars.example @@ -0,0 +1,22 @@ +# Copy to terraform.tfvars and fill in. terraform.tfvars is gitignored. +# NEVER put the API token or origin_auth_value in a committed file. +# +# export CLOUDFLARE_API_TOKEN=... # provider auth +# export TF_VAR_origin_auth_value=... # secret origin-auth header value + +zone_id = "__CF_ZONE_ID__" +account_id = "__CF_ACCOUNT_ID__" +origin_ip = "__ORIGIN_IP__" + +api_hostnames = [ + "api.example.com", + "sub.example.com", +] + +origin_auth_header = "X-Origin-Auth" + +rate_limit_requests_per_minute = 120 +rate_limit_mitigation_seconds = 600 +enable_bot_management = true + +# origin_auth_value is intentionally omitted here — set TF_VAR_origin_auth_value. diff --git a/infra/domains/cdn/terraform/variables.tf b/infra/domains/cdn/terraform/variables.tf new file mode 100644 index 0000000..de4c38f --- /dev/null +++ b/infra/domains/cdn/terraform/variables.tf @@ -0,0 +1,50 @@ +variable "zone_id" { + description = "Cloudflare zone ID for the domain being onboarded." + type = string +} + +variable "account_id" { + description = "Cloudflare account ID (used by account-scoped resources)." + type = string + default = "" +} + +variable "origin_ip" { + description = "Origin server IP the CDN proxies to. Only the CDN ever talks to it; clients never see it (doc/05 §2)." + type = string +} + +variable "api_hostnames" { + description = "Proxied hostnames served by this zone (API pool / subscription / website mirror)." + type = list(string) +} + +variable "origin_auth_header" { + description = "Header name the CDN injects on origin requests; the Go originauth middleware checks it." + type = string + default = "X-Origin-Auth" +} + +variable "origin_auth_value" { + description = "Secret origin-auth value. Provide via TF_VAR_origin_auth_value env var — NEVER commit it. Rotated quarterly (doc/06 §6)." + type = string + sensitive = true +} + +variable "rate_limit_requests_per_minute" { + description = "Per-IP request budget for /v1/* before mitigation." + type = number + default = 120 +} + +variable "rate_limit_mitigation_seconds" { + description = "How long an offending IP stays blocked." + type = number + default = 600 +} + +variable "enable_bot_management" { + description = "Enable the cloudflare_bot_management resource (requires Pro+/Bot Management on the plan)." + type = bool + default = true +} diff --git a/infra/domains/cdn/terraform/versions.tf b/infra/domains/cdn/terraform/versions.tf new file mode 100644 index 0000000..8000fbc --- /dev/null +++ b/infra/domains/cdn/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.40" + } + } +} + +# The API token is read from the CLOUDFLARE_API_TOKEN environment variable. +# NEVER hardcode the token here or in any *.tfvars file (doc/06 §2 红线). +provider "cloudflare" {} diff --git a/infra/domains/cdn/terraform/waf.tf b/infra/domains/cdn/terraform/waf.tf new file mode 100644 index 0000000..5441923 --- /dev/null +++ b/infra/domains/cdn/terraform/waf.tf @@ -0,0 +1,67 @@ +# WAF managed rulesets (doc/05 §2 WAF 全开). +resource "cloudflare_ruleset" "waf_managed" { + zone_id = var.zone_id + name = "pangolin-waf-managed" + kind = "zone" + phase = "http_request_firewall_managed" + + rules { + action = "execute" + description = "Cloudflare Managed Ruleset" + enabled = true + expression = "true" + + action_parameters { + # Cloudflare Managed Ruleset (stable well-known ID). + id = "efb7b8c949ac4650a09736fc376e9aee" + } + } + + rules { + action = "execute" + description = "Cloudflare OWASP Core Ruleset" + enabled = true + expression = "true" + + action_parameters { + # OWASP Core Ruleset (stable well-known ID). + id = "4814384a9e5d4991b9815dcfc25d2f1f" + } + } +} + +# Rate limiting (doc/05 §2 速率限制全开). Per-IP budget on the API surface. +resource "cloudflare_ruleset" "rate_limit" { + zone_id = var.zone_id + name = "pangolin-rate-limit" + kind = "zone" + phase = "http_ratelimit" + + rules { + action = "block" + description = "Per-IP rate limit on /v1/*" + enabled = true + expression = "(starts_with(http.request.uri.path, \"/v1/\"))" + + ratelimit { + characteristics = ["ip.src", "cf.colo.id"] + period = 60 + requests_per_period = var.rate_limit_requests_per_minute + mitigation_timeout = var.rate_limit_mitigation_seconds + } + } +} + +# Bot management (doc/05 §2 bot 管理全开). Requires Bot Management / Super Bot +# Fight Mode on the plan; toggle with enable_bot_management. +resource "cloudflare_bot_management" "this" { + count = var.enable_bot_management ? 1 : 0 + zone_id = var.zone_id + + enable_js = true + sbfm_definitely_automated = "block" + sbfm_likely_automated = "managed_challenge" + sbfm_verified_bots = "allow" + sbfm_static_resource_protection = false + optimize_wordpress = false +} diff --git a/infra/domains/domains.md b/infra/domains/domains.md new file mode 100644 index 0000000..1cd8112 --- /dev/null +++ b/infra/domains/domains.md @@ -0,0 +1,84 @@ +# 域名分组登记(配置即代码) + +> 口径来源:doc/05 §3「域名体系与解析兜底」、doc/06 §2「身份隔离红线」。 +> +> **本文件只登记分组、状态、启用流程,绝不记录任何注册身份信息**(注册商账号、邮箱、付款方式、WHOIS 联系人等身份线索一律离线管理,见末尾「身份线索」一节)。所有占位域名均为 `example-*`,启用真实域名时只替换占位、不写身份。 + +## 1. 四组隔离原则 + +四组域名**互不关联**:不同注册商、独立 WHOIS 隐私、不共享 NS 特征化配置、加密货币付款(doc/05 §3 / doc/06 §2 红线)。任意一组被墙或被关联,不牵连其余组。 + +| 组 | 用途 | 隔离策略 | 客户端是否内置 | +|---|---|---|---| +| **主域名** | 官网 + 用户中心 | 品牌入口,被墙概率最高,**做好牺牲准备**;常备 ≥2 活跃镜像 | 否 | +| **API 域名池(N≥3)** | 客户端 API | 不同注册商、不同 WHOIS、互不关联;客户端内置全部 | 是(全部) | +| **订阅 / 分发域名** | 端点更新、公告 JSON | **单独隔离**——「取订阅」最易被关联封锁 | 是(端点池来源之一) | +| **冷备池(≥5)** | 已注册未启用 | 启用即配 CDN,随签名端点更新下发 | 否(启用后进池) | + +## 2. 登记表(占位示例,按组维护) + +状态取值:`active`(在用)/ `standby`(冷备待启用)/ `burned`(已被墙,保留记录)/ `retired`(主动下线)。 + +### 2.1 主域名 + +| 域名 | 状态 | 用途 | CDN | 备注 | +|---|---|---|---|---| +| `example-brand.com` | active | 官网+用户中心 | CF | 主品牌入口 | +| `example-brand-mirror.com` | active | 官网镜像 | CF | 牺牲后顶替 | + +### 2.2 API 域名池(N≥3) + +| 域名 | 状态 | 注册商分组* | CDN | 备注 | +|---|---|---|---|---| +| `api-a.example-alpha.com` | active | A | CF | | +| `api-b.example-bravo.net` | active | B | CF | | +| `api-c.example-charlie.org` | active | C | CF | | + +\* 注册商分组只记**代号**(A/B/C),代号到真实注册商的映射在离线身份台账,不入库。 + +### 2.3 订阅 / 分发域名(独立隔离) + +| 域名 | 状态 | 用途 | CDN | 备注 | +|---|---|---|---|---| +| `dist-1.example-dist.com` | active | 端点更新 + 公告 JSON | CF | 与上两组零关联 | + +> 分发内容本身走 ≥3 镜像(CF Pages / GitHub<独立账号> / 对象存储,见 `tools/publish-mirrors`),分发**域名**只是其中之一的入口。 + +### 2.4 冷备池(≥5) + +| 域名 | 状态 | 预留用途 | 备注 | +|---|---|---|---| +| `example-cold-1.com` | standby | API 补池 | 已注册未配 CDN | +| `example-cold-2.net` | standby | API 补池 | | +| `example-cold-3.org` | standby | 分发补位 | | +| `example-cold-4.io` | standby | 主域镜像 | | +| `example-cold-5.app` | standby | 机动 | | + +## 3. 启用流程(standby → active) + +对应 doc/05 §5 Runbook(API 域名被墙 / 主域被墙): + +1. **选域**:从冷备池挑一个与被墙域不同注册商分组的域名。 +2. **配 CDN**:`infra/domains/cdn/terraform` 填 `zone_id/api_hostnames/origin_ip`,`terraform apply`(含 WAF/bot/速率限制/回源鉴权,目标 ≤30min)。 +3. **下发**:用 `tools/endpoint-signer` 把新域名并入 `api_domains[]`,`version` +1,签名;`tools/publish-mirrors` 推 ≥3 镜像并校验 hash 一致。 +4. **登记**:本文件把该域名状态改 `active`,被墙的改 `burned`,并补一个新 standby 维持冷备池 ≥5。 +5. **验证**:域名级探针(复用 #15 探针体系)确认境内可达;回源鉴权 200、直连源站 403。 + +## 4. 被墙检测(接口需求,不在本任务实现) + +域名级 DNS 污染 / TCP 阻断探测复用 **#15 探针体系**(doc/05 §3:境内拨测 + 境外对照)。本任务只登记契约需求: + +- 输入:本文件 `active` 组的域名清单。 +- 输出:每域名 `{reachable, dns_pollution, tcp_blocked, checked_at}`,污染 / 阻断即触发上面的启用流程。 +- 探测器本身由 #15 实现,本任务不实现探针。 + +## 5. 身份线索(离线管理,禁止入库) + +以下信息**只存在离线身份台账**,本仓库任何文件都不得出现: + +- 注册商账号 / 登录邮箱 / 付款凭据(加密货币钱包)。 +- WHOIS 联系人(一律隐私注册)。 +- 注册商分组代号(A/B/C)到真实注册商的映射。 +- CDN / 对象存储 / GitHub 独立账号的身份。 + +> 红线(doc/06 §2):服务器、域名、支付、邮箱之间不得产生可被关联的身份线索;管理操作只在专用环境进行。 diff --git a/infra/domains/examples/endpoints.payload.example.json b/infra/domains/examples/endpoints.payload.example.json new file mode 100644 index 0000000..393cf0c --- /dev/null +++ b/infra/domains/examples/endpoints.payload.example.json @@ -0,0 +1,25 @@ +{ + "api_domains": [ + "api-a.example-alpha.com", + "api-b.example-bravo.net", + "api-c.example-charlie.org" + ], + "mirror_urls": [ + "https://pages.example-pages.dev/endpoints.v1.json", + "https://raw.githubusercontent.com/example-alt-account/dist/main/endpoints.v1.json", + "https://obj.example-storage.com/dist/endpoints.v1.json" + ], + "emergency_nodes_hint": [ + "builtin" + ], + "channel": "", + "notice": { + "id": "2026-q2-welcome", + "level": "info", + "title_zh": "服务稳定运行中", + "title_en": "Service running normally", + "body_zh": "如遇连接问题,客户端会自动切换线路。", + "body_en": "If you hit connection issues, the client will switch routes automatically.", + "published_at": "2026-06-13T00:00:00Z" + } +} diff --git a/infra/domains/examples/mirrors.example.json b/infra/domains/examples/mirrors.example.json new file mode 100644 index 0000000..dc139f0 --- /dev/null +++ b/infra/domains/examples/mirrors.example.json @@ -0,0 +1,22 @@ +{ + "mirrors": [ + { + "name": "cloudflare-pages", + "dir": "./_publish/cf-pages", + "fetch_url": "https://pages.example-pages.dev", + "sync_cmd": ["wrangler", "pages", "deploy", "./_publish/cf-pages", "--project-name", "dist"] + }, + { + "name": "github-pages-altacct", + "dir": "./_publish/github", + "fetch_url": "https://raw.githubusercontent.com/example-alt-account/dist/main", + "sync_cmd": ["git", "-C", "./_publish/github", "push"] + }, + { + "name": "object-storage", + "dir": "./_publish/objstore", + "fetch_url": "https://obj.example-storage.com/dist", + "sync_cmd": ["aws", "s3", "sync", "./_publish/objstore", "s3://dist", "--profile", "dist-egress"] + } + ] +} diff --git a/infra/domains/examples/notices.payload.example.json b/infra/domains/examples/notices.payload.example.json new file mode 100644 index 0000000..fbcc2f1 --- /dev/null +++ b/infra/domains/examples/notices.payload.example.json @@ -0,0 +1,14 @@ +{ + "notices": [ + { + "id": "2026-q2-mirror-move", + "level": "warning", + "title_zh": "官网地址更新", + "title_en": "Website address updated", + "body_zh": "请通过客户端内最新地址访问用户中心。", + "body_en": "Please reach the member center via the latest address shown in the app.", + "url": "https://example-mirror-2.com", + "published_at": "2026-06-13T00:00:00Z" + } + ] +} diff --git a/infra/domains/tools/README.md b/infra/domains/tools/README.md new file mode 100644 index 0000000..c4c329c --- /dev/null +++ b/infra/domains/tools/README.md @@ -0,0 +1,65 @@ +# tools — 签名端点分发 / 多镜像发布(离线 Go CLI) + +零外部依赖(纯标准库),独立 Go module(不污染 `server/`)。 + +```bash +cd infra/domains/tools +go test ./... # 单测:签名/验签往返、篡改拒、防回滚、key_id 轮换、镜像一致性/故障转移 +go build ./... +``` + +## endpoint-signer(离线使用) + +在**离线机器**上签发文档。私钥永不离开离线机;只分发产出的 `*.v1.json` 和(内置进客户端的)公钥。 + +```bash +# 1. 生成离线密钥对(私钥 0600,离线两地保存;公钥内置客户端) +go run ./cmd/endpoint-signer keygen -out-prefix ./key -key-id ed25519-2026q2 + +# 2. 签端点文档(version 单调递增;可用 -prev 自动 +1) +go run ./cmd/endpoint-signer sign -type endpoints \ + -in ../examples/endpoints.payload.example.json \ + -key ./key.ed25519.key -key-id ed25519-2026q2 -version 1 \ + -out endpoints.v1.json + +# 3. 签公告文档(/v1/notices 的静态镜像版) +go run ./cmd/endpoint-signer sign -type notices \ + -in ../examples/notices.payload.example.json \ + -key ./key.ed25519.key -key-id ed25519-2026q2 -version 1 \ + -out notices.v1.json + +# 4. 验签(含防回滚 -min-version 与 key 轮换多公钥) +go run ./cmd/endpoint-signer verify -in endpoints.v1.json \ + -keys "ed25519-2026q2=[,ed25519-2026q1=<旧公钥>]" \ + -type endpoints -min-version 0 + +go run ./cmd/endpoint-signer inspect -in endpoints.v1.json +``` + +文档结构与验签规则见 `../CLIENT-CONTRACT.md`。 + +## publish-mirrors + +把签名文档推到 ≥3 镜像,校验各镜像内容 hash 一致;并能像客户端一样做故障转移取回。 + +```bash +# 发布 + 一致性校验(任一镜像写失败或内容不一致即非零退出) +go run ./cmd/publish-mirrors publish -in endpoints.v1.json \ + -config ../examples/mirrors.example.json -name endpoints.v1.json + +# 只校验(不写) +go run ./cmd/publish-mirrors publish -in endpoints.v1.json \ + -config ... -name endpoints.v1.json -verify-only + +# 故障转移取回(单镜像失效仍能从其余镜像取到;可带 -keys 顺带验签) +go run ./cmd/publish-mirrors fetch -config ... -name endpoints.v1.json \ + -keys "ed25519-2026q2=" -type endpoints -out got.json +``` + +镜像配置(`mirrors.example.json`):每个镜像有本地内容根 `dir`(发布即写入),可选 `fetch_url`(`file://`/`http(s)://`,用于校验与取回),以及 `sync_cmd`(如 wrangler / aws s3,**仅登记,本工具不执行**——由部署把 `dir` 同步到对应平台)。 + +## 安全约束 + +- 私钥(`*.ed25519.key`)、`_publish/`、签名产物默认 gitignore;本目录不入库任何私钥。 +- 公钥可入库 / 内置客户端。 +- token、身份信息一律不出现在本目录。 diff --git a/infra/domains/tools/cmd/endpoint-signer/flags.go b/infra/domains/tools/cmd/endpoint-signer/flags.go new file mode 100644 index 0000000..559a8d5 --- /dev/null +++ b/infra/domains/tools/cmd/endpoint-signer/flags.go @@ -0,0 +1,9 @@ +package main + +import "flag" + +// newFlagSet returns a flag set that reports parse errors instead of exiting, +// so the caller controls the exit path uniformly. +func newFlagSet(name string) *flag.FlagSet { + return flag.NewFlagSet(name, flag.ContinueOnError) +} diff --git a/infra/domains/tools/cmd/endpoint-signer/main.go b/infra/domains/tools/cmd/endpoint-signer/main.go new file mode 100644 index 0000000..9079f73 --- /dev/null +++ b/infra/domains/tools/cmd/endpoint-signer/main.go @@ -0,0 +1,282 @@ +// Command endpoint-signer is the OFFLINE CLI that signs endpoint and notice +// distribution documents with an Ed25519 key (doc/06 §3). +// +// It is meant to run on an air-gapped machine. The private key never leaves that +// machine; only the produced *.v1.json (and the public key, embedded in the +// client) are distributed. +// +// Subcommands: +// +// keygen -out-prefix

[-key-id id] generate an offline keypair +// sign -type endpoints|notices -in payload.json -key priv.key -key-id id +// -version N [-prev prev.json] [-issued-at RFC3339] [-out out.json] +// verify -in doc.json -keys "id=b64[,id2=b64]" [-type ...] [-min-version N] +// inspect -in doc.json +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/wangjia/pangolin/infra/domains/tools/internal/endpoint" + "github.com/wangjia/pangolin/infra/domains/tools/internal/notice" + "github.com/wangjia/pangolin/infra/domains/tools/internal/sign" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "keygen": + err = cmdKeygen(os.Args[2:]) + case "sign": + err = cmdSign(os.Args[2:]) + case "verify": + err = cmdVerify(os.Args[2:]) + case "inspect": + err = cmdInspect(os.Args[2:]) + case "-h", "--help", "help": + usage() + return + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `endpoint-signer — offline Ed25519 signer for endpoint/notice distribution + + keygen -out-prefix

[-key-id id] + sign -type endpoints|notices -in payload.json -key priv.key -key-id id -version N [-prev prev.json] [-issued-at RFC3339] [-out out.json] + verify -in doc.json -keys "id=b64[,id2=b64]" [-type endpoints|notices] [-min-version N] + inspect -in doc.json +`) +} + +func cmdKeygen(args []string) error { + fs := newFlagSet("keygen") + outPrefix := fs.String("out-prefix", "", "output prefix; writes .ed25519.key (private, 0600) and .ed25519.pub") + keyID := fs.String("key-id", "", "suggested key_id label (informational)") + if err := fs.Parse(args); err != nil { + return err + } + if *outPrefix == "" { + return errors.New("-out-prefix is required") + } + pub, priv, err := sign.GenerateKey() + if err != nil { + return err + } + privPath := *outPrefix + ".ed25519.key" + pubPath := *outPrefix + ".ed25519.pub" + if err := os.WriteFile(privPath, []byte(sign.EncodePrivate(priv)+"\n"), 0o600); err != nil { + return err + } + if err := os.WriteFile(pubPath, []byte(sign.EncodePublic(pub)+"\n"), 0o644); err != nil { + return err + } + fmt.Printf("private key (0600, store OFFLINE in TWO locations): %s\n", privPath) + fmt.Printf("public key (embed in client install package): %s\n", pubPath) + fmt.Printf("public key (base64): %s\n", sign.EncodePublic(pub)) + if *keyID != "" { + fmt.Printf("key ring spec: %s=%s\n", *keyID, sign.EncodePublic(pub)) + } + return nil +} + +func cmdSign(args []string) error { + fs := newFlagSet("sign") + docType := fs.String("type", "endpoints", "payload type: endpoints|notices") + in := fs.String("in", "", "input payload JSON file") + keyFile := fs.String("key", "", "offline private key file (base64)") + keyID := fs.String("key-id", "", "key_id stamped into the document") + version := fs.Uint64("version", 0, "monotonic version (0 = derive from -prev + 1)") + prev := fs.String("prev", "", "previous signed document; version defaults to its version + 1") + issuedAt := fs.String("issued-at", "", "RFC3339 UTC timestamp (default: now)") + out := fs.String("out", "", "output file (default: stdout)") + if err := fs.Parse(args); err != nil { + return err + } + if *in == "" || *keyFile == "" || *keyID == "" { + return errors.New("-in, -key and -key-id are required") + } + + keyBytes, err := os.ReadFile(*keyFile) + if err != nil { + return err + } + priv, err := sign.DecodePrivate(string(keyBytes)) + if err != nil { + return err + } + + ver := *version + if ver == 0 { + if *prev == "" { + return errors.New("-version is required when -prev is not given") + } + prevRaw, err := os.ReadFile(*prev) + if err != nil { + return err + } + prevEnv, err := sign.Parse(prevRaw) + if err != nil { + return err + } + ver = prevEnv.Version + 1 + } + + ts := *issuedAt + if ts == "" { + ts = time.Now().UTC().Format(time.RFC3339) + } + + payloadRaw, err := os.ReadFile(*in) + if err != nil { + return err + } + + var env sign.Envelope + switch *docType { + case "endpoints": + var p endpoint.Payload + if err := json.Unmarshal(payloadRaw, &p); err != nil { + return fmt.Errorf("parse endpoints payload: %w", err) + } + env, err = endpoint.Build(p, *keyID, ver, ts, priv) + if err != nil { + return err + } + case "notices": + var l notice.List + if err := json.Unmarshal(payloadRaw, &l); err != nil { + return fmt.Errorf("parse notices payload: %w", err) + } + if err := l.Validate(); err != nil { + return err + } + canon, err := json.Marshal(l) + if err != nil { + return err + } + env = sign.Envelope{Version: ver, IssuedAt: ts, KeyID: *keyID, Payload: canon} + if err := sign.Sign(priv, &env); err != nil { + return err + } + default: + return fmt.Errorf("unknown -type %q", *docType) + } + + rendered, err := sign.Marshal(env) + if err != nil { + return err + } + rendered = append(rendered, '\n') + if *out == "" { + _, err = os.Stdout.Write(rendered) + return err + } + if err := os.WriteFile(*out, rendered, 0o644); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "signed %s document v%d -> %s\n", *docType, ver, *out) + return nil +} + +func cmdVerify(args []string) error { + fs := newFlagSet("verify") + in := fs.String("in", "", "signed document file") + keys := fs.String("keys", "", "key ring: id=base64pub[,id2=base64pub] (multiple = rotation window)") + docType := fs.String("type", "endpoints", "payload type for schema validation: endpoints|notices") + minVersion := fs.Uint64("min-version", 0, "reject documents with version <= this (anti-rollback)") + if err := fs.Parse(args); err != nil { + return err + } + if *in == "" || *keys == "" { + return errors.New("-in and -keys are required") + } + ring, err := sign.ParseKeyRing([]string{*keys}) + if err != nil { + return err + } + raw, err := os.ReadFile(*in) + if err != nil { + return err + } + + switch *docType { + case "endpoints": + env, p, err := endpoint.VerifyDocument(raw, ring, *minVersion) + if err != nil { + return err + } + fmt.Printf("OK: endpoints v%d key_id=%s api_domains=%d mirror_urls=%d\n", + env.Version, env.KeyID, len(p.APIDomains), len(p.MirrorURLs)) + case "notices": + env, err := sign.Parse(raw) + if err != nil { + return err + } + if err := sign.Verify(env, ring); err != nil { + return err + } + if env.Version <= *minVersion { + return endpoint.ErrRollback + } + var l notice.List + if err := json.Unmarshal(env.Payload, &l); err != nil { + return err + } + if err := l.Validate(); err != nil { + return err + } + fmt.Printf("OK: notices v%d key_id=%s notices=%d\n", env.Version, env.KeyID, len(l.Notices)) + default: + return fmt.Errorf("unknown -type %q", *docType) + } + return nil +} + +func cmdInspect(args []string) error { + fs := newFlagSet("inspect") + in := fs.String("in", "", "signed document file") + if err := fs.Parse(args); err != nil { + return err + } + if *in == "" { + return errors.New("-in is required") + } + raw, err := os.ReadFile(*in) + if err != nil { + return err + } + env, err := sign.Parse(raw) + if err != nil { + return err + } + fmt.Printf("version: %d\n", env.Version) + fmt.Printf("issued_at: %s\n", env.IssuedAt) + fmt.Printf("key_id: %s\n", env.KeyID) + fmt.Printf("sig: %s...\n", truncate(env.Sig, 16)) + fmt.Printf("payload: %s\n", string(env.Payload)) + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/infra/domains/tools/cmd/publish-mirrors/main.go b/infra/domains/tools/cmd/publish-mirrors/main.go new file mode 100644 index 0000000..91a3f80 --- /dev/null +++ b/infra/domains/tools/cmd/publish-mirrors/main.go @@ -0,0 +1,171 @@ +// Command publish-mirrors pushes a signed distribution document to ≥3 mirrors +// and verifies every mirror serves byte-identical content (doc/05 §1/§5). +// +// Subcommands: +// +// publish -in doc.json -config mirrors.json [-name endpoints.v1.json] [-verify-only] +// fetch -config mirrors.json -name endpoints.v1.json [-keys "id=b64"] [-type endpoints|notices] +// +// The mirrors config lists each destination's local content root (dir) and an +// optional fetch_url used for read-back verification / failover fetch. The +// per-mirror sync_cmd (e.g. wrangler / aws s3 cp) is recorded for the operator +// but never executed here — publishing writes the object into dir, and the +// deploy ships dir to its platform out of band. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/wangjia/pangolin/infra/domains/tools/internal/endpoint" + "github.com/wangjia/pangolin/infra/domains/tools/internal/mirror" + "github.com/wangjia/pangolin/infra/domains/tools/internal/sign" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "publish": + err = cmdPublish(os.Args[2:]) + case "fetch": + err = cmdFetch(os.Args[2:]) + case "-h", "--help", "help": + usage() + return + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `publish-mirrors — push signed docs to ≥3 mirrors and verify consistency + + publish -in doc.json -config mirrors.json [-name endpoints.v1.json] [-verify-only] + fetch -config mirrors.json -name endpoints.v1.json [-keys "id=b64"] [-type endpoints|notices] +`) +} + +func loadConfig(path string) (mirror.Config, error) { + var cfg mirror.Config + raw, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return cfg, fmt.Errorf("parse mirrors config: %w", err) + } + if len(cfg.Mirrors) < 3 { + return cfg, fmt.Errorf("at least 3 mirrors required, got %d", len(cfg.Mirrors)) + } + return cfg, nil +} + +func cmdPublish(args []string) error { + fs := flag.NewFlagSet("publish", flag.ContinueOnError) + in := fs.String("in", "", "signed document to publish") + cfgPath := fs.String("config", "", "mirrors config JSON") + name := fs.String("name", "", "published object name (default: basename of -in)") + verifyOnly := fs.Bool("verify-only", false, "skip writing; only verify existing mirror content matches -in") + if err := fs.Parse(args); err != nil { + return err + } + if *in == "" || *cfgPath == "" { + return errors.New("-in and -config are required") + } + cfg, err := loadConfig(*cfgPath) + if err != nil { + return err + } + content, err := os.ReadFile(*in) + if err != nil { + return err + } + objectName := *name + if objectName == "" { + objectName = filepath.Base(*in) + } + want := mirror.SHA256Hex(content) + + if !*verifyOnly { + results, perr := mirror.Publish(content, objectName, cfg.Mirrors) + for _, r := range results { + if r.Err != nil { + fmt.Printf(" ✗ %-16s %v\n", r.Name, r.Err) + } else { + fmt.Printf(" ✓ %-16s %s sha256=%s\n", r.Name, r.Path, r.SHA256) + } + } + if perr != nil { + return fmt.Errorf("publish had failures: %w", perr) + } + } + + if err := mirror.VerifyConsistency(objectName, cfg.Mirrors, want); err != nil { + return err + } + fmt.Printf("consistency OK: %d/%d mirrors serve sha256=%s\n", len(cfg.Mirrors), len(cfg.Mirrors), want) + return nil +} + +func cmdFetch(args []string) error { + fs := flag.NewFlagSet("fetch", flag.ContinueOnError) + cfgPath := fs.String("config", "", "mirrors config JSON") + name := fs.String("name", "", "object name to fetch") + keys := fs.String("keys", "", "optional key ring id=base64pub[,...] to verify signature on fetch") + docType := fs.String("type", "endpoints", "payload type when -keys given: endpoints|notices") + out := fs.String("out", "", "write fetched content here (default: stdout)") + if err := fs.Parse(args); err != nil { + return err + } + if *cfgPath == "" || *name == "" { + return errors.New("-config and -name are required") + } + cfg, err := loadConfig(*cfgPath) + if err != nil { + return err + } + + var validate func([]byte) error + if *keys != "" { + ring, err := sign.ParseKeyRing([]string{*keys}) + if err != nil { + return err + } + validate = func(b []byte) error { + if *docType == "endpoints" { + _, _, e := endpoint.VerifyDocument(b, ring, 0) + return e + } + env, e := sign.Parse(b) + if e != nil { + return e + } + return sign.Verify(env, ring) + } + } + + content, winner, err := mirror.Fetch(*name, cfg.Mirrors, validate) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "fetched from mirror %q\n", winner) + if *out == "" { + _, err = os.Stdout.Write(content) + return err + } + return os.WriteFile(*out, content, 0o644) +} diff --git a/infra/domains/tools/go.mod b/infra/domains/tools/go.mod new file mode 100644 index 0000000..44370a7 --- /dev/null +++ b/infra/domains/tools/go.mod @@ -0,0 +1,3 @@ +module github.com/wangjia/pangolin/infra/domains/tools + +go 1.25.0 diff --git a/infra/domains/tools/internal/endpoint/endpoint.go b/infra/domains/tools/internal/endpoint/endpoint.go new file mode 100644 index 0000000..e3aa37c --- /dev/null +++ b/infra/domains/tools/internal/endpoint/endpoint.go @@ -0,0 +1,189 @@ +// Package endpoint defines the endpoint-distribution payload (the api_domains / +// mirror_urls / emergency / notice / channel bundle) that the client merges +// into its endpoint pool, plus normalization, validation and anti-rollback +// verification on top of the sign envelope. +// +// See infra/domains/CLIENT-CONTRACT.md for the consuming contract. +package endpoint + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/wangjia/pangolin/infra/domains/tools/internal/notice" + "github.com/wangjia/pangolin/infra/domains/tools/internal/sign" +) + +// ErrRollback is returned when a document's version is not strictly greater than +// the version the client already trusts (downgrade / replay protection). +var ErrRollback = errors.New("endpoint: document version is not newer than current (rollback rejected)") + +// Payload is the signed body of an endpoints document. +type Payload struct { + // APIDomains is the ordered API domain pool the client should try (doc/05 §3). + APIDomains []string `json:"api_domains"` + // MirrorURLs are the full URLs (≥3) where the next signed document lives. + MirrorURLs []string `json:"mirror_urls"` + // EmergencyNodesHint is an optional opaque hint pointing the client at where + // to fetch emergency node parameters; never the parameters themselves. + EmergencyNodesHint []string `json:"emergency_nodes_hint,omitempty"` + // Notice is an optional inline announcement (same schema as /v1/notices). + Notice *notice.Notice `json:"notice,omitempty"` + // Channel scopes a document to a distribution channel so sensitive built-in + // parameters can be rotated per package (doc/06 §3 客户端). Empty = all. + Channel string `json:"channel,omitempty"` +} + +// Normalize trims, lowercases and de-duplicates domains, and de-duplicates +// mirror URLs, producing a stable ordering so re-signing identical input yields +// identical bytes. +func (p *Payload) Normalize() { + p.APIDomains = normalizeHosts(p.APIDomains) + p.MirrorURLs = dedupSorted(strings.TrimSpace, p.MirrorURLs) + p.EmergencyNodesHint = dedupSorted(strings.TrimSpace, p.EmergencyNodesHint) + p.Channel = strings.TrimSpace(p.Channel) +} + +// Validate enforces the schema invariants the client relies on. +func (p Payload) Validate() error { + if len(p.APIDomains) == 0 { + return fmt.Errorf("endpoint: api_domains must contain at least one domain") + } + for _, d := range p.APIDomains { + if !isHostname(d) { + return fmt.Errorf("endpoint: %q is not a valid hostname", d) + } + } + if len(p.MirrorURLs) < 1 { + return fmt.Errorf("endpoint: mirror_urls must contain at least one URL") + } + for _, m := range p.MirrorURLs { + u, err := url.Parse(m) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return fmt.Errorf("endpoint: mirror_url %q must be an absolute http(s) URL", m) + } + } + if p.Notice != nil { + if err := p.Notice.Validate(); err != nil { + return err + } + } + return nil +} + +// Build normalizes and validates p, then wraps it into a signed envelope and +// signs it. priv is the offline private key; keyID selects the verifying key. +func Build(p Payload, keyID string, version uint64, issuedAt string, priv []byte) (sign.Envelope, error) { + p.Normalize() + if err := p.Validate(); err != nil { + return sign.Envelope{}, err + } + raw, err := json.Marshal(p) + if err != nil { + return sign.Envelope{}, err + } + env := sign.Envelope{ + Version: version, + IssuedAt: issuedAt, + KeyID: keyID, + Payload: raw, + } + if err := sign.Sign(priv, &env); err != nil { + return sign.Envelope{}, err + } + return env, nil +} + +// Decode parses the payload out of a (already-verified) envelope. +func Decode(env sign.Envelope) (Payload, error) { + var p Payload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return Payload{}, fmt.Errorf("endpoint: cannot decode payload: %w", err) + } + return p, nil +} + +// VerifyDocument runs the full client-side acceptance check on raw bytes: +// 1. signature valid under one of the ring's keys (supports key rotation), +// 2. version strictly greater than currentVersion (anti-rollback), +// 3. payload passes schema validation. +// +// currentVersion is the version the client already trusts (0 if none yet). +func VerifyDocument(raw []byte, ring sign.KeyRing, currentVersion uint64) (sign.Envelope, Payload, error) { + env, err := sign.Parse(raw) + if err != nil { + return sign.Envelope{}, Payload{}, err + } + if err := sign.Verify(env, ring); err != nil { + return sign.Envelope{}, Payload{}, err + } + if env.Version <= currentVersion { + return sign.Envelope{}, Payload{}, ErrRollback + } + p, err := Decode(env) + if err != nil { + return sign.Envelope{}, Payload{}, err + } + if err := p.Validate(); err != nil { + return sign.Envelope{}, Payload{}, err + } + return env, p, nil +} + +// --- helpers --- + +func normalizeHosts(in []string) []string { + return dedupSorted(func(s string) string { + return strings.ToLower(strings.TrimSpace(s)) + }, in) +} + +func dedupSorted(norm func(string) string, in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, s := range in { + s = norm(s) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + sort.Strings(out) + if len(out) == 0 { + return nil + } + return out +} + +// isHostname does a conservative check: 1..253 chars, dot-separated labels of +// [a-z0-9-], not starting/ending with hyphen, at least two labels. +func isHostname(h string) bool { + if len(h) == 0 || len(h) > 253 { + return false + } + labels := strings.Split(h, ".") + if len(labels) < 2 { + return false + } + for _, l := range labels { + if len(l) == 0 || len(l) > 63 { + return false + } + if l[0] == '-' || l[len(l)-1] == '-' { + return false + } + for i := 0; i < len(l); i++ { + c := l[i] + ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' + if !ok { + return false + } + } + } + return true +} diff --git a/infra/domains/tools/internal/endpoint/endpoint_test.go b/infra/domains/tools/internal/endpoint/endpoint_test.go new file mode 100644 index 0000000..ab6e176 --- /dev/null +++ b/infra/domains/tools/internal/endpoint/endpoint_test.go @@ -0,0 +1,132 @@ +package endpoint + +import ( + "errors" + "testing" + + "github.com/wangjia/pangolin/infra/domains/tools/internal/notice" + "github.com/wangjia/pangolin/infra/domains/tools/internal/sign" +) + +func buildSigned(t *testing.T, p Payload, keyID string, version uint64) ([]byte, sign.KeyRing) { + t.Helper() + pub, priv, err := sign.GenerateKey() + if err != nil { + t.Fatal(err) + } + env, err := Build(p, keyID, version, "2026-06-13T00:00:00Z", priv) + if err != nil { + t.Fatalf("Build: %v", err) + } + raw, err := sign.Marshal(env) + if err != nil { + t.Fatal(err) + } + return raw, sign.KeyRing{keyID: pub} +} + +func validPayload() Payload { + return Payload{ + APIDomains: []string{"api-b.example.net", "api-a.example.com"}, + MirrorURLs: []string{"https://m1.example.com/endpoints.v1.json"}, + } +} + +func TestBuildVerifyRoundTrip(t *testing.T) { + raw, ring := buildSigned(t, validPayload(), "k1", 3) + env, p, err := VerifyDocument(raw, ring, 0) + if err != nil { + t.Fatalf("VerifyDocument: %v", err) + } + if env.Version != 3 { + t.Fatalf("version = %d", env.Version) + } + // Normalization should have sorted the domains. + if p.APIDomains[0] != "api-a.example.com" { + t.Fatalf("domains not normalized/sorted: %v", p.APIDomains) + } +} + +func TestRollbackRejected(t *testing.T) { + raw, ring := buildSigned(t, validPayload(), "k1", 5) + // Client already trusts version 5; a v5 (replay) or lower must be rejected. + if _, _, err := VerifyDocument(raw, ring, 5); !errors.Is(err, ErrRollback) { + t.Fatalf("want ErrRollback for equal version, got %v", err) + } + if _, _, err := VerifyDocument(raw, ring, 9); !errors.Is(err, ErrRollback) { + t.Fatalf("want ErrRollback for lower version, got %v", err) + } + // A newer current baseline that is actually older than doc is accepted. + if _, _, err := VerifyDocument(raw, ring, 4); err != nil { + t.Fatalf("v5 doc over current=4 should pass, got %v", err) + } +} + +func TestTamperRejected(t *testing.T) { + raw, ring := buildSigned(t, validPayload(), "k1", 1) + // Flip a byte inside the JSON. + tampered := make([]byte, len(raw)) + copy(tampered, raw) + for i := range tampered { + if tampered[i] == 'a' { + tampered[i] = 'b' + break + } + } + if _, _, err := VerifyDocument(tampered, ring, 0); err == nil { + t.Fatal("tampered document accepted") + } +} + +func TestKeyRotationTransition(t *testing.T) { + oldPub, _, _ := sign.GenerateKey() + newPub, newPriv, _ := sign.GenerateKey() + env, err := Build(validPayload(), "v2", 2, "2026-06-13T00:00:00Z", newPriv) + if err != nil { + t.Fatal(err) + } + raw, _ := sign.Marshal(env) + // Rotation window: client carries both old and new public keys. + ring := sign.KeyRing{"v1": oldPub, "v2": newPub} + if _, _, err := VerifyDocument(raw, ring, 0); err != nil { + t.Fatalf("rotation window verify failed: %v", err) + } +} + +func TestValidationRejectsEmptyDomains(t *testing.T) { + pub, priv, _ := sign.GenerateKey() + _ = pub + if _, err := Build(Payload{MirrorURLs: []string{"https://m/x"}}, "k1", 1, "t", priv); err == nil { + t.Fatal("want error for empty api_domains") + } +} + +func TestValidationRejectsBadMirrorURL(t *testing.T) { + _, priv, _ := sign.GenerateKey() + p := Payload{APIDomains: []string{"a.example.com"}, MirrorURLs: []string{"not-a-url"}} + if _, err := Build(p, "k1", 1, "t", priv); err == nil { + t.Fatal("want error for bad mirror url") + } +} + +func TestNoticeInPayloadValidated(t *testing.T) { + _, priv, _ := sign.GenerateKey() + p := validPayload() + p.Notice = ¬ice.Notice{ID: "n1", Level: "bogus", TitleZH: "x", TitleEn: "x", PublishedAt: "t"} + if _, err := Build(p, "k1", 1, "t", priv); err == nil { + t.Fatal("want error for invalid notice level") + } +} + +func TestChannelPreserved(t *testing.T) { + p := validPayload() + p.Channel = "play-store" + raw, ring := buildSigned(t, p, "k1", 1) + _, got, err := VerifyDocument(raw, ring, 0) + if err != nil { + t.Fatal(err) + } + if got.Channel != "play-store" { + t.Fatalf("channel lost: %q", got.Channel) + } +} diff --git a/infra/domains/tools/internal/mirror/mirror.go b/infra/domains/tools/internal/mirror/mirror.go new file mode 100644 index 0000000..74e8fa8 --- /dev/null +++ b/infra/domains/tools/internal/mirror/mirror.go @@ -0,0 +1,174 @@ +// Package mirror publishes a signed distribution document to ≥3 independent +// mirrors (Cloudflare Pages / GitHub / object storage) and +// verifies that every mirror serves byte-identical content (doc/05 §1: the +// announcement/endpoint channel has the most mirrors and the highest priority). +// +// Mirrors are modelled filesystem-first so publishing and consistency checks are +// fully offline-testable: each Target has a local content root (Dir) that the +// real deploy syncs to its platform via an out-of-band SyncCmd (NOT executed by +// this package). For read-back/verify and client-style failover fetch, a Target +// may also expose a FetchURL (file://, http:// or https://). +package mirror + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Target is one mirror destination. +type Target struct { + Name string `json:"name"` + // Dir is the local content root that Publish writes the object into. + Dir string `json:"dir,omitempty"` + // FetchURL is the base location used for read-back verification and failover + // fetch. If empty, Dir is used. Supports file://, http://, https://. + FetchURL string `json:"fetch_url,omitempty"` + // SyncCmd documents how the deploy ships Dir to the platform. Recorded for + // the operator; this package never runs it. + SyncCmd []string `json:"sync_cmd,omitempty"` +} + +// Config is the publish-mirrors config file. +type Config struct { + Mirrors []Target `json:"mirrors"` +} + +// Result is the outcome of publishing to a single mirror. +type Result struct { + Name string + Path string + SHA256 string + Err error +} + +// SHA256Hex returns the hex sha256 of content. +func SHA256Hex(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +// Publish writes content as objectName into every target's Dir and returns a +// per-target Result. It returns an error if any mirror failed, but still +// reports results for all of them. +func Publish(content []byte, objectName string, targets []Target) ([]Result, error) { + if len(targets) == 0 { + return nil, errors.New("mirror: no targets configured") + } + want := SHA256Hex(content) + results := make([]Result, 0, len(targets)) + var firstErr error + for _, t := range targets { + r := Result{Name: t.Name, SHA256: want} + if t.Dir == "" { + r.Err = fmt.Errorf("mirror %q: dir is empty, cannot publish", t.Name) + } else if err := os.MkdirAll(t.Dir, 0o755); err != nil { + r.Err = fmt.Errorf("mirror %q: mkdir: %w", t.Name, err) + } else { + r.Path = filepath.Join(t.Dir, objectName) + if err := os.WriteFile(r.Path, content, 0o644); err != nil { + r.Err = fmt.Errorf("mirror %q: write: %w", t.Name, err) + } + } + if r.Err != nil && firstErr == nil { + firstErr = r.Err + } + results = append(results, r) + } + return results, firstErr +} + +// VerifyConsistency fetches objectName from every target and confirms each +// matches want (hex sha256). A nil error means every mirror is byte-identical. +func VerifyConsistency(objectName string, targets []Target, want string) error { + if len(targets) == 0 { + return errors.New("mirror: no targets configured") + } + var problems []string + for _, t := range targets { + content, err := fetchOne(t, objectName) + if err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", t.Name, err)) + continue + } + got := SHA256Hex(content) + if got != want { + problems = append(problems, fmt.Sprintf("%s: sha256 mismatch (got %s want %s)", t.Name, got, want)) + } + } + if len(problems) > 0 { + return fmt.Errorf("mirror: consistency check failed:\n %s", strings.Join(problems, "\n ")) + } + return nil +} + +// Fetch tries each target in order and returns the content from the first one +// that both downloads and passes validate. This is the client-side failover: +// any single mirror being down/poisoned still lets us fetch from the rest. +// validate may be nil. It returns the winning target name. +func Fetch(objectName string, targets []Target, validate func([]byte) error) ([]byte, string, error) { + if len(targets) == 0 { + return nil, "", errors.New("mirror: no targets configured") + } + var attempts []string + for _, t := range targets { + content, err := fetchOne(t, objectName) + if err != nil { + attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err)) + continue + } + if validate != nil { + if err := validate(content); err != nil { + attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err)) + continue + } + } + return content, t.Name, nil + } + return nil, "", fmt.Errorf("mirror: all mirrors failed:\n %s", strings.Join(attempts, "\n ")) +} + +func fetchOne(t Target, objectName string) ([]byte, error) { + base := t.FetchURL + if base == "" { + // Fall back to the local Dir. + if t.Dir == "" { + return nil, errors.New("no fetch_url or dir configured") + } + return os.ReadFile(filepath.Join(t.Dir, objectName)) + } + switch { + case strings.HasPrefix(base, "file://"): + root := strings.TrimPrefix(base, "file://") + return os.ReadFile(filepath.Join(root, objectName)) + case strings.HasPrefix(base, "http://"), strings.HasPrefix(base, "https://"): + return httpGet(joinURL(base, objectName)) + default: + // Treat as a bare filesystem path. + return os.ReadFile(filepath.Join(base, objectName)) + } +} + +func joinURL(base, name string) string { + return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(name, "/") +} + +func httpGet(u string) ([]byte, error) { + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(u) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap +} diff --git a/infra/domains/tools/internal/mirror/mirror_test.go b/infra/domains/tools/internal/mirror/mirror_test.go new file mode 100644 index 0000000..39bc3a7 --- /dev/null +++ b/infra/domains/tools/internal/mirror/mirror_test.go @@ -0,0 +1,113 @@ +package mirror + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func threeDirTargets(t *testing.T) []Target { + t.Helper() + ts := make([]Target, 3) + for i := range ts { + dir := t.TempDir() + ts[i] = Target{Name: "m" + string(rune('1'+i)), Dir: dir, FetchURL: "file://" + dir} + } + return ts +} + +func TestPublishAndConsistency(t *testing.T) { + targets := threeDirTargets(t) + content := []byte(`{"version":1,"sig":"x"}`) + results, err := Publish(content, "endpoints.v1.json", targets) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if len(results) != 3 { + t.Fatalf("want 3 results, got %d", len(results)) + } + want := SHA256Hex(content) + if err := VerifyConsistency("endpoints.v1.json", targets, want); err != nil { + t.Fatalf("VerifyConsistency: %v", err) + } +} + +func TestConsistencyDetectsCorruptMirror(t *testing.T) { + targets := threeDirTargets(t) + content := []byte(`{"version":1}`) + if _, err := Publish(content, "doc.json", targets); err != nil { + t.Fatal(err) + } + // Corrupt mirror #2. + if err := os.WriteFile(filepath.Join(targets[1].Dir, "doc.json"), []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyConsistency("doc.json", targets, SHA256Hex(content)); err == nil { + t.Fatal("want consistency error for corrupt mirror") + } +} + +func TestFetchFailoverSkipsInvalid(t *testing.T) { + targets := threeDirTargets(t) + good := []byte("GOOD") + // m1 has bad content, m2 good, m3 good. + if err := os.WriteFile(filepath.Join(targets[0].Dir, "o.json"), []byte("BAD"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targets[1].Dir, "o.json"), good, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targets[2].Dir, "o.json"), good, 0o644); err != nil { + t.Fatal(err) + } + validate := func(b []byte) error { + if string(b) != "GOOD" { + return errors.New("invalid") + } + return nil + } + got, winner, err := Fetch("o.json", targets, validate) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if string(got) != "GOOD" { + t.Fatalf("got %q", got) + } + if winner != targets[1].Name { + t.Fatalf("winner = %q, want %q", winner, targets[1].Name) + } +} + +func TestFetchFailoverHTTPMirrorDown(t *testing.T) { + // First mirror: HTTP 500. Second mirror: serves the document. + down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer down.Close() + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("PAYLOAD")) + })) + defer up.Close() + + targets := []Target{ + {Name: "down", FetchURL: down.URL}, + {Name: "up", FetchURL: up.URL}, + } + got, winner, err := Fetch("any.json", targets, nil) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if string(got) != "PAYLOAD" || winner != "up" { + t.Fatalf("got=%q winner=%q", got, winner) + } +} + +func TestFetchAllFail(t *testing.T) { + targets := []Target{{Name: "x", Dir: t.TempDir()}} + if _, _, err := Fetch("missing.json", targets, nil); err == nil { + t.Fatal("want error when all mirrors fail") + } +} diff --git a/infra/domains/tools/internal/notice/notice.go b/infra/domains/tools/internal/notice/notice.go new file mode 100644 index 0000000..4999810 --- /dev/null +++ b/infra/domains/tools/internal/notice/notice.go @@ -0,0 +1,77 @@ +// Package notice defines the announcement payload that is published as the +// signed static mirror of the API's /v1/notices endpoint (doc/05 §5 应急广播). +// +// Notices are read by the client's announcement slot and MUST be verified with +// the embedded Ed25519 public key before display, exactly like endpoint +// documents — they share the sign.Envelope. +package notice + +import ( + "fmt" + "strings" +) + +// Valid severity levels. +const ( + LevelInfo = "info" + LevelWarning = "warning" + LevelCritical = "critical" +) + +// Notice is a single bilingual announcement. +type Notice struct { + ID string `json:"id"` + Level string `json:"level"` + TitleZH string `json:"title_zh"` + TitleEn string `json:"title_en"` + BodyZH string `json:"body_zh"` + BodyEn string `json:"body_en"` + URL string `json:"url,omitempty"` + PublishedAt string `json:"published_at"` // RFC3339 UTC +} + +// List is the payload signed into a notices document. +type List struct { + Notices []Notice `json:"notices"` +} + +func validLevel(l string) bool { + switch l { + case LevelInfo, LevelWarning, LevelCritical: + return true + default: + return false + } +} + +// Validate checks a single notice for completeness and a known severity. +func (n Notice) Validate() error { + if strings.TrimSpace(n.ID) == "" { + return fmt.Errorf("notice: id is required") + } + if !validLevel(n.Level) { + return fmt.Errorf("notice %q: level must be one of info|warning|critical, got %q", n.ID, n.Level) + } + if strings.TrimSpace(n.TitleZH) == "" || strings.TrimSpace(n.TitleEn) == "" { + return fmt.Errorf("notice %q: both title_zh and title_en are required", n.ID) + } + if strings.TrimSpace(n.PublishedAt) == "" { + return fmt.Errorf("notice %q: published_at is required", n.ID) + } + return nil +} + +// Validate checks the whole list and rejects duplicate IDs. +func (l List) Validate() error { + seen := map[string]bool{} + for _, n := range l.Notices { + if err := n.Validate(); err != nil { + return err + } + if seen[n.ID] { + return fmt.Errorf("notice: duplicate id %q", n.ID) + } + seen[n.ID] = true + } + return nil +} diff --git a/infra/domains/tools/internal/sign/keys.go b/infra/domains/tools/internal/sign/keys.go new file mode 100644 index 0000000..3a02d2b --- /dev/null +++ b/infra/domains/tools/internal/sign/keys.go @@ -0,0 +1,76 @@ +package sign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" +) + +// GenerateKey creates a fresh Ed25519 keypair for offline use. +func GenerateKey() (ed25519.PublicKey, ed25519.PrivateKey, error) { + return ed25519.GenerateKey(rand.Reader) +} + +// EncodePrivate / EncodePublic render keys as base64 (std) for storage. The +// private encoding is meant to be written to an OFFLINE medium only. +func EncodePrivate(priv ed25519.PrivateKey) string { + return base64.StdEncoding.EncodeToString(priv) +} + +func EncodePublic(pub ed25519.PublicKey) string { + return base64.StdEncoding.EncodeToString(pub) +} + +// DecodePrivate parses a base64-encoded Ed25519 private key. +func DecodePrivate(s string) (ed25519.PrivateKey, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil { + return nil, fmt.Errorf("sign: private key not valid base64: %w", err) + } + if len(b) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("sign: private key wrong size: got %d want %d", len(b), ed25519.PrivateKeySize) + } + return ed25519.PrivateKey(b), nil +} + +// DecodePublic parses a base64-encoded Ed25519 public key. +func DecodePublic(s string) (ed25519.PublicKey, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil { + return nil, fmt.Errorf("sign: public key not valid base64: %w", err) + } + if len(b) != ed25519.PublicKeySize { + return nil, fmt.Errorf("sign: public key wrong size: got %d want %d", len(b), ed25519.PublicKeySize) + } + return ed25519.PublicKey(b), nil +} + +// ParseKeyRing builds a KeyRing from "keyid=base64pub" specs. Multiple specs +// (comma- or repeat-supplied) enable a rotation window where either key +// validates a document. +func ParseKeyRing(specs []string) (KeyRing, error) { + ring := KeyRing{} + for _, spec := range specs { + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, b64, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("sign: key ring spec %q must be keyid=base64pubkey", part) + } + pub, err := DecodePublic(b64) + if err != nil { + return nil, err + } + ring[strings.TrimSpace(id)] = pub + } + } + if len(ring) == 0 { + return nil, fmt.Errorf("sign: empty key ring") + } + return ring, nil +} diff --git a/infra/domains/tools/internal/sign/sign.go b/infra/domains/tools/internal/sign/sign.go new file mode 100644 index 0000000..9dd8e27 --- /dev/null +++ b/infra/domains/tools/internal/sign/sign.go @@ -0,0 +1,213 @@ +// Package sign implements the Ed25519 signing envelope used for offline +// signing of endpoint and notice distribution documents (doc/06 §3 密码学口径). +// +// Trust model: +// - The signing private key lives OFFLINE, in two physically separate +// locations. It must never enter the server, CI, or this repository. +// - The verifying public key is embedded in the client install package. +// - key_id selects which public key verifies a document; a KeyRing may hold +// more than one key so a new signing key can be rolled out before the old +// one is retired (双公钥轮换过渡). +// +// Document shape (the on-disk JSON): +// +// { +// "version": , // anti-rollback: clients only accept larger +// "issued_at": "", +// "key_id": "", +// "payload": { ... arbitrary JSON ... }, +// "sig": "" +// } +// +// The signature covers the canonical JSON encoding of the document WITHOUT the +// "sig" field, i.e. {version, issued_at, key_id, payload}. Because version, +// key_id and issued_at are all inside the signed bytes, an attacker cannot +// downgrade the version or swap the key without breaking the signature. +package sign + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "sort" +) + +// Errors returned by Verify. +var ( + ErrUnknownKeyID = errors.New("sign: unknown key_id (no matching public key in ring)") + ErrBadSignature = errors.New("sign: signature verification failed") + ErrMissingSig = errors.New("sign: document has no signature") + ErrEmptyKeyID = errors.New("sign: key_id is empty") + ErrBadPayload = errors.New("sign: payload is not valid JSON") +) + +// Envelope is the signed distribution document. +type Envelope struct { + Version uint64 `json:"version"` + IssuedAt string `json:"issued_at"` + KeyID string `json:"key_id"` + Payload json.RawMessage `json:"payload"` + Sig string `json:"sig,omitempty"` +} + +// KeyRing maps key_id -> public key. Holding more than one entry enables a +// rotation window where documents signed by either key validate. +type KeyRing map[string]ed25519.PublicKey + +// signingBytes returns the canonical bytes that are signed/verified: the +// envelope without its signature. +func (e Envelope) signingBytes() ([]byte, error) { + if e.KeyID == "" { + return nil, ErrEmptyKeyID + } + if !json.Valid(e.Payload) { + return nil, ErrBadPayload + } + unsigned := Envelope{ + Version: e.Version, + IssuedAt: e.IssuedAt, + KeyID: e.KeyID, + Payload: e.Payload, + // Sig intentionally empty -> omitted by omitempty. + } + raw, err := json.Marshal(unsigned) + if err != nil { + return nil, err + } + return Canonicalize(raw) +} + +// Sign computes the Ed25519 signature over e's canonical bytes and stores it in +// e.Sig (base64). The private key is supplied by the caller (loaded from the +// offline key file) and is never persisted by this package. +func Sign(priv ed25519.PrivateKey, e *Envelope) error { + if len(priv) != ed25519.PrivateKeySize { + return fmt.Errorf("sign: invalid private key size %d", len(priv)) + } + msg, err := e.signingBytes() + if err != nil { + return err + } + sig := ed25519.Sign(priv, msg) + e.Sig = base64.StdEncoding.EncodeToString(sig) + return nil +} + +// Verify checks e's signature against the public key selected by e.KeyID from +// ring. It returns nil only if the key is known and the signature is valid. +func Verify(e Envelope, ring KeyRing) error { + if e.Sig == "" { + return ErrMissingSig + } + pub, ok := ring[e.KeyID] + if !ok { + return ErrUnknownKeyID + } + sig, err := base64.StdEncoding.DecodeString(e.Sig) + if err != nil { + return fmt.Errorf("sign: signature is not valid base64: %w", err) + } + msg, err := e.signingBytes() + if err != nil { + return err + } + if !ed25519.Verify(pub, msg, sig) { + return ErrBadSignature + } + return nil +} + +// Marshal renders the signed envelope as indented JSON suitable for publishing. +func Marshal(e Envelope) ([]byte, error) { + if e.Sig == "" { + return nil, ErrMissingSig + } + return json.MarshalIndent(e, "", " ") +} + +// Parse decodes a published document into an Envelope. +func Parse(raw []byte) (Envelope, error) { + var e Envelope + if err := json.Unmarshal(raw, &e); err != nil { + return Envelope{}, fmt.Errorf("sign: cannot parse document: %w", err) + } + return e, nil +} + +// Canonicalize returns a deterministic JSON encoding of raw: object keys sorted +// lexicographically, no insignificant whitespace, array order preserved. This +// guarantees signer and verifier hash identical bytes regardless of field order +// or formatting. +func Canonicalize(raw []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() // keep integers exact; never widen to float64 + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("sign: canonicalize decode: %w", err) + } + var buf bytes.Buffer + if err := writeCanonical(&buf, v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeCanonical(buf *bytes.Buffer, v any) error { + switch t := v.(type) { + case map[string]any: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + kb, err := json.Marshal(k) + if err != nil { + return err + } + buf.Write(kb) + buf.WriteByte(':') + if err := writeCanonical(buf, t[k]); err != nil { + return err + } + } + buf.WriteByte('}') + case []any: + buf.WriteByte('[') + for i, e := range t { + if i > 0 { + buf.WriteByte(',') + } + if err := writeCanonical(buf, e); err != nil { + return err + } + } + buf.WriteByte(']') + case string: + b, err := json.Marshal(t) + if err != nil { + return err + } + buf.Write(b) + case json.Number: + buf.WriteString(t.String()) + case bool: + if t { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + default: + return fmt.Errorf("sign: unsupported JSON type %T in canonicalization", v) + } + return nil +} diff --git a/infra/domains/tools/internal/sign/sign_test.go b/infra/domains/tools/internal/sign/sign_test.go new file mode 100644 index 0000000..0ae0b6c --- /dev/null +++ b/infra/domains/tools/internal/sign/sign_test.go @@ -0,0 +1,141 @@ +package sign + +import ( + "crypto/ed25519" + "encoding/json" + "errors" + "testing" +) + +func mustKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + return pub, priv +} + +func TestSignVerifyRoundTrip(t *testing.T) { + pub, priv := mustKey(t) + env := Envelope{ + Version: 1, + IssuedAt: "2026-06-13T00:00:00Z", + KeyID: "k1", + Payload: json.RawMessage(`{"api_domains":["a.example.com"]}`), + } + if err := Sign(priv, &env); err != nil { + t.Fatalf("Sign: %v", err) + } + if env.Sig == "" { + t.Fatal("signature not set") + } + if err := Verify(env, KeyRing{"k1": pub}); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestVerifyRejectsTamperedPayload(t *testing.T) { + pub, priv := mustKey(t) + env := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"x":1}`)} + if err := Sign(priv, &env); err != nil { + t.Fatal(err) + } + env.Payload = json.RawMessage(`{"x":2}`) // tamper after signing + if err := Verify(env, KeyRing{"k1": pub}); !errors.Is(err, ErrBadSignature) { + t.Fatalf("want ErrBadSignature, got %v", err) + } +} + +func TestVerifyRejectsVersionTamper(t *testing.T) { + pub, priv := mustKey(t) + env := Envelope{Version: 5, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"x":1}`)} + if err := Sign(priv, &env); err != nil { + t.Fatal(err) + } + env.Version = 99 // attacker tries to inflate version + if err := Verify(env, KeyRing{"k1": pub}); !errors.Is(err, ErrBadSignature) { + t.Fatalf("want ErrBadSignature, got %v", err) + } +} + +func TestVerifyUnknownKeyID(t *testing.T) { + pub, priv := mustKey(t) + env := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{}`)} + if err := Sign(priv, &env); err != nil { + t.Fatal(err) + } + if err := Verify(env, KeyRing{"other": pub}); !errors.Is(err, ErrUnknownKeyID) { + t.Fatalf("want ErrUnknownKeyID, got %v", err) + } +} + +func TestKeyRotationDoublePublicKey(t *testing.T) { + oldPub, _ := mustKey(t) + newPub, newPriv := mustKey(t) + // Document signed with the NEW key, key_id "v2". + env := Envelope{Version: 1, IssuedAt: "t", KeyID: "v2", Payload: json.RawMessage(`{}`)} + if err := Sign(newPriv, &env); err != nil { + t.Fatal(err) + } + // During the rotation window the client holds BOTH public keys. + ring := KeyRing{"v1": oldPub, "v2": newPub} + if err := Verify(env, ring); err != nil { + t.Fatalf("rotation window verify failed: %v", err) + } + // A client that only has the old key must reject it. + if err := Verify(env, KeyRing{"v1": oldPub}); !errors.Is(err, ErrUnknownKeyID) { + t.Fatalf("want ErrUnknownKeyID for old-only ring, got %v", err) + } +} + +func TestCanonicalizeFieldOrderInvariant(t *testing.T) { + _, priv := mustKey(t) + // Same logical payload, different key order -> identical signature. + envA := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"a":1,"b":2}`)} + envB := Envelope{Version: 1, IssuedAt: "t", KeyID: "k1", Payload: json.RawMessage(`{"b":2,"a":1}`)} + if err := Sign(priv, &envA); err != nil { + t.Fatal(err) + } + if err := Sign(priv, &envB); err != nil { + t.Fatal(err) + } + if envA.Sig != envB.Sig { + t.Fatalf("canonicalization not order-invariant:\n A=%s\n B=%s", envA.Sig, envB.Sig) + } +} + +func TestCanonicalizePreservesIntegers(t *testing.T) { + out, err := Canonicalize([]byte(`{"v":12345678901234567}`)) + if err != nil { + t.Fatal(err) + } + if string(out) != `{"v":12345678901234567}` { + t.Fatalf("integer not preserved: %s", out) + } +} + +func TestParseKeyRing(t *testing.T) { + pub, _ := mustKey(t) + ring, err := ParseKeyRing([]string{"k1=" + EncodePublic(pub)}) + if err != nil { + t.Fatal(err) + } + if _, ok := ring["k1"]; !ok { + t.Fatal("k1 missing from ring") + } + if _, err := ParseKeyRing([]string{"bad"}); err == nil { + t.Fatal("want error for malformed spec") + } +} + +func TestDecodePrivateRoundTrip(t *testing.T) { + _, priv := mustKey(t) + got, err := DecodePrivate(EncodePrivate(priv)) + if err != nil { + t.Fatal(err) + } + if !got.Equal(priv) { + t.Fatal("private key round-trip mismatch") + } +} diff --git a/server/internal/originauth/doc.go b/server/internal/originauth/doc.go new file mode 100644 index 0000000..5236a5d --- /dev/null +++ b/server/internal/originauth/doc.go @@ -0,0 +1,27 @@ +// Package originauth implements origin-hiding enforcement (doc/05 §2 源站隐藏 / +// 回源鉴权). +// +// The API only ever serves traffic through the CDN. The CDN is configured to +// 1. connect to the origin from a known set of egress IP ranges, and +// 2. inject a shared secret header (default X-Origin-Auth: ) on every +// origin request. +// +// This middleware rejects (403) any request that either does not originate from +// an allowed CDN egress range, or does not carry a recognised auth value — so a +// direct hit on the origin IP, bypassing the CDN, is refused. +// +// Rotation: the auth value is rotated quarterly (doc/06 §6). To make rotation +// zero-downtime the middleware accepts a Current value plus an optional Previous +// value, so both the old and new secret validate during the transition window. +// Comparison is constant-time. +// +// Wiring (not done automatically to keep /healthz reachable for CDN probes): +// +// mw, err := originauth.New(originauth.FromEnv()) +// if err == nil { +// r.Group(func(pr chi.Router) { +// pr.Use(mw.Handler) +// // ... protected API routes ... +// }) +// } +package originauth diff --git a/server/internal/originauth/originauth.go b/server/internal/originauth/originauth.go new file mode 100644 index 0000000..84d5ff1 --- /dev/null +++ b/server/internal/originauth/originauth.go @@ -0,0 +1,149 @@ +package originauth + +import ( + "crypto/subtle" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "strings" + + "github.com/wangjia/pangolin/server/internal/apierr" +) + +// DefaultHeader is the request header the CDN injects on origin requests. +const DefaultHeader = "X-Origin-Auth" + +// errForbidden is the desensitised 403 body (no "VPN"/"proxy" wording). +var errForbidden = &apierr.Error{ + Code: "FORBIDDEN", + MessageZH: "访问被拒绝", + MessageEn: "Access denied", +} + +// Config configures the middleware. +type Config struct { + // Header is the auth header name. Empty defaults to DefaultHeader. + Header string + // Current is the active auth value (required). + Current string + // Previous is the prior auth value accepted during a rotation window + // (optional). + Previous string + // AllowedCIDRs are the CDN egress ranges permitted to reach the origin + // (required, at least one). + AllowedCIDRs []string +} + +// Middleware enforces CDN-only origin access. +type Middleware struct { + header string + current string + previous string + nets []netip.Prefix +} + +// New validates cfg and builds a Middleware. +func New(cfg Config) (*Middleware, error) { + header := cfg.Header + if header == "" { + header = DefaultHeader + } + if cfg.Current == "" { + return nil, fmt.Errorf("originauth: Current auth value is required") + } + if len(cfg.AllowedCIDRs) == 0 { + return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required") + } + nets := make([]netip.Prefix, 0, len(cfg.AllowedCIDRs)) + for _, c := range cfg.AllowedCIDRs { + c = strings.TrimSpace(c) + if c == "" { + continue + } + p, err := netip.ParsePrefix(c) + if err != nil { + return nil, fmt.Errorf("originauth: invalid CIDR %q: %w", c, err) + } + nets = append(nets, p.Masked()) + } + if len(nets) == 0 { + return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required") + } + return &Middleware{ + header: header, + current: cfg.Current, + previous: cfg.Previous, + nets: nets, + }, nil +} + +// Handler is the net/http middleware. It calls next only when both the source +// IP and the auth header are accepted; otherwise it writes a 403. +func (m *Middleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.ipAllowed(r.RemoteAddr) || !m.headerAllowed(r.Header.Get(m.header)) { + apierr.WriteJSON(w, http.StatusForbidden, errForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ipAllowed reports whether the TCP peer address falls in an allowed CDN range. +// It uses the real connection peer (RemoteAddr), never a client-supplied header, +// so a forged X-Forwarded-For cannot bypass the check. +func (m *Middleware) ipAllowed(remoteAddr string) bool { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + addr, err := netip.ParseAddr(strings.TrimSpace(host)) + if err != nil { + return false + } + addr = addr.Unmap() + for _, p := range m.nets { + if p.Contains(addr) { + return true + } + } + return false +} + +// headerAllowed reports whether v matches the current or previous auth value, +// using constant-time comparison. +func (m *Middleware) headerAllowed(v string) bool { + if v == "" { + return false + } + if subtle.ConstantTimeCompare([]byte(v), []byte(m.current)) == 1 { + return true + } + if m.previous != "" && subtle.ConstantTimeCompare([]byte(v), []byte(m.previous)) == 1 { + return true + } + return false +} + +// FromEnv builds a Config from environment variables: +// +// ORIGIN_AUTH_HEADER (optional, default X-Origin-Auth) +// ORIGIN_AUTH_CURRENT (required) +// ORIGIN_AUTH_PREVIOUS (optional, rotation window) +// ORIGIN_AUTH_CIDRS (required, comma-separated CDN egress ranges) +// +// The returned Config is still passed to New for validation. +func FromEnv() Config { + var cidrs []string + if raw := os.Getenv("ORIGIN_AUTH_CIDRS"); raw != "" { + cidrs = strings.Split(raw, ",") + } + return Config{ + Header: os.Getenv("ORIGIN_AUTH_HEADER"), + Current: os.Getenv("ORIGIN_AUTH_CURRENT"), + Previous: os.Getenv("ORIGIN_AUTH_PREVIOUS"), + AllowedCIDRs: cidrs, + } +} diff --git a/server/internal/originauth/originauth_test.go b/server/internal/originauth/originauth_test.go new file mode 100644 index 0000000..685453d --- /dev/null +++ b/server/internal/originauth/originauth_test.go @@ -0,0 +1,135 @@ +package originauth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func testMW(t *testing.T) *Middleware { + t.Helper() + mw, err := New(Config{ + Current: "secret-current", + Previous: "secret-previous", + AllowedCIDRs: []string{"203.0.113.0/24", "2001:db8::/32"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return mw +} + +func do(t *testing.T, mw *Middleware, remoteAddr, headerVal string) int { + t.Helper() + h := mw.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + req := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil) + req.RemoteAddr = remoteAddr + if headerVal != "" { + req.Header.Set(DefaultHeader, headerVal) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code +} + +func TestAllowedIPCurrentValue(t *testing.T) { + mw := testMW(t) + if code := do(t, mw, "203.0.113.5:443", "secret-current"); code != http.StatusOK { + t.Fatalf("want 200, got %d", code) + } +} + +func TestAllowedIPPreviousValueDuringRotation(t *testing.T) { + mw := testMW(t) + if code := do(t, mw, "203.0.113.5:443", "secret-previous"); code != http.StatusOK { + t.Fatalf("rotation window: want 200, got %d", code) + } +} + +func TestAllowedIPWrongValue(t *testing.T) { + mw := testMW(t) + if code := do(t, mw, "203.0.113.5:443", "nope"); code != http.StatusForbidden { + t.Fatalf("want 403, got %d", code) + } +} + +func TestAllowedIPMissingValue(t *testing.T) { + mw := testMW(t) + if code := do(t, mw, "203.0.113.5:443", ""); code != http.StatusForbidden { + t.Fatalf("want 403, got %d", code) + } +} + +func TestDirectConnectBypassRejected(t *testing.T) { + mw := testMW(t) + // Correct header but a source IP outside the CDN range = someone hitting the + // origin directly. Must be 403. + if code := do(t, mw, "198.51.100.7:55000", "secret-current"); code != http.StatusForbidden { + t.Fatalf("direct connect must be 403, got %d", code) + } +} + +func TestIPv6Allowed(t *testing.T) { + mw := testMW(t) + if code := do(t, mw, "[2001:db8::1]:443", "secret-current"); code != http.StatusOK { + t.Fatalf("want 200 for allowed v6, got %d", code) + } +} + +func TestForgedXFFDoesNotBypass(t *testing.T) { + mw := testMW(t) + h := mw.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil) + req.RemoteAddr = "198.51.100.7:55000" // real peer: not a CDN range + req.Header.Set("X-Forwarded-For", "203.0.113.5") + req.Header.Set(DefaultHeader, "secret-current") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("forged XFF must not bypass, got %d", rec.Code) + } +} + +func TestNewValidation(t *testing.T) { + if _, err := New(Config{AllowedCIDRs: []string{"203.0.113.0/24"}}); err == nil { + t.Fatal("want error: missing Current") + } + if _, err := New(Config{Current: "x"}); err == nil { + t.Fatal("want error: missing CIDRs") + } + if _, err := New(Config{Current: "x", AllowedCIDRs: []string{"not-a-cidr"}}); err == nil { + t.Fatal("want error: bad CIDR") + } +} + +func TestIntegrationWithChiRouter(t *testing.T) { + mw := testMW(t) + r := chi.NewRouter() + r.Use(mw.Handler) + r.Get("/v1/nodes", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("nodes")) + }) + + srv := httptest.NewServer(r) + defer srv.Close() + + // Through-the-stack request with a non-CDN client IP (httptest loopback) + // must be rejected even with the correct header. + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/nodes", nil) + req.Header.Set(DefaultHeader, "secret-current") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("loopback (non-CDN) should be 403, got %d", resp.StatusCode) + } +}