Files
pangolin/server/internal/originauth/originauth.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

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