feat(infra/domains): 域名池 + CDN 前置 + 签名端点分发 (tsk_NU9JuUweHWMt)
- 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 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -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
|
||||
|
||||
@@ -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` 语义说明。
|
||||
@@ -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、无身份信息(见各文件头部约束)。
|
||||
@@ -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`。
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):服务器、域名、支付、邮箱之间不得产生可被关联的身份线索;管理操作只在专用环境进行。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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=<base64pub>[,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=<base64pub>" -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、身份信息一律不出现在本目录。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <p> [-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 <p> [-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 <prefix>.ed25519.key (private, 0600) and <prefix>.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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/wangjia/pangolin/infra/domains/tools
|
||||
|
||||
go 1.25.0
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package mirror publishes a signed distribution document to ≥3 independent
|
||||
// mirrors (Cloudflare Pages / GitHub <separate account> / 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": <monotonic uint64>, // anti-rollback: clients only accept larger
|
||||
// "issued_at": "<RFC3339 UTC>",
|
||||
// "key_id": "<key identifier>",
|
||||
// "payload": { ... arbitrary JSON ... },
|
||||
// "sig": "<base64(ed25519 signature)>"
|
||||
// }
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: <random>) 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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user