Files
pangolin/infra/domains/tools/internal/mirror/mirror.go
T
wangjia 7d89ec9d91 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>
2026-06-13 14:21:55 +08:00

175 lines
5.6 KiB
Go

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