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