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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user