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>
150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
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,
|
|
}
|
|
}
|