// 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 }