7d89ec9d91
- 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>
175 lines
5.6 KiB
Go
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
|
|
}
|