6e99e32285
实现「一个二进制三个监听」中的管理端:
- 独立监听 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>
211 lines
6.0 KiB
Go
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
|
|
}
|