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 }