Files
pangolin/server/internal/admin/config.go
T
wangjia 6e99e32285 feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:

- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
  mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
  TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
  首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
  1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
     批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
  2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
     draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
  3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
  (登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)

测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:49:32 +08:00

211 lines
6.0 KiB
Go

package admin
import (
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"os"
"strings"
"time"
)
// Config holds all configuration for the admin backend listener.
//
// Security invariants enforced here (see doc/02 §1 and doc/06 §2 红线
// "管理后台不得暴露公网"):
// - Listen must never bind 0.0.0.0 / :: / an empty host; only a concrete
// loopback or internal address is accepted.
// - AllowCIDRs defaults to loopback + RFC1918 / ULA internal ranges only.
type Config struct {
// Listen is the admin HTTP listen address, e.g. "127.0.0.1:9443".
Listen string
// AllowCIDRs is the IP allowlist applied by mw_ipallow. A request whose
// source address is not contained in any of these networks is rejected
// with 403 before any handler runs.
AllowCIDRs []*net.IPNet
// SecretKey is the 32-byte key (AES-256) used to encrypt TOTP secrets at
// rest and to sign session/CSRF tokens' opaque ids are random, not signed.
SecretKey []byte
// SessionTTL is the sliding idle timeout for an admin session.
SessionTTL time.Duration
// LoginFailMax is the number of consecutive failed logins (per username)
// before the account is temporarily locked.
LoginFailMax int
// LoginLockDuration is how long a username stays locked after hitting
// LoginFailMax.
LoginLockDuration time.Duration
// CookieSecure controls the Secure attribute on the session cookie.
// Defaults to true; only disabled explicitly for local/dev over plain HTTP.
CookieSecure bool
}
// defaultInternalCIDRs are the loopback and private/ULA ranges allowed by
// default — the admin port must only be reachable over SSH tunnel / intranet.
var defaultInternalCIDRs = []string{
"127.0.0.0/8",
"::1/128",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
}
// FromEnv builds a Config from environment variables, applying safe defaults.
//
// ADMIN_LISTEN listen address (default 127.0.0.1:9443)
// ADMIN_ALLOW_CIDRS comma-separated allowlist(default internal ranges)
// ADMIN_SECRET_KEY hex/base64 32-byte key (required)
// ADMIN_SESSION_TTL Go duration (default 30m)
// ADMIN_LOGIN_FAIL_MAX int (default 5)
// ADMIN_LOGIN_LOCK Go duration (default 15m)
// ADMIN_COOKIE_INSECURE "1" disables Secure flag (dev only)
func FromEnv() (*Config, error) {
c := &Config{
Listen: getEnvDefault("ADMIN_LISTEN", "127.0.0.1:9443"),
SessionTTL: 30 * time.Minute,
LoginFailMax: 5,
LoginLockDuration: 15 * time.Minute,
CookieSecure: os.Getenv("ADMIN_COOKIE_INSECURE") != "1",
}
if err := validateListen(c.Listen); err != nil {
return nil, err
}
cidrs := os.Getenv("ADMIN_ALLOW_CIDRS")
var raw []string
if strings.TrimSpace(cidrs) == "" {
raw = defaultInternalCIDRs
} else {
raw = splitTrim(cidrs)
}
nets, err := ParseCIDRs(raw)
if err != nil {
return nil, err
}
c.AllowCIDRs = nets
key, err := parseSecretKey(os.Getenv("ADMIN_SECRET_KEY"))
if err != nil {
return nil, err
}
c.SecretKey = key
if v := os.Getenv("ADMIN_SESSION_TTL"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("config: ADMIN_SESSION_TTL: %w", err)
}
c.SessionTTL = d
}
if v := os.Getenv("ADMIN_LOGIN_LOCK"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("config: ADMIN_LOGIN_LOCK: %w", err)
}
c.LoginLockDuration = d
}
if v := os.Getenv("ADMIN_LOGIN_FAIL_MAX"); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
return nil, fmt.Errorf("config: ADMIN_LOGIN_FAIL_MAX must be a positive integer")
}
c.LoginFailMax = n
}
return c, nil
}
// validateListen rejects any address that would expose the admin port on a
// public/wildcard interface. This is the code-level guard behind the red line.
func validateListen(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("config: ADMIN_LISTEN %q invalid: %w", addr, err)
}
host = strings.TrimSpace(host)
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
return fmt.Errorf("config: ADMIN_LISTEN must bind a concrete internal address, not a wildcard (%q)", addr)
}
ip := net.ParseIP(host)
if ip == nil {
// A hostname (e.g. an internal DNS name) is permitted; we cannot resolve
// here, but we have rejected the obvious wildcard forms above.
return nil
}
if ip.IsUnspecified() {
return fmt.Errorf("config: ADMIN_LISTEN must not be the unspecified address (%q)", addr)
}
return nil
}
// ParseCIDRs parses a list of CIDR strings into *net.IPNet.
func ParseCIDRs(raw []string) ([]*net.IPNet, error) {
nets := make([]*net.IPNet, 0, len(raw))
for _, r := range raw {
r = strings.TrimSpace(r)
if r == "" {
continue
}
// Allow bare IPs by appending the host-route mask.
if !strings.Contains(r, "/") {
if strings.Contains(r, ":") {
r += "/128"
} else {
r += "/32"
}
}
_, n, err := net.ParseCIDR(r)
if err != nil {
return nil, fmt.Errorf("config: invalid CIDR %q: %w", r, err)
}
nets = append(nets, n)
}
if len(nets) == 0 {
return nil, fmt.Errorf("config: empty IP allowlist")
}
return nets, nil
}
func parseSecretKey(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("config: ADMIN_SECRET_KEY is required (32-byte hex or base64)")
}
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
return nil, fmt.Errorf("config: ADMIN_SECRET_KEY must decode to exactly 32 bytes (hex or base64)")
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func splitTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}