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,149 @@
|
||||
package originauth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// DefaultHeader is the request header the CDN injects on origin requests.
|
||||
const DefaultHeader = "X-Origin-Auth"
|
||||
|
||||
// errForbidden is the desensitised 403 body (no "VPN"/"proxy" wording).
|
||||
var errForbidden = &apierr.Error{
|
||||
Code: "FORBIDDEN",
|
||||
MessageZH: "访问被拒绝",
|
||||
MessageEn: "Access denied",
|
||||
}
|
||||
|
||||
// Config configures the middleware.
|
||||
type Config struct {
|
||||
// Header is the auth header name. Empty defaults to DefaultHeader.
|
||||
Header string
|
||||
// Current is the active auth value (required).
|
||||
Current string
|
||||
// Previous is the prior auth value accepted during a rotation window
|
||||
// (optional).
|
||||
Previous string
|
||||
// AllowedCIDRs are the CDN egress ranges permitted to reach the origin
|
||||
// (required, at least one).
|
||||
AllowedCIDRs []string
|
||||
}
|
||||
|
||||
// Middleware enforces CDN-only origin access.
|
||||
type Middleware struct {
|
||||
header string
|
||||
current string
|
||||
previous string
|
||||
nets []netip.Prefix
|
||||
}
|
||||
|
||||
// New validates cfg and builds a Middleware.
|
||||
func New(cfg Config) (*Middleware, error) {
|
||||
header := cfg.Header
|
||||
if header == "" {
|
||||
header = DefaultHeader
|
||||
}
|
||||
if cfg.Current == "" {
|
||||
return nil, fmt.Errorf("originauth: Current auth value is required")
|
||||
}
|
||||
if len(cfg.AllowedCIDRs) == 0 {
|
||||
return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required")
|
||||
}
|
||||
nets := make([]netip.Prefix, 0, len(cfg.AllowedCIDRs))
|
||||
for _, c := range cfg.AllowedCIDRs {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("originauth: invalid CIDR %q: %w", c, err)
|
||||
}
|
||||
nets = append(nets, p.Masked())
|
||||
}
|
||||
if len(nets) == 0 {
|
||||
return nil, fmt.Errorf("originauth: at least one AllowedCIDR is required")
|
||||
}
|
||||
return &Middleware{
|
||||
header: header,
|
||||
current: cfg.Current,
|
||||
previous: cfg.Previous,
|
||||
nets: nets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handler is the net/http middleware. It calls next only when both the source
|
||||
// IP and the auth header are accepted; otherwise it writes a 403.
|
||||
func (m *Middleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.ipAllowed(r.RemoteAddr) || !m.headerAllowed(r.Header.Get(m.header)) {
|
||||
apierr.WriteJSON(w, http.StatusForbidden, errForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ipAllowed reports whether the TCP peer address falls in an allowed CDN range.
|
||||
// It uses the real connection peer (RemoteAddr), never a client-supplied header,
|
||||
// so a forged X-Forwarded-For cannot bypass the check.
|
||||
func (m *Middleware) ipAllowed(remoteAddr string) bool {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(host))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range m.nets {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// headerAllowed reports whether v matches the current or previous auth value,
|
||||
// using constant-time comparison.
|
||||
func (m *Middleware) headerAllowed(v string) bool {
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(v), []byte(m.current)) == 1 {
|
||||
return true
|
||||
}
|
||||
if m.previous != "" && subtle.ConstantTimeCompare([]byte(v), []byte(m.previous)) == 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FromEnv builds a Config from environment variables:
|
||||
//
|
||||
// ORIGIN_AUTH_HEADER (optional, default X-Origin-Auth)
|
||||
// ORIGIN_AUTH_CURRENT (required)
|
||||
// ORIGIN_AUTH_PREVIOUS (optional, rotation window)
|
||||
// ORIGIN_AUTH_CIDRS (required, comma-separated CDN egress ranges)
|
||||
//
|
||||
// The returned Config is still passed to New for validation.
|
||||
func FromEnv() Config {
|
||||
var cidrs []string
|
||||
if raw := os.Getenv("ORIGIN_AUTH_CIDRS"); raw != "" {
|
||||
cidrs = strings.Split(raw, ",")
|
||||
}
|
||||
return Config{
|
||||
Header: os.Getenv("ORIGIN_AUTH_HEADER"),
|
||||
Current: os.Getenv("ORIGIN_AUTH_CURRENT"),
|
||||
Previous: os.Getenv("ORIGIN_AUTH_PREVIOUS"),
|
||||
AllowedCIDRs: cidrs,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user