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>
283 lines
7.6 KiB
Go
283 lines
7.6 KiB
Go
// Command endpoint-signer is the OFFLINE CLI that signs endpoint and notice
|
|
// distribution documents with an Ed25519 key (doc/06 §3).
|
|
//
|
|
// It is meant to run on an air-gapped machine. The private key never leaves that
|
|
// machine; only the produced *.v1.json (and the public key, embedded in the
|
|
// client) are distributed.
|
|
//
|
|
// Subcommands:
|
|
//
|
|
// keygen -out-prefix <p> [-key-id id] generate an offline keypair
|
|
// sign -type endpoints|notices -in payload.json -key priv.key -key-id id
|
|
// -version N [-prev prev.json] [-issued-at RFC3339] [-out out.json]
|
|
// verify -in doc.json -keys "id=b64[,id2=b64]" [-type ...] [-min-version N]
|
|
// inspect -in doc.json
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/infra/domains/tools/internal/endpoint"
|
|
"github.com/wangjia/pangolin/infra/domains/tools/internal/notice"
|
|
"github.com/wangjia/pangolin/infra/domains/tools/internal/sign"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "keygen":
|
|
err = cmdKeygen(os.Args[2:])
|
|
case "sign":
|
|
err = cmdSign(os.Args[2:])
|
|
case "verify":
|
|
err = cmdVerify(os.Args[2:])
|
|
case "inspect":
|
|
err = cmdInspect(os.Args[2:])
|
|
case "-h", "--help", "help":
|
|
usage()
|
|
return
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `endpoint-signer — offline Ed25519 signer for endpoint/notice distribution
|
|
|
|
keygen -out-prefix <p> [-key-id id]
|
|
sign -type endpoints|notices -in payload.json -key priv.key -key-id id -version N [-prev prev.json] [-issued-at RFC3339] [-out out.json]
|
|
verify -in doc.json -keys "id=b64[,id2=b64]" [-type endpoints|notices] [-min-version N]
|
|
inspect -in doc.json
|
|
`)
|
|
}
|
|
|
|
func cmdKeygen(args []string) error {
|
|
fs := newFlagSet("keygen")
|
|
outPrefix := fs.String("out-prefix", "", "output prefix; writes <prefix>.ed25519.key (private, 0600) and <prefix>.ed25519.pub")
|
|
keyID := fs.String("key-id", "", "suggested key_id label (informational)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *outPrefix == "" {
|
|
return errors.New("-out-prefix is required")
|
|
}
|
|
pub, priv, err := sign.GenerateKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
privPath := *outPrefix + ".ed25519.key"
|
|
pubPath := *outPrefix + ".ed25519.pub"
|
|
if err := os.WriteFile(privPath, []byte(sign.EncodePrivate(priv)+"\n"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(pubPath, []byte(sign.EncodePublic(pub)+"\n"), 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("private key (0600, store OFFLINE in TWO locations): %s\n", privPath)
|
|
fmt.Printf("public key (embed in client install package): %s\n", pubPath)
|
|
fmt.Printf("public key (base64): %s\n", sign.EncodePublic(pub))
|
|
if *keyID != "" {
|
|
fmt.Printf("key ring spec: %s=%s\n", *keyID, sign.EncodePublic(pub))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdSign(args []string) error {
|
|
fs := newFlagSet("sign")
|
|
docType := fs.String("type", "endpoints", "payload type: endpoints|notices")
|
|
in := fs.String("in", "", "input payload JSON file")
|
|
keyFile := fs.String("key", "", "offline private key file (base64)")
|
|
keyID := fs.String("key-id", "", "key_id stamped into the document")
|
|
version := fs.Uint64("version", 0, "monotonic version (0 = derive from -prev + 1)")
|
|
prev := fs.String("prev", "", "previous signed document; version defaults to its version + 1")
|
|
issuedAt := fs.String("issued-at", "", "RFC3339 UTC timestamp (default: now)")
|
|
out := fs.String("out", "", "output file (default: stdout)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" || *keyFile == "" || *keyID == "" {
|
|
return errors.New("-in, -key and -key-id are required")
|
|
}
|
|
|
|
keyBytes, err := os.ReadFile(*keyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
priv, err := sign.DecodePrivate(string(keyBytes))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ver := *version
|
|
if ver == 0 {
|
|
if *prev == "" {
|
|
return errors.New("-version is required when -prev is not given")
|
|
}
|
|
prevRaw, err := os.ReadFile(*prev)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
prevEnv, err := sign.Parse(prevRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ver = prevEnv.Version + 1
|
|
}
|
|
|
|
ts := *issuedAt
|
|
if ts == "" {
|
|
ts = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
payloadRaw, err := os.ReadFile(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var env sign.Envelope
|
|
switch *docType {
|
|
case "endpoints":
|
|
var p endpoint.Payload
|
|
if err := json.Unmarshal(payloadRaw, &p); err != nil {
|
|
return fmt.Errorf("parse endpoints payload: %w", err)
|
|
}
|
|
env, err = endpoint.Build(p, *keyID, ver, ts, priv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "notices":
|
|
var l notice.List
|
|
if err := json.Unmarshal(payloadRaw, &l); err != nil {
|
|
return fmt.Errorf("parse notices payload: %w", err)
|
|
}
|
|
if err := l.Validate(); err != nil {
|
|
return err
|
|
}
|
|
canon, err := json.Marshal(l)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env = sign.Envelope{Version: ver, IssuedAt: ts, KeyID: *keyID, Payload: canon}
|
|
if err := sign.Sign(priv, &env); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown -type %q", *docType)
|
|
}
|
|
|
|
rendered, err := sign.Marshal(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rendered = append(rendered, '\n')
|
|
if *out == "" {
|
|
_, err = os.Stdout.Write(rendered)
|
|
return err
|
|
}
|
|
if err := os.WriteFile(*out, rendered, 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "signed %s document v%d -> %s\n", *docType, ver, *out)
|
|
return nil
|
|
}
|
|
|
|
func cmdVerify(args []string) error {
|
|
fs := newFlagSet("verify")
|
|
in := fs.String("in", "", "signed document file")
|
|
keys := fs.String("keys", "", "key ring: id=base64pub[,id2=base64pub] (multiple = rotation window)")
|
|
docType := fs.String("type", "endpoints", "payload type for schema validation: endpoints|notices")
|
|
minVersion := fs.Uint64("min-version", 0, "reject documents with version <= this (anti-rollback)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" || *keys == "" {
|
|
return errors.New("-in and -keys are required")
|
|
}
|
|
ring, err := sign.ParseKeyRing([]string{*keys})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := os.ReadFile(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch *docType {
|
|
case "endpoints":
|
|
env, p, err := endpoint.VerifyDocument(raw, ring, *minVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("OK: endpoints v%d key_id=%s api_domains=%d mirror_urls=%d\n",
|
|
env.Version, env.KeyID, len(p.APIDomains), len(p.MirrorURLs))
|
|
case "notices":
|
|
env, err := sign.Parse(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := sign.Verify(env, ring); err != nil {
|
|
return err
|
|
}
|
|
if env.Version <= *minVersion {
|
|
return endpoint.ErrRollback
|
|
}
|
|
var l notice.List
|
|
if err := json.Unmarshal(env.Payload, &l); err != nil {
|
|
return err
|
|
}
|
|
if err := l.Validate(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("OK: notices v%d key_id=%s notices=%d\n", env.Version, env.KeyID, len(l.Notices))
|
|
default:
|
|
return fmt.Errorf("unknown -type %q", *docType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdInspect(args []string) error {
|
|
fs := newFlagSet("inspect")
|
|
in := fs.String("in", "", "signed document file")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" {
|
|
return errors.New("-in is required")
|
|
}
|
|
raw, err := os.ReadFile(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env, err := sign.Parse(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("version: %d\n", env.Version)
|
|
fmt.Printf("issued_at: %s\n", env.IssuedAt)
|
|
fmt.Printf("key_id: %s\n", env.KeyID)
|
|
fmt.Printf("sig: %s...\n", truncate(env.Sig, 16))
|
|
fmt.Printf("payload: %s\n", string(env.Payload))
|
|
return nil
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n]
|
|
}
|