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>
This commit is contained in:
wangjia
2026-06-13 14:49:32 +08:00
parent 787151245e
commit 6e99e32285
36 changed files with 3695 additions and 2 deletions
+166
View File
@@ -0,0 +1,166 @@
// Command adminctl manages admin-backend accounts from the CLI.
//
// The first administrator must be created here (the web UI has no self-signup):
//
// adminctl create -username alice
//
// Requires DB_DSN and ADMIN_SECRET_KEY in the environment (same as the server).
// On success it prints the otpauth:// provisioning URI and Base32 secret to the
// terminal so the operator can add it to an authenticator app / render a QR.
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
"fmt"
"os"
"strings"
"golang.org/x/term"
"github.com/wangjia/pangolin/server/internal/admin"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/totp"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "create":
if err := runCreate(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
case "-h", "--help", "help":
usage()
default:
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stdout, "adminctl — Pangolin admin account tool")
fmt.Fprintln(os.Stdout, "")
fmt.Fprintln(os.Stdout, "Usage:")
fmt.Fprintln(os.Stdout, " adminctl create -username <name>")
fmt.Fprintln(os.Stdout, "")
fmt.Fprintln(os.Stdout, "Env: DB_DSN, ADMIN_SECRET_KEY (32-byte hex/base64)")
}
func runCreate(args []string) error {
var username string
fs := flag.NewFlagSet("create", flag.ContinueOnError)
fs.StringVar(&username, "username", "", "admin username")
if err := fs.Parse(args); err != nil {
return err
}
username = strings.TrimSpace(username)
if username == "" {
return errors.New("-username is required")
}
dsn := os.Getenv("DB_DSN")
if dsn == "" {
return errors.New("DB_DSN is required")
}
keyStr := os.Getenv("ADMIN_SECRET_KEY")
if keyStr == "" {
return errors.New("ADMIN_SECRET_KEY is required")
}
key, err := parseSecretKey(keyStr)
if err != nil {
return err
}
password, err := readPassword()
if err != nil {
return err
}
if len(password) < 8 {
return errors.New("password must be at least 8 characters")
}
pwHash, err := admin.HashPassword(password)
if err != nil {
return err
}
secret, err := totp.GenerateSecret()
if err != nil {
return err
}
enc, err := admin.EncryptSecret(key, secret)
if err != nil {
return err
}
database, err := db.Open(dsn)
if err != nil {
return err
}
defer database.Close()
store := admin.NewDBStore(database)
id, err := store.CreateAdmin(context.Background(), username, pwHash, enc)
if err != nil {
return err
}
uri := totp.ProvisioningURI(secret, username, "Pangolin")
fmt.Println()
fmt.Printf("✓ 已创建管理员 #%d: %s\n", id, username)
fmt.Println()
fmt.Println("请在身份验证器中添加以下 TOTP(二维码可由下方 otpauth URI 生成):")
fmt.Println(" Secret (Base32):", secret)
fmt.Println(" otpauth URI :", uri)
fmt.Println()
fmt.Println("登录需输入:用户名 + 密码 + 6 位动态验证码。")
return nil
}
// readPassword reads a password from ADMIN_PASSWORD or, if unset, prompts on the
// terminal with echo disabled (falling back to a plain stdin line).
func readPassword() (string, error) {
if v := os.Getenv("ADMIN_PASSWORD"); v != "" {
return v, nil
}
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Print("Password: ")
b, err := term.ReadPassword(fd)
fmt.Println()
if err != nil {
return "", err
}
return string(b), nil
}
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// parseSecretKey accepts a 32-byte key as hex or base64 (mirrors the admin
// package's own loader so the CLI and server agree on the key format).
func parseSecretKey(s string) ([]byte, error) {
s = strings.TrimSpace(s)
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, errors.New("ADMIN_SECRET_KEY must decode to exactly 32 bytes (hex or base64)")
}
+53
View File
@@ -9,6 +9,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/wangjia/pangolin/server/internal/admin"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/redisutil"
)
func main() {
@@ -23,6 +27,10 @@ func main() {
}
}
// Admin backend (separate internal listener). Started only when explicitly
// configured so the public API can run on its own. See internal/admin.
startAdminIfConfigured()
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -38,3 +46,48 @@ func main() {
log.Fatalf("server error: %v", err)
}
}
// startAdminIfConfigured launches the admin listener in a background goroutine
// when ADMIN_SECRET_KEY and DB_DSN are present. The admin port binds an
// internal address only (enforced by admin.FromEnv) and is fronted by an IP
// allowlist + two-factor login.
func startAdminIfConfigured() {
if os.Getenv("ADMIN_SECRET_KEY") == "" || os.Getenv("DB_DSN") == "" {
log.Printf("admin backend disabled (set DB_DSN and ADMIN_SECRET_KEY to enable)")
return
}
cfg, err := admin.FromEnv()
if err != nil {
log.Fatalf("admin config: %v", err)
}
database, err := db.Open(os.Getenv("DB_DSN"))
if err != nil {
log.Fatalf("admin db: %v", err)
}
rdb, err := redisutil.New(
getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"),
os.Getenv("REDIS_PASSWORD"), 0)
if err != nil {
log.Fatalf("admin redis: %v", err)
}
svc := admin.BuildServices(database, rdb, 5, cfg.LoginLockDuration)
handler, err := admin.NewHandler(cfg, database, rdb, svc, log.Default())
if err != nil {
log.Fatalf("admin handler: %v", err)
}
go func() {
log.Printf("admin backend listening on %s (internal only)", cfg.Listen)
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
log.Fatalf("admin server error: %v", err)
}
}()
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+2 -2
View File
@@ -12,6 +12,8 @@ require (
github.com/testcontainers/testcontainers-go v0.34.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0
github.com/testcontainers/testcontainers-go/modules/redis v0.34.0
golang.org/x/crypto v0.48.0
golang.org/x/term v0.40.0
google.golang.org/grpc v1.81.1
)
@@ -208,14 +210,12 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.42.0 // indirect
+132
View File
@@ -0,0 +1,132 @@
package admin
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/totp"
)
// Login outcome sentinel errors.
var (
// ErrInvalidCredentials is returned for any wrong username / password /
// TOTP combination. It is deliberately generic to avoid user enumeration.
ErrInvalidCredentials = errors.New("admin: invalid credentials")
// ErrLockedOut is returned when the username is temporarily locked after
// too many consecutive failures.
ErrLockedOut = errors.New("admin: account temporarily locked")
)
const loginFailKeyPrefix = "admin:loginfail:"
// Authenticator performs two-factor admin login with failure rate-limiting.
type Authenticator struct {
store Store
sessions *SessionStore
rdb *redis.Client
secret []byte
failMax int
lockDur time.Duration
sec *SecurityLog
// now is overridable in tests.
now func() time.Time
}
// NewAuthenticator wires an Authenticator.
func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator {
return &Authenticator{
store: store,
sessions: sessions,
rdb: rdb,
secret: cfg.SecretKey,
failMax: cfg.LoginFailMax,
lockDur: cfg.LoginLockDuration,
sec: sec,
now: func() time.Time { return time.Now().UTC() },
}
}
// Login validates username + password + TOTP and, on success, creates a
// session and returns its id. Every failure is rate-limited and recorded as a
// security event in the audit log (red line: admin records only security
// events, never routine access).
func (a *Authenticator) Login(ctx context.Context, username, password, code, remoteIP string) (sid string, sess *Session, err error) {
locked, lerr := a.isLocked(ctx, username)
if lerr != nil {
return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr)
}
if locked {
a.sec.LoginLocked(ctx, username, remoteIP)
return "", nil, ErrLockedOut
}
admin, gerr := a.store.GetAdminByUsername(ctx, username)
if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) {
return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr)
}
if admin == nil || admin.Status != "active" || !VerifyPassword(admin.PwHash, password) {
a.recordFail(ctx, username)
a.sec.LoginFail(ctx, username, remoteIP, "bad_password")
return "", nil, ErrInvalidCredentials
}
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
if derr != nil {
a.recordFail(ctx, username)
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
return "", nil, ErrInvalidCredentials
}
if !totp.Validate(secret, code, a.now(), 1) {
a.recordFail(ctx, username)
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
return "", nil, ErrInvalidCredentials
}
// Success: clear counter, stamp login, create session.
a.clearFail(ctx, username)
if uerr := a.store.UpdateLastLogin(ctx, admin.ID, a.now()); uerr != nil {
return "", nil, fmt.Errorf("admin.Login update: %w", uerr)
}
sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username)
if serr != nil {
return "", nil, serr
}
a.sec.LoginOK(ctx, username, remoteIP)
return sid, sess, nil
}
func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) {
if a.rdb == nil {
return false, nil
}
n, err := a.rdb.Get(ctx, loginFailKeyPrefix+username).Int()
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil {
return false, err
}
return n >= a.failMax, nil
}
func (a *Authenticator) recordFail(ctx context.Context, username string) {
if a.rdb == nil {
return
}
key := loginFailKeyPrefix + username
pipe := a.rdb.Pipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, a.lockDur)
_, _ = pipe.Exec(ctx)
}
func (a *Authenticator) clearFail(ctx context.Context, username string) {
if a.rdb == nil {
return
}
_ = a.rdb.Del(ctx, loginFailKeyPrefix+username).Err()
}
+116
View File
@@ -0,0 +1,116 @@
package admin
import (
"context"
"crypto/rand"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/totp"
)
// newTestAdmin seeds an active admin and returns its TOTP secret.
func newTestAdmin(t *testing.T, store *fakeStore, key []byte, username, password string) string {
t.Helper()
hash, err := HashPassword(password)
if err != nil {
t.Fatal(err)
}
secret, err := totp.GenerateSecret()
if err != nil {
t.Fatal(err)
}
enc, err := EncryptSecret(key, secret)
if err != nil {
t.Fatal(err)
}
store.admins[username] = &Admin{ID: 1, Username: username, PwHash: hash, TOTPSecretEnc: enc, Status: "active"}
return secret
}
func newTestAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) {
t.Helper()
rdb, _ := newTestRedis(t)
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
store := newFakeStore()
cfg := &Config{SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute, SessionTTL: 30 * time.Minute}
sessions := NewSessionStore(rdb, cfg.SessionTTL)
sec := NewSecurityLog(store, nil)
return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key
}
func TestLogin_Success(t *testing.T) {
auth, store, key := newTestAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
sid, sess, err := auth.Login(context.Background(), "alice", "s3cret-pass", code, "127.0.0.1")
if err != nil {
t.Fatalf("login failed: %v", err)
}
if sid == "" || sess == nil {
t.Fatal("no session returned")
}
if _, ok := store.lastLogin[1]; !ok {
t.Error("last login not recorded")
}
if len(store.auditFor("admin_login_ok")) != 1 {
t.Error("successful login not audited")
}
}
func TestLogin_WrongPassword(t *testing.T) {
auth, store, key := newTestAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
_, _, err := auth.Login(context.Background(), "alice", "WRONG", code, "127.0.0.1")
if err != ErrInvalidCredentials {
t.Fatalf("err = %v; want ErrInvalidCredentials", err)
}
if len(store.auditFor("admin_login_fail")) != 1 {
t.Error("failed login not audited")
}
}
func TestLogin_WrongTOTP(t *testing.T) {
auth, store, key := newTestAuth(t)
_ = newTestAdmin(t, store, key, "alice", "s3cret-pass")
_, _, err := auth.Login(context.Background(), "alice", "s3cret-pass", "000000", "127.0.0.1")
if err != ErrInvalidCredentials {
t.Fatalf("err = %v; want ErrInvalidCredentials", err)
}
}
func TestLogin_UnknownUser(t *testing.T) {
auth, _, _ := newTestAuth(t)
_, _, err := auth.Login(context.Background(), "ghost", "x", "000000", "127.0.0.1")
if err != ErrInvalidCredentials {
t.Fatalf("err = %v; want ErrInvalidCredentials", err)
}
}
func TestLogin_LockoutAfterFailures(t *testing.T) {
auth, store, key := newTestAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
ctx := context.Background()
// 3 failures hit the cap.
for i := 0; i < 3; i++ {
if _, _, err := auth.Login(ctx, "alice", "WRONG", "000000", "127.0.0.1"); err != ErrInvalidCredentials {
t.Fatalf("attempt %d err = %v", i, err)
}
}
// Now even a correct credential is locked out.
code, _ := totp.Code(secret, time.Now().UTC())
if _, _, err := auth.Login(ctx, "alice", "s3cret-pass", code, "127.0.0.1"); err != ErrLockedOut {
t.Fatalf("err = %v; want ErrLockedOut", err)
}
if len(store.auditFor("admin_login_locked")) == 0 {
t.Error("lockout not audited")
}
}
+210
View File
@@ -0,0 +1,210 @@
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
}
+95
View File
@@ -0,0 +1,95 @@
package admin
import (
"encoding/hex"
"net"
"strings"
"testing"
)
func TestValidateListen_RejectsPublic(t *testing.T) {
bad := []string{"0.0.0.0:9443", ":9443", "[::]:9443", "::"}
for _, addr := range bad {
if err := validateListen(addr); err == nil {
t.Errorf("validateListen(%q) = nil; want error (public bind)", addr)
}
}
good := []string{"127.0.0.1:9443", "10.0.0.5:9443", "192.168.1.2:9090", "internal.host:9443"}
for _, addr := range good {
if err := validateListen(addr); err != nil {
t.Errorf("validateListen(%q) = %v; want nil", addr, err)
}
}
}
func TestParseSecretKey(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
if _, err := parseSecretKey(hex.EncodeToString(key)); err != nil {
t.Errorf("hex key rejected: %v", err)
}
if _, err := parseSecretKey("short"); err == nil {
t.Error("short key accepted")
}
if _, err := parseSecretKey(""); err == nil {
t.Error("empty key accepted")
}
}
func TestParseCIDRs_Defaults(t *testing.T) {
nets, err := ParseCIDRs(defaultInternalCIDRs)
if err != nil {
t.Fatal(err)
}
// Loopback and an RFC1918 address must be inside; a public one must not.
inside := []string{"127.0.0.1", "10.1.2.3", "192.168.0.9", "172.16.5.5"}
for _, ip := range inside {
if !anyContains(nets, ip) {
t.Errorf("%s not covered by default allowlist", ip)
}
}
if anyContains(nets, "8.8.8.8") {
t.Error("public IP 8.8.8.8 unexpectedly allowed")
}
}
func TestParseCIDRs_BareIP(t *testing.T) {
nets, err := ParseCIDRs([]string{"203.0.113.7"})
if err != nil {
t.Fatal(err)
}
if !anyContains(nets, "203.0.113.7") {
t.Error("bare IP host route not matched")
}
if anyContains(nets, "203.0.113.8") {
t.Error("bare IP matched neighbour")
}
}
func TestParseCIDRs_Invalid(t *testing.T) {
if _, err := ParseCIDRs([]string{"not-a-cidr"}); err == nil {
t.Error("invalid CIDR accepted")
}
if _, err := ParseCIDRs(nil); err == nil {
t.Error("empty allowlist accepted")
}
}
func anyContains(nets []*net.IPNet, ip string) bool {
parsed := net.ParseIP(ip)
for _, n := range nets {
if n.Contains(parsed) {
return true
}
}
return false
}
func TestSplitTrim(t *testing.T) {
got := splitTrim(" a , b ,, c ")
if strings.Join(got, ",") != "a,b,c" {
t.Errorf("splitTrim = %v", got)
}
}
+126
View File
@@ -0,0 +1,126 @@
package admin
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These follow OWASP's "second" recommended profile
// (64 MiB, 1 iteration, parallelism 4) — strong yet fast enough for an
// interactive admin login.
const (
argonMemory = 64 * 1024 // KiB
argonTime = 1
argonParallelism = 4
argonSaltLen = 16
argonKeyLen = 32
)
var b64 = base64.RawStdEncoding
// HashPassword hashes a plaintext password with argon2id and returns a PHC
// formatted string: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>.
func HashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("admin.HashPassword: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonParallelism, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonParallelism,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches the given PHC-encoded
// argon2id hash, in constant time.
func VerifyPassword(encoded, password string) bool {
params, salt, want, err := decodePHC(encoded)
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, params.t, params.m, params.p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
type argonParams struct {
m uint32
t uint32
p uint8
}
func decodePHC(encoded string) (argonParams, []byte, []byte, error) {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
if len(parts) != 6 || parts[1] != "argon2id" {
return argonParams{}, nil, nil, errors.New("admin: malformed argon2 hash")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return argonParams{}, nil, nil, errors.New("admin: bad argon2 version")
}
var pr argonParams
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pr.m, &pr.t, &pr.p); err != nil {
return argonParams{}, nil, nil, errors.New("admin: bad argon2 params")
}
salt, err := b64.DecodeString(parts[4])
if err != nil {
return argonParams{}, nil, nil, errors.New("admin: bad argon2 salt")
}
hash, err := b64.DecodeString(parts[5])
if err != nil {
return argonParams{}, nil, nil, errors.New("admin: bad argon2 hash")
}
return pr, salt, hash, nil
}
// EncryptSecret seals plaintext with AES-256-GCM under key (32 bytes). The
// returned blob is nonce || ciphertext, suitable for storing in a VARBINARY
// column. Used to keep TOTP secrets encrypted at rest.
func EncryptSecret(key []byte, plaintext string) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("admin.EncryptSecret nonce: %w", err)
}
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}
// DecryptSecret reverses EncryptSecret.
func DecryptSecret(key, blob []byte) (string, error) {
gcm, err := newGCM(key)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(blob) < ns {
return "", errors.New("admin.DecryptSecret: ciphertext too short")
}
nonce, ct := blob[:ns], blob[ns:]
pt, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("admin.DecryptSecret: %w", err)
}
return string(pt), nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != 32 {
return nil, fmt.Errorf("admin: secret key must be 32 bytes, got %d", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("admin: aes cipher: %w", err)
}
return cipher.NewGCM(block)
}
+60
View File
@@ -0,0 +1,60 @@
package admin
import (
"bytes"
"crypto/rand"
"testing"
)
func TestPasswordHashVerify(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword(hash, "correct horse battery staple") {
t.Error("valid password rejected")
}
if VerifyPassword(hash, "wrong password") {
t.Error("wrong password accepted")
}
if VerifyPassword("not-a-phc-string", "x") {
t.Error("malformed hash accepted")
}
// Two hashes of the same password must differ (random salt).
hash2, _ := HashPassword("correct horse battery staple")
if hash == hash2 {
t.Error("identical hashes for same password — salt not applied")
}
}
func TestEncryptDecryptSecret(t *testing.T) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
const plain = "JBSWY3DPEHPK3PXP"
blob, err := EncryptSecret(key, plain)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(blob, []byte(plain)) {
t.Error("ciphertext contains plaintext secret")
}
got, err := DecryptSecret(key, blob)
if err != nil {
t.Fatal(err)
}
if got != plain {
t.Errorf("DecryptSecret = %q; want %q", got, plain)
}
// Wrong key must fail.
badKey := make([]byte, 32)
if _, err := DecryptSecret(badKey, blob); err == nil {
t.Error("decrypt with wrong key succeeded")
}
// Short key rejected.
if _, err := EncryptSecret(key[:16], plain); err == nil {
t.Error("encrypt accepted 16-byte key")
}
}
+464
View File
@@ -0,0 +1,464 @@
package admin
import (
"context"
"encoding/csv"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Handlers holds the dependencies for all admin HTTP handlers.
type Handlers struct {
cfg *Config
store Store
sessions *SessionStore
auth *Authenticator
svc Services
sec *SecurityLog
render *renderer
logger *log.Logger
}
// NewHandlers constructs the handler set.
func NewHandlers(cfg *Config, store Store, sessions *SessionStore, auth *Authenticator,
svc Services, sec *SecurityLog, logger *log.Logger) (*Handlers, error) {
r, err := newRenderer()
if err != nil {
return nil, err
}
if logger == nil {
logger = log.Default()
}
return &Handlers{
cfg: cfg, store: store, sessions: sessions, auth: auth,
svc: svc, sec: sec, render: r, logger: logger,
}, nil
}
const batchListLimit = 50
const auditListLimit = 50
// --------------------------------------------------------------------------
// Auth
// --------------------------------------------------------------------------
// LoginPage renders the login form (GET /login).
func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
h.render.render(w, "login", pageData{Flash: r.URL.Query().Get("e")})
}
// LoginSubmit processes the login form (POST /login).
func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.render.render(w, "login", pageData{Flash: "请求无效"})
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
code := strings.TrimSpace(r.PostFormValue("totp"))
ip := hostOnly(r.RemoteAddr)
sid, _, err := h.auth.Login(r.Context(), username, password, code, ip)
if err != nil {
flash := "用户名、密码或动态验证码有误"
if err == ErrLockedOut {
flash = "尝试过于频繁,账户已临时锁定,请稍后再试"
}
w.WriteHeader(http.StatusUnauthorized)
h.render.render(w, "login", pageData{Flash: flash})
return
}
h.setSessionCookie(w, sid)
http.Redirect(w, r, "/", http.StatusFound)
}
// Logout destroys the session (POST /logout).
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
if sess == nil || !h.validCSRF(r, sess) {
http.Error(w, "invalid csrf", http.StatusForbidden)
return
}
if c, err := r.Cookie(SessionCookieName); err == nil {
_ = h.sessions.Delete(r.Context(), c.Value)
}
h.clearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusFound)
}
// --------------------------------------------------------------------------
// Dashboard
// --------------------------------------------------------------------------
// Dashboard renders the landing page (GET /).
func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
h.render.render(w, "dashboard", pageData{
Username: sess.Username, CSRF: sess.CSRFToken, Active: "dashboard",
})
}
// --------------------------------------------------------------------------
// Code batches
// --------------------------------------------------------------------------
type codesView struct {
Batches []BatchSummary
HasPrev bool
HasNext bool
PrevOffset int
NextOffset int
}
// CodesPage lists batches and shows the generate form (GET /codes).
func (h *Handlers) CodesPage(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
batches, total, err := h.svc.Codes.ListBatches(r.Context(), batchListLimit, offset)
if err != nil {
h.serverError(w, "list batches", err)
return
}
view := codesView{
Batches: batches,
HasPrev: offset > 0,
PrevOffset: maxInt(0, offset-batchListLimit),
HasNext: offset+batchListLimit < total,
NextOffset: offset + batchListLimit,
}
h.render.render(w, "codes", pageData{
Username: sess.Username, CSRF: sess.CSRFToken, Active: "codes",
Flash: r.URL.Query().Get("flash"), Data: view,
})
}
// CreateBatch generates a batch and streams the plaintext CSV (POST /codes).
// The plaintext codes appear ONLY in this response body — never persisted or
// logged.
func (h *Handlers) CreateBatch(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !h.validCSRF(r, sess) {
http.Error(w, "invalid csrf", http.StatusForbidden)
return
}
plan := strings.TrimSpace(r.PostFormValue("plan"))
channel := strings.TrimSpace(r.PostFormValue("channel"))
note := strings.TrimSpace(r.PostFormValue("note"))
duration, derr := strconv.Atoi(r.PostFormValue("duration_days"))
count, cerr := strconv.Atoi(r.PostFormValue("count"))
if derr != nil || cerr != nil || duration < 1 || duration > 3650 || count < 1 || count > 5000 {
http.Error(w, "参数无效:时长 1-3650 天,数量 1-5000", http.StatusBadRequest)
return
}
batch, err := h.svc.Codes.CreateBatch(r.Context(), CodeBatchParams{
Plan: plan, DurationDays: duration, Count: count, Channel: channel,
Note: note, CreatedBy: "admin:" + sess.Username,
})
if err != nil {
h.serverError(w, "create batch", err)
return
}
// Audit: record metadata only (NEVER the plaintext codes).
h.writeAudit(r.Context(), sess.Username, "code_batch_create",
fmt.Sprintf("batch:%d", batch.BatchID),
fmt.Sprintf(`{"plan":%q,"duration_days":%d,"count":%d,"channel":%q}`,
batch.Plan, batch.DurationDays, len(batch.Codes), batch.Channel))
h.streamBatchCSV(w, batch)
}
// streamBatchCSV writes the one-time plaintext CSV download.
func (h *Handlers) streamBatchCSV(w http.ResponseWriter, batch *GeneratedBatch) {
filename := fmt.Sprintf("batch-%d-%s.csv", batch.BatchID, batch.GeneratedAt.Format("20060102T150405Z"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set("Cache-Control", "no-store")
cw := csv.NewWriter(w)
_ = cw.Write([]string{"index", "code", "plan", "duration_days", "batch_id", "channel"})
for i, code := range batch.Codes {
_ = cw.Write([]string{
strconv.Itoa(i + 1), code, batch.Plan,
strconv.Itoa(batch.DurationDays), strconv.FormatInt(batch.BatchID, 10), batch.Channel,
})
}
cw.Flush()
}
// VoidBatch voids all unused codes in a batch (POST /codes/void).
func (h *Handlers) VoidBatch(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
if !h.checkWrite(w, r, sess) {
return
}
batchID, err := strconv.ParseInt(r.PostFormValue("batch_id"), 10, 64)
if err != nil || batchID <= 0 {
http.Error(w, "bad batch_id", http.StatusBadRequest)
return
}
affected, verr := h.svc.Codes.VoidBatch(r.Context(), batchID)
if verr != nil {
h.serverError(w, "void batch", verr)
return
}
h.writeAudit(r.Context(), sess.Username, "code_batch_void",
fmt.Sprintf("batch:%d", batchID),
fmt.Sprintf(`{"voided":%d}`, affected))
h.redirectFlash(w, r, "/codes", fmt.Sprintf("已作废批次 %d 的 %d 个未使用激活码", batchID, affected))
}
// --------------------------------------------------------------------------
// Nodes
// --------------------------------------------------------------------------
type nodesView struct {
Nodes []NodeRow
ProvisionReady bool
LifecycleReady bool
}
// NodesPage lists nodes and operation controls (GET /nodes).
func (h *Handlers) NodesPage(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
nodes, err := h.store.ListNodes(r.Context(), 3)
if err != nil {
h.serverError(w, "list nodes", err)
return
}
h.render.render(w, "nodes", pageData{
Username: sess.Username, CSRF: sess.CSRFToken, Active: "nodes",
Flash: r.URL.Query().Get("flash"),
Data: nodesView{
Nodes: nodes,
ProvisionReady: h.svc.Provision.Ready(),
LifecycleReady: h.svc.Lifecycle.Ready(),
},
})
}
// NodeOp dispatches replace / draining / up (POST /nodes/op).
func (h *Handlers) NodeOp(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
if !h.checkWrite(w, r, sess) {
return
}
nodeID, err := strconv.ParseInt(r.PostFormValue("node_id"), 10, 64)
if err != nil || nodeID <= 0 {
http.Error(w, "bad node_id", http.StatusBadRequest)
return
}
op := r.PostFormValue("op")
var opErr error
var action string
switch op {
case "replace":
action = "node_replace"
opErr = h.svc.Provision.Replace(r.Context(), nodeID, sess.Username)
case "draining", "up":
action = "node_" + op
opErr = h.svc.Lifecycle.TransitionStatus(r.Context(), nodeID, op, sess.Username)
default:
http.Error(w, "unknown op", http.StatusBadRequest)
return
}
if opErr != nil {
if opErr == ErrServiceUnavailable {
h.redirectFlash(w, r, "/nodes", "该操作所依赖的服务尚未接入")
return
}
h.serverError(w, "node op", opErr)
return
}
h.writeAudit(r.Context(), sess.Username, action,
fmt.Sprintf("node:%d", nodeID), fmt.Sprintf(`{"op":%q}`, op))
h.redirectFlash(w, r, "/nodes", fmt.Sprintf("节点 %d 操作 %s 已执行", nodeID, op))
}
// --------------------------------------------------------------------------
// Audit
// --------------------------------------------------------------------------
type auditView struct {
Filter AuditFilter
FromStr string
ToStr string
Entries []AuditEntry
Total int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
NodeEvents []NodeEvent
NodeIDStr string
}
// AuditPage renders the filtered audit log and optional node events (GET /audit).
func (h *Handlers) AuditPage(w http.ResponseWriter, r *http.Request) {
sess := SessionFromContext(r.Context())
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
if offset < 0 {
offset = 0
}
fromStr := strings.TrimSpace(q.Get("from"))
toStr := strings.TrimSpace(q.Get("to"))
f := AuditFilter{
Actor: strings.TrimSpace(q.Get("actor")),
Action: strings.TrimSpace(q.Get("action")),
Target: strings.TrimSpace(q.Get("target")),
From: parseDate(fromStr, false),
To: parseDate(toStr, true),
Limit: auditListLimit,
Offset: offset,
}
entries, total, err := h.store.QueryAudit(r.Context(), f)
if err != nil {
h.serverError(w, "query audit", err)
return
}
var nodeEvents []NodeEvent
nodeIDStr := strings.TrimSpace(q.Get("node_id"))
if nodeIDStr != "" {
if nid, perr := strconv.ParseInt(nodeIDStr, 10, 64); perr == nil && nid > 0 {
nodeEvents, err = h.store.QueryNodeEvents(r.Context(), nid, 50)
if err != nil {
h.serverError(w, "query node events", err)
return
}
}
}
view := auditView{
Filter: f, FromStr: fromStr, ToStr: toStr,
Entries: entries, Total: total,
HasPrev: offset > 0, HasNext: offset+auditListLimit < total,
PrevURL: auditURL(q, maxInt(0, offset-auditListLimit)),
NextURL: auditURL(q, offset+auditListLimit),
NodeEvents: nodeEvents, NodeIDStr: nodeIDStr,
}
h.render.render(w, "audit", pageData{
Username: sess.Username, CSRF: sess.CSRFToken, Active: "audit", Data: view,
})
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
func (h *Handlers) validCSRF(r *http.Request, sess *Session) bool {
return sess != nil && sess.ValidCSRF(r.PostFormValue("csrf_token"))
}
// checkWrite enforces CSRF + explicit second confirmation for write ops.
func (h *Handlers) checkWrite(w http.ResponseWriter, r *http.Request, sess *Session) bool {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return false
}
if !h.validCSRF(r, sess) {
http.Error(w, "invalid csrf", http.StatusForbidden)
return false
}
if r.PostFormValue("confirm") != "yes" {
http.Error(w, "confirmation required", http.StatusBadRequest)
return false
}
return true
}
func (h *Handlers) writeAudit(ctx context.Context, actor, action, target, metaJSON string) {
if err := h.store.WriteAudit(ctx, actor, action, target, metaJSON); err != nil {
h.logger.Printf("admin: audit write failed action=%s target=%s: %v", action, target, err)
}
}
func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sid,
Path: "/",
HttpOnly: true,
Secure: h.cfg.CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.cfg.SessionTTL.Seconds()),
})
}
func (h *Handlers) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.cfg.CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (h *Handlers) redirectFlash(w http.ResponseWriter, r *http.Request, path, msg string) {
http.Redirect(w, r, path+"?flash="+url.QueryEscape(msg), http.StatusFound)
}
func (h *Handlers) serverError(w http.ResponseWriter, what string, err error) {
h.logger.Printf("admin: %s: %v", what, err)
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
}
func auditURL(q url.Values, offset int) string {
nq := url.Values{}
for _, k := range []string{"actor", "action", "target", "from", "to", "node_id"} {
if v := q.Get(k); v != "" {
nq.Set(k, v)
}
}
nq.Set("offset", strconv.Itoa(offset))
return "/audit?" + nq.Encode()
}
// parseDate accepts "2006-01-02" or RFC3339. When endOfDay is true a bare date
// is pushed to 23:59:59 so the range is inclusive.
func parseDate(s string, endOfDay bool) *time.Time {
if s == "" {
return nil
}
if t, err := time.ParseInLocation("2006-01-02", s, time.UTC); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Second)
}
return &t
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
tu := t.UTC()
return &tu
}
return nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+352
View File
@@ -0,0 +1,352 @@
package admin
import (
"context"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/totp"
)
type testEnv struct {
router http.Handler
sessions *SessionStore
store *fakeStore
codes *fakeCodes
life *recordingLifecycle
prov *recordingProvision
mr *miniredis.Miniredis
cfg *Config
key []byte
}
func newEnv(t *testing.T, provReady, lifeReady bool) *testEnv {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { rdb.Close() })
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
allow, _ := ParseCIDRs([]string{"127.0.0.0/8"})
cfg := &Config{
Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key,
SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute,
CookieSecure: false,
}
store := newFakeStore()
store.nodes = []NodeRow{{ID: 7, UUID: "u7", Region: "HK", NameZH: "香港节点", Tier: "pro", Status: "up", Weight: 100, Provider: "p"}}
codes := &fakeCodes{}
life := &recordingLifecycle{ready: lifeReady}
prov := &recordingProvision{ready: provReady}
sessions := NewSessionStore(rdb, cfg.SessionTTL)
sec := NewSecurityLog(store, nil)
auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
svc := Services{Codes: codes, Lifecycle: life, Provision: prov}
h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil)
if err != nil {
t.Fatal(err)
}
return &testEnv{
router: NewRouter(h, sessions, cfg, sec),
sessions: sessions, store: store, codes: codes, life: life, prov: prov,
mr: mr, cfg: cfg, key: key,
}
}
// login creates a session and returns the cookie and its CSRF token.
func (e *testEnv) login(t *testing.T) (*http.Cookie, string) {
t.Helper()
sid, sess, err := e.sessions.Create(context.Background(), 1, "alice")
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: SessionCookieName, Value: sid}, sess.CSRFToken
}
func (e *testEnv) do(t *testing.T, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if form != nil {
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, target, nil)
}
req.RemoteAddr = "127.0.0.1:40000"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
// --------------------------------------------------------------------------
func TestRouter_IPBlocked(t *testing.T) {
e := newEnv(t, true, true)
req := httptest.NewRequest("GET", "/login", nil)
req.RemoteAddr = "8.8.8.8:1234"
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status %d; want 403", rec.Code)
}
}
func TestRouter_UnauthRedirect(t *testing.T) {
e := newEnv(t, true, true)
rec := e.do(t, "GET", "/", nil, nil)
if rec.Code != http.StatusFound {
t.Fatalf("status %d; want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("redirect to %q; want /login", loc)
}
}
func TestLoginFlow_EndToEnd(t *testing.T) {
e := newEnv(t, true, true)
secret := newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {code}}
rec := e.do(t, "POST", "/login", form, nil)
if rec.Code != http.StatusFound {
t.Fatalf("login status %d; want 302", rec.Code)
}
var sc *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == SessionCookieName {
sc = c
}
}
if sc == nil {
t.Fatal("no session cookie set")
}
if !sc.HttpOnly || sc.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags wrong: HttpOnly=%v SameSite=%v", sc.HttpOnly, sc.SameSite)
}
// Authenticated dashboard now reachable.
rec2 := e.do(t, "GET", "/", nil, sc)
if rec2.Code != http.StatusOK {
t.Fatalf("dashboard status %d; want 200", rec2.Code)
}
}
func TestLoginFlow_BadTOTPRejected(t *testing.T) {
e := newEnv(t, true, true)
_ = newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass")
form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {"000000"}}
rec := e.do(t, "POST", "/login", form, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status %d; want 401", rec.Code)
}
if len(rec.Result().Cookies()) != 0 {
t.Error("cookie set despite failed login")
}
}
func TestCreateBatch_CSVAndAudit(t *testing.T) {
e := newEnv(t, true, true)
cookie, csrf := e.login(t)
e.codes.nextCodes = []string{"PLAINCODE-A", "PLAINCODE-B"}
form := url.Values{
"csrf_token": {csrf}, "plan": {"pro"}, "duration_days": {"30"},
"count": {"2"}, "channel": {"manual"}, "note": {"q2 promo"},
}
rec := e.do(t, "POST", "/codes", form, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("status %d; want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Errorf("content-type %q; want text/csv", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "PLAINCODE-A") || !strings.Contains(body, "PLAINCODE-B") {
t.Error("CSV missing plaintext codes")
}
// Service received the right params.
if len(e.codes.created) != 1 {
t.Fatalf("CreateBatch called %d times", len(e.codes.created))
}
got := e.codes.created[0]
if got.Plan != "pro" || got.DurationDays != 30 || got.Count != 2 || got.Channel != "manual" {
t.Errorf("batch params wrong: %+v", got)
}
if got.CreatedBy != "admin:alice" {
t.Errorf("CreatedBy = %q; want admin:alice", got.CreatedBy)
}
// Audit written, but with NO plaintext leaked into meta.
au := e.store.auditFor("code_batch_create")
if len(au) != 1 {
t.Fatalf("expected 1 code_batch_create audit, got %d", len(au))
}
if strings.Contains(au[0].Meta, "PLAINCODE") {
t.Errorf("plaintext code leaked into audit meta: %s", au[0].Meta)
}
}
func TestCreateBatch_BadCSRFRejected(t *testing.T) {
e := newEnv(t, true, true)
cookie, _ := e.login(t)
form := url.Values{"csrf_token": {"wrong"}, "plan": {"pro"}, "duration_days": {"30"}, "count": {"2"}, "channel": {"manual"}}
rec := e.do(t, "POST", "/codes", form, cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status %d; want 403", rec.Code)
}
if len(e.codes.created) != 0 {
t.Error("batch created despite CSRF failure")
}
}
func TestVoidBatch_RequiresConfirm(t *testing.T) {
e := newEnv(t, true, true)
cookie, csrf := e.login(t)
e.codes.voidReturn = 9
// Missing confirm → 400.
form := url.Values{"csrf_token": {csrf}, "batch_id": {"5"}}
rec := e.do(t, "POST", "/codes/void", form, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing-confirm status %d; want 400", rec.Code)
}
if len(e.codes.voided) != 0 {
t.Error("void executed without confirmation")
}
// With confirm → 302 + audit.
form.Set("confirm", "yes")
rec = e.do(t, "POST", "/codes/void", form, cookie)
if rec.Code != http.StatusFound {
t.Fatalf("confirmed void status %d; want 302", rec.Code)
}
if len(e.codes.voided) != 1 || e.codes.voided[0] != 5 {
t.Errorf("voided = %v; want [5]", e.codes.voided)
}
if len(e.store.auditFor("code_batch_void")) != 1 {
t.Error("void not audited")
}
}
func TestNodeOp_Replace(t *testing.T) {
e := newEnv(t, true, true)
cookie, csrf := e.login(t)
form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}}
rec := e.do(t, "POST", "/nodes/op", form, cookie)
if rec.Code != http.StatusFound {
t.Fatalf("status %d; want 302", rec.Code)
}
if len(e.prov.calls) != 1 || e.prov.calls[0].NodeID != 7 || e.prov.calls[0].Actor != "alice" {
t.Errorf("provision calls = %+v; want [{7 alice}]", e.prov.calls)
}
if len(e.store.auditFor("node_replace")) != 1 {
t.Error("replace not audited")
}
}
func TestNodeOp_Draining(t *testing.T) {
e := newEnv(t, true, true)
cookie, csrf := e.login(t)
form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"draining"}, "confirm": {"yes"}}
rec := e.do(t, "POST", "/nodes/op", form, cookie)
if rec.Code != http.StatusFound {
t.Fatalf("status %d; want 302", rec.Code)
}
if len(e.life.calls) != 1 || e.life.calls[0].NodeID != 7 || e.life.calls[0].Target != "draining" || e.life.calls[0].Actor != "alice" {
t.Errorf("lifecycle calls = %+v", e.life.calls)
}
if len(e.store.auditFor("node_draining")) != 1 {
t.Error("draining not audited")
}
}
func TestNodeOp_MissingConfirmRejected(t *testing.T) {
e := newEnv(t, true, true)
cookie, csrf := e.login(t)
form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}}
rec := e.do(t, "POST", "/nodes/op", form, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d; want 400", rec.Code)
}
if len(e.prov.calls) != 0 {
t.Error("replace executed without confirmation")
}
}
func TestNodeOp_BadCSRFRejected(t *testing.T) {
e := newEnv(t, true, true)
cookie, _ := e.login(t)
form := url.Values{"csrf_token": {"nope"}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}}
rec := e.do(t, "POST", "/nodes/op", form, cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status %d; want 403", rec.Code)
}
if len(e.prov.calls) != 0 {
t.Error("replace executed despite bad CSRF")
}
}
func TestSessionExpiry_RequiresReLogin(t *testing.T) {
e := newEnv(t, true, true)
cookie, _ := e.login(t)
if rec := e.do(t, "GET", "/", nil, cookie); rec.Code != http.StatusOK {
t.Fatalf("fresh session status %d; want 200", rec.Code)
}
// Idle past the 30-minute TTL.
e.mr.FastForward(31 * time.Minute)
rec := e.do(t, "GET", "/", nil, cookie)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Fatalf("expired session: status %d loc %q; want 302 /login", rec.Code, rec.Header().Get("Location"))
}
}
func TestNodesPage_Renders(t *testing.T) {
e := newEnv(t, false, false)
cookie, _ := e.login(t)
rec := e.do(t, "GET", "/nodes", nil, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("status %d; want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "香港节点") {
t.Error("node name not rendered")
}
// Stub services → controls greyed (disabled) + notice shown.
if !strings.Contains(body, "disabled") {
t.Error("expected disabled buttons when services not ready")
}
}
func TestAuditPage_Filters(t *testing.T) {
e := newEnv(t, true, true)
cookie, _ := e.login(t)
ctx := context.Background()
_ = e.store.WriteAudit(ctx, "alice", "node_replace", "node:7", `{"op":"replace"}`)
_ = e.store.WriteAudit(ctx, "bob", "code_batch_void", "batch:3", `{"voided":2}`)
rec := e.do(t, "GET", "/audit?action=node_replace", nil, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("status %d; want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "node:7") {
t.Error("filtered entry missing")
}
if strings.Contains(body, "batch:3") {
t.Error("filter leaked non-matching entry")
}
}
+60
View File
@@ -0,0 +1,60 @@
package admin
import (
"net"
"net/http"
)
// IPAllow is middleware that rejects any request whose source IP is not within
// the configured allowlist. It is the first line of defence in front of the
// admin backend (doc/06 §2: 管理后台不得暴露公网).
//
// The source address is taken from the TCP peer (RemoteAddr) ONLY. Proxy
// headers like X-Forwarded-For are intentionally ignored: the admin port is
// reached directly over an SSH tunnel / intranet, so trusting client-supplied
// headers would let an attacker spoof the allowlist.
type IPAllow struct {
allow []*net.IPNet
sec *SecurityLog
next http.Handler
}
// NewIPAllow wraps next with the allowlist check.
func NewIPAllow(allow []*net.IPNet, sec *SecurityLog, next http.Handler) *IPAllow {
return &IPAllow{allow: allow, sec: sec, next: next}
}
func (m *IPAllow) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r.RemoteAddr)
if ip == nil || !m.allowed(ip) {
if m.sec != nil {
m.sec.IPBlocked(r.Context(), hostOnly(r.RemoteAddr), r.URL.Path)
}
http.Error(w, "forbidden", http.StatusForbidden)
return
}
m.next.ServeHTTP(w, r)
}
func (m *IPAllow) allowed(ip net.IP) bool {
for _, n := range m.allow {
if n.Contains(ip) {
return true
}
}
return false
}
// clientIP extracts the net.IP from a "host:port" RemoteAddr.
func clientIP(remoteAddr string) net.IP {
return net.ParseIP(hostOnly(remoteAddr))
}
func hostOnly(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// RemoteAddr may already be a bare host in some test setups.
return remoteAddr
}
return host
}
+48
View File
@@ -0,0 +1,48 @@
package admin
import (
"net/http"
"net/http/httptest"
"testing"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
func TestIPAllow(t *testing.T) {
allow, err := ParseCIDRs([]string{"127.0.0.0/8", "10.0.0.0/8"})
if err != nil {
t.Fatal(err)
}
store := newFakeStore()
sec := NewSecurityLog(store, nil)
mw := NewIPAllow(allow, sec, okHandler())
cases := []struct {
remote string
want int
}{
{"127.0.0.1:5555", http.StatusOK},
{"10.4.5.6:5555", http.StatusOK},
{"8.8.8.8:5555", http.StatusForbidden},
{"192.168.1.1:5555", http.StatusForbidden},
}
for _, c := range cases {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = c.remote
rec := httptest.NewRecorder()
mw.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("remote %s: status %d; want %d", c.remote, rec.Code, c.want)
}
}
// A blocked request must produce a security-event audit row.
if len(store.auditFor("admin_ip_blocked")) == 0 {
t.Error("expected admin_ip_blocked security event in audit log")
}
}
+57
View File
@@ -0,0 +1,57 @@
package admin
import (
"context"
"net/http"
)
type ctxKey string
const ctxKeySession ctxKey = "admin_session"
// SessionMiddleware loads and validates the session cookie for protected
// routes. Requests without a valid session are redirected to the login page
// (GET) or rejected with 401 (other methods). On success the session is stored
// in the request context and its idle TTL is slid forward.
type SessionMiddleware struct {
sessions *SessionStore
next http.Handler
}
// NewSessionMiddleware wraps next so it only runs with a valid session.
func NewSessionMiddleware(sessions *SessionStore, next http.Handler) *SessionMiddleware {
return &SessionMiddleware{sessions: sessions, next: next}
}
func (m *SessionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(SessionCookieName)
if err != nil {
m.deny(w, r)
return
}
sess, serr := m.sessions.Get(r.Context(), c.Value)
if serr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if sess == nil {
m.deny(w, r)
return
}
ctx := context.WithValue(r.Context(), ctxKeySession, sess)
m.next.ServeHTTP(w, r.WithContext(ctx))
}
func (m *SessionMiddleware) deny(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
// SessionFromContext returns the authenticated session, or nil.
func SessionFromContext(ctx context.Context) *Session {
s, _ := ctx.Value(ctxKeySession).(*Session)
return s
}
+76
View File
@@ -0,0 +1,76 @@
package admin
import (
"database/sql"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/codes"
)
// NewRouter wires the full admin HTTP handler chain:
//
// IP allowlist → [ /login, /static ] (no session)
// IP allowlist → session → [ everything else ] (authenticated)
func NewRouter(h *Handlers, sessions *SessionStore, cfg *Config, sec *SecurityLog) http.Handler {
ipAllow := func(next http.Handler) http.Handler { return NewIPAllow(cfg.AllowCIDRs, sec, next) }
requireSession := func(next http.Handler) http.Handler { return NewSessionMiddleware(sessions, next) }
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(ipAllow)
// Static assets (CSS/JS) — embedded; behind the allowlist but pre-auth.
r.Handle("/static/*", http.FileServer(http.FS(assetsFS)))
// Pre-auth login routes.
r.Get("/login", h.LoginPage)
r.Post("/login", h.LoginSubmit)
// Authenticated routes.
r.Group(func(pr chi.Router) {
pr.Use(requireSession)
pr.Get("/", h.Dashboard)
pr.Post("/logout", h.Logout)
pr.Get("/codes", h.CodesPage)
pr.Post("/codes", h.CreateBatch)
pr.Post("/codes/void", h.VoidBatch)
pr.Get("/nodes", h.NodesPage)
pr.Post("/nodes/op", h.NodeOp)
pr.Get("/audit", h.AuditPage)
})
return r
}
// BuildServices assembles the downstream services. The codes service (#3) is
// real; lifecycle (#5) and provisioning (#14) are stubs until those modules
// land — the UI greys out their controls (Ready()==false).
func BuildServices(db *sql.DB, rdb *redis.Client, failMax int, lockDur time.Duration) Services {
codeStore := codes.NewStore(db)
codeSvc := codes.NewService(codeStore, rdb, failMax, lockDur)
return Services{
Codes: NewCodesAdapter(codeSvc, codeStore),
Lifecycle: NewStubLifecycle(),
Provision: NewStubProvision(),
}
}
// NewHandler builds the complete admin http.Handler from its dependencies.
func NewHandler(cfg *Config, db *sql.DB, rdb *redis.Client, svc Services, logger *log.Logger) (http.Handler, error) {
if logger == nil {
logger = log.Default()
}
store := NewDBStore(db)
sessions := NewSessionStore(rdb, cfg.SessionTTL)
sec := NewSecurityLog(store, logger)
auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
handlers, err := NewHandlers(cfg, store, sessions, auth, svc, sec, logger)
if err != nil {
return nil, err
}
return NewRouter(handlers, sessions, cfg, sec), nil
}
+74
View File
@@ -0,0 +1,74 @@
package admin
import (
"context"
"encoding/json"
"log"
)
// SecurityLog records admin-side security events (login failures, allowlist
// rejections, lockouts) to both the structured logger and the audit_log table.
//
// Per doc/02 §1 the admin backend records ONLY security events — never routine
// access logs.
type SecurityLog struct {
store Store
logger *log.Logger
}
// NewSecurityLog creates a SecurityLog. A nil logger falls back to the
// standard logger.
func NewSecurityLog(store Store, logger *log.Logger) *SecurityLog {
if logger == nil {
logger = log.Default()
}
return &SecurityLog{store: store, logger: logger}
}
func (s *SecurityLog) write(ctx context.Context, actor, action, target string, meta map[string]any) {
if s == nil {
return
}
metaJSON := ""
if meta != nil {
if b, err := json.Marshal(meta); err == nil {
metaJSON = string(b)
}
}
s.logger.Printf("admin security event action=%s actor=%s target=%s", action, actor, target)
if s.store != nil {
if err := s.store.WriteAudit(ctx, actor, action, target, metaJSON); err != nil {
s.logger.Printf("admin security event audit write failed: %v", err)
}
}
}
// LoginFail records a failed login attempt.
func (s *SecurityLog) LoginFail(ctx context.Context, username, ip, reason string) {
s.write(ctx, safeActor(username), "admin_login_fail", "ip:"+ip, map[string]any{"reason": reason})
}
// LoginLocked records a login attempt against a locked-out account.
func (s *SecurityLog) LoginLocked(ctx context.Context, username, ip string) {
s.write(ctx, safeActor(username), "admin_login_locked", "ip:"+ip, nil)
}
// LoginOK records a successful login.
func (s *SecurityLog) LoginOK(ctx context.Context, username, ip string) {
s.write(ctx, safeActor(username), "admin_login_ok", "ip:"+ip, nil)
}
// IPBlocked records an allowlist rejection.
func (s *SecurityLog) IPBlocked(ctx context.Context, ip, path string) {
s.write(ctx, "-", "admin_ip_blocked", "ip:"+ip, map[string]any{"path": path})
}
func safeActor(username string) string {
if username == "" {
return "-"
}
if len(username) > 64 {
return username[:64]
}
return username
}
+167
View File
@@ -0,0 +1,167 @@
package admin
import (
"context"
"errors"
"time"
"github.com/wangjia/pangolin/server/internal/codes"
)
// ErrServiceUnavailable is returned by stub services whose real implementation
// (#5 lifecycle, #14 provisioning) is not yet wired in. The UI greys out the
// corresponding controls when Ready() is false.
var ErrServiceUnavailable = errors.New("admin: service not available yet")
// --------------------------------------------------------------------------
// Code-batch service (#3 codes — implemented)
// --------------------------------------------------------------------------
// CodeBatchParams are the inputs to a batch generation.
type CodeBatchParams struct {
Plan string
DurationDays int
Count int
Channel string
Note string
CreatedBy string
}
// GeneratedBatch carries the one-and-only plaintext output of a generation.
type GeneratedBatch struct {
BatchID int64
Plan string
DurationDays int
Channel string
Codes []string // plaintext, streamed to CSV once and never stored
GeneratedAt time.Time
}
// CodesService is the subset of the #3 codes service the admin UI consumes.
type CodesService interface {
CreateBatch(ctx context.Context, p CodeBatchParams) (*GeneratedBatch, error)
ListBatches(ctx context.Context, limit, offset int) ([]BatchSummary, int, error)
VoidBatch(ctx context.Context, batchID int64) (int64, error)
}
// CodesAdapter adapts the real codes.Service / codes.Store to CodesService.
type CodesAdapter struct {
svc *codes.Service
store *codes.Store
}
// NewCodesAdapter wires the real codes implementation.
func NewCodesAdapter(svc *codes.Service, store *codes.Store) *CodesAdapter {
return &CodesAdapter{svc: svc, store: store}
}
// CreateBatch generates a batch and returns the plaintext codes.
func (a *CodesAdapter) CreateBatch(ctx context.Context, p CodeBatchParams) (*GeneratedBatch, error) {
res, err := a.svc.CreateBatch(ctx, codes.BatchRequest{
PlanCode: codes.PlanCode(p.Plan),
DurationDays: p.DurationDays,
Count: p.Count,
Channel: codes.BatchChannel(p.Channel),
Note: p.Note,
CreatedBy: p.CreatedBy,
})
if err != nil {
return nil, err
}
return &GeneratedBatch{
BatchID: res.BatchID,
Plan: string(res.PlanCode),
DurationDays: res.DurationDays,
Channel: string(res.Channel),
Codes: res.Codes,
GeneratedAt: time.Now().UTC(),
}, nil
}
// ListBatches returns paginated batch summaries.
func (a *CodesAdapter) ListBatches(ctx context.Context, limit, offset int) ([]BatchSummary, int, error) {
infos, total, err := a.store.ListBatches(ctx, limit, offset)
if err != nil {
return nil, 0, err
}
out := make([]BatchSummary, len(infos))
for i, b := range infos {
out[i] = BatchSummary{
ID: b.ID,
Channel: string(b.Channel),
CreatedBy: b.CreatedBy,
Note: b.Note,
CreatedAt: b.CreatedAt,
Total: b.Total,
Redeemed: b.Redeemed,
Void: b.Void,
Unused: b.Unused,
}
}
return out, total, nil
}
// VoidBatch voids all unused codes in a batch.
func (a *CodesAdapter) VoidBatch(ctx context.Context, batchID int64) (int64, error) {
return a.store.VoidBatch(ctx, batchID)
}
// --------------------------------------------------------------------------
// Node lifecycle service (#5) — not yet implemented
// --------------------------------------------------------------------------
// LifecycleService transitions a node's status (draining / up). Backed by the
// #5 lifecycle module once available.
type LifecycleService interface {
// TransitionStatus moves nodeID to target ("draining" | "up").
TransitionStatus(ctx context.Context, nodeID int64, target, actor string) error
// Ready reports whether the real implementation is wired (UI greys out
// controls when false).
Ready() bool
}
// StubLifecycle is the placeholder used until #5 lands.
type StubLifecycle struct{}
// NewStubLifecycle returns a not-ready lifecycle service.
func NewStubLifecycle() *StubLifecycle { return &StubLifecycle{} }
// TransitionStatus always fails until #5 is wired.
func (StubLifecycle) TransitionStatus(context.Context, int64, string, string) error {
return ErrServiceUnavailable
}
// Ready reports false.
func (StubLifecycle) Ready() bool { return false }
// --------------------------------------------------------------------------
// Node provisioning service (#14) — not yet implemented
// --------------------------------------------------------------------------
// ProvisionService replaces a node by provisioning a fresh one. Backed by the
// #14 ProvisionService once available.
type ProvisionService interface {
// Replace decommissions nodeID and provisions a replacement.
Replace(ctx context.Context, nodeID int64, actor string) error
// Ready reports whether the real implementation is wired.
Ready() bool
}
// StubProvision is the placeholder used until #14 lands.
type StubProvision struct{}
// NewStubProvision returns a not-ready provision service.
func NewStubProvision() *StubProvision { return &StubProvision{} }
// Replace always fails until #14 is wired.
func (StubProvision) Replace(context.Context, int64, string) error { return ErrServiceUnavailable }
// Ready reports false.
func (StubProvision) Ready() bool { return false }
// Services bundles the three downstream services the admin UI depends on.
type Services struct {
Codes CodesService
Lifecycle LifecycleService
Provision ProvisionService
}
+126
View File
@@ -0,0 +1,126 @@
package admin
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// SessionCookieName is the name of the admin session cookie.
const SessionCookieName = "admin_session"
// sessionKeyPrefix namespaces session keys in Redis.
const sessionKeyPrefix = "admin:sess:"
// Session is the server-side state for one logged-in admin.
type Session struct {
AdminID int64 `json:"admin_id"`
Username string `json:"username"`
CSRFToken string `json:"csrf"`
CreatedAt time.Time `json:"created_at"`
}
// SessionStore persists admin sessions in Redis with a sliding idle TTL.
type SessionStore struct {
rdb *redis.Client
ttl time.Duration
}
// NewSessionStore creates a SessionStore. ttl is the sliding idle timeout.
func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore {
if ttl <= 0 {
ttl = 30 * time.Minute
}
return &SessionStore{rdb: rdb, ttl: ttl}
}
// Create starts a new session for the given admin and returns the opaque
// session id (to be set as the cookie value).
func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) {
sid, err := randToken(32)
if err != nil {
return "", nil, err
}
csrf, err := randToken(32)
if err != nil {
return "", nil, err
}
sess := &Session{
AdminID: adminID,
Username: username,
CSRFToken: csrf,
CreatedAt: time.Now().UTC(),
}
if err := s.save(ctx, sid, sess); err != nil {
return "", nil, err
}
return sid, sess, nil
}
// Get loads a session and slides its TTL forward. Returns (nil, nil) when the
// session is absent or expired.
func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
if sid == "" {
return nil, nil
}
val, err := s.rdb.Get(ctx, sessionKeyPrefix+sid).Bytes()
if errors.Is(err, redis.Nil) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("admin.SessionStore.Get: %w", err)
}
var sess Session
if err := json.Unmarshal(val, &sess); err != nil {
return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err)
}
// Slide the idle timeout.
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.ttl).Err(); err != nil {
return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err)
}
return &sess, nil
}
// Delete removes a session (logout).
func (s *SessionStore) Delete(ctx context.Context, sid string) error {
if sid == "" {
return nil
}
return s.rdb.Del(ctx, sessionKeyPrefix+sid).Err()
}
func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) error {
b, err := json.Marshal(sess)
if err != nil {
return fmt.Errorf("admin.SessionStore.save: %w", err)
}
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.ttl).Err(); err != nil {
return fmt.Errorf("admin.SessionStore.save set: %w", err)
}
return nil
}
// ValidCSRF reports, in constant time, whether token matches the session's
// CSRF token.
func (sess *Session) ValidCSRF(token string) bool {
if sess == nil || sess.CSRFToken == "" || token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sess.CSRFToken), []byte(token)) == 1
}
// randToken returns a URL-safe random token of n bytes of entropy.
func randToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("admin.randToken: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+84
View File
@@ -0,0 +1,84 @@
package admin
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { rdb.Close() })
return rdb, mr
}
func TestSessionStore_Lifecycle(t *testing.T) {
rdb, _ := newTestRedis(t)
store := NewSessionStore(rdb, 30*time.Minute)
ctx := context.Background()
sid, sess, err := store.Create(ctx, 42, "alice")
if err != nil {
t.Fatal(err)
}
if sess.AdminID != 42 || sess.Username != "alice" || sess.CSRFToken == "" {
t.Fatalf("unexpected session %+v", sess)
}
got, err := store.Get(ctx, sid)
if err != nil {
t.Fatal(err)
}
if got == nil || got.AdminID != 42 {
t.Fatalf("Get returned %+v", got)
}
// CSRF check.
if !got.ValidCSRF(sess.CSRFToken) {
t.Error("valid CSRF token rejected")
}
if got.ValidCSRF("bogus") {
t.Error("bogus CSRF token accepted")
}
// Delete.
if err := store.Delete(ctx, sid); err != nil {
t.Fatal(err)
}
got, _ = store.Get(ctx, sid)
if got != nil {
t.Error("session still present after delete")
}
}
func TestSessionStore_SlidingExpiry(t *testing.T) {
rdb, mr := newTestRedis(t)
store := NewSessionStore(rdb, 30*time.Minute)
ctx := context.Background()
sid, _, err := store.Create(ctx, 1, "bob")
if err != nil {
t.Fatal(err)
}
// Advance 20 min, access (slides TTL forward).
mr.FastForward(20 * time.Minute)
if got, _ := store.Get(ctx, sid); got == nil {
t.Fatal("session expired prematurely")
}
// Another 20 min: still alive because the previous Get slid the TTL.
mr.FastForward(20 * time.Minute)
if got, _ := store.Get(ctx, sid); got == nil {
t.Fatal("sliding TTL did not extend session")
}
// Idle past the full TTL: gone.
mr.FastForward(31 * time.Minute)
if got, _ := store.Get(ctx, sid); got != nil {
t.Error("session survived past idle timeout")
}
}
+11
View File
@@ -0,0 +1,11 @@
// Second-confirmation gate for destructive admin actions.
// The server independently requires confirm=yes; this is the UI half.
document.addEventListener('submit', function (e) {
var form = e.target;
if (form.classList && form.classList.contains('confirm')) {
var msg = form.getAttribute('data-confirm') || '确认执行此操作?';
if (!window.confirm(msg)) {
e.preventDefault();
}
}
}, true);
+45
View File
@@ -0,0 +1,45 @@
:root { --fg:#1c2230; --muted:#7a8294; --line:#e2e6ee; --accent:#2d5bd7; --danger:#c0392b; --warn:#b7791f; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif; color:var(--fg); background:#f5f6f9; }
body.centered { display:flex; min-height:100vh; align-items:center; justify-content:center; }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
.topbar { display:flex; align-items:center; gap:24px; padding:10px 20px; background:#fff; border-bottom:1px solid var(--line); }
.brand { font-weight:700; }
.topbar nav { display:flex; gap:16px; flex:1; }
.topbar nav a.on { font-weight:700; color:var(--fg); }
.who { color:var(--muted); display:flex; align-items:center; gap:8px; }
main { max-width:1100px; margin:24px auto; padding:0 20px; }
h1 { font-size:20px; } h2 { font-size:16px; }
.card { background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px 20px; margin-bottom:20px; }
.card.login { width:340px; }
form label { display:block; margin:10px 0; font-size:13px; color:var(--muted); }
form input, form select { display:block; width:100%; margin-top:4px; padding:8px 10px; border:1px solid var(--line); border-radius:6px; font-size:14px; color:var(--fg); }
form.inline { display:inline; margin:0; }
form.inline input { width:auto; }
.filters { display:flex; flex-wrap:wrap; gap:12px; align-items:flex-end; margin-bottom:16px; }
.filters label { margin:0; }
button { padding:8px 14px; border:0; border-radius:6px; background:var(--accent); color:#fff; cursor:pointer; font-size:14px; }
button:hover { filter:brightness(1.05); }
button:disabled { background:#c2c8d4; cursor:not-allowed; }
button.danger { background:var(--danger); }
button.link { background:none; color:var(--accent); padding:0; }
table { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); font-size:13px; vertical-align:top; }
th { background:#fafbfd; color:var(--muted); font-weight:600; }
.muted { color:var(--muted); }
.meta { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; max-width:280px; word-break:break-all; }
.flash { background:#eef5ee; border:1px solid #cfe3cf; color:#2f6f37; padding:10px 20px; margin:0; }
.error { background:#fbeeec; border:1px solid #f0c8c2; color:var(--danger); padding:8px 10px; border-radius:6px; margin:10px 0; }
.warn { background:#fdf6e7; border:1px solid #f0e0b8; color:var(--warn); padding:10px 14px; border-radius:6px; margin-bottom:16px; }
.hint { color:var(--muted); font-size:12px; }
.ops { display:flex; gap:6px; flex-wrap:wrap; }
.pager { margin-top:12px; display:flex; gap:16px; align-items:center; }
.cards { list-style:none; padding:0; display:flex; gap:16px; flex-wrap:wrap; }
.cards a { display:block; background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px 20px; min-width:180px; }
.cards strong { display:block; } .cards span { color:var(--muted); font-size:12px; }
.status { padding:2px 8px; border-radius:10px; font-size:12px; background:#eef0f5; }
.status-up { background:#e6f4ea; color:#1e7a3a; }
.status-draining { background:#fdf6e7; color:var(--warn); }
.status-down, .status-destroyed { background:#fbeeec; color:var(--danger); }
.ev { font-size:12px; }
+243
View File
@@ -0,0 +1,243 @@
package admin
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// ErrAdminNotFound is returned when no admin row matches a lookup.
var ErrAdminNotFound = errors.New("admin: not found")
// Store is the data-access surface the admin backend needs. It is an interface
// so handlers and the authenticator can be unit-tested with a fake; the
// production implementation (DBStore) is backed by MySQL.
type Store interface {
// Admin identity.
GetAdminByUsername(ctx context.Context, username string) (*Admin, error)
CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error)
UpdateLastLogin(ctx context.Context, id int64, at time.Time) error
// Node catalogue (read-only here; mutations go through the #5/#14 services).
ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error)
GetNode(ctx context.Context, id int64) (*NodeRow, error)
// Audit & events.
WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error
QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error)
QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error)
}
// DBStore implements Store over MySQL.
type DBStore struct {
db *sql.DB
}
// NewDBStore creates a DBStore.
func NewDBStore(db *sql.DB) *DBStore { return &DBStore{db: db} }
// GetAdminByUsername loads an admin by username.
func (s *DBStore) GetAdminByUsername(ctx context.Context, username string) (*Admin, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, pw_hash, totp_secret, status, created_at, last_login_at
FROM admins WHERE username = ?`, username)
var a Admin
var last sql.NullTime
err := row.Scan(&a.ID, &a.Username, &a.PwHash, &a.TOTPSecretEnc, &a.Status, &a.CreatedAt, &last)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAdminNotFound
}
if err != nil {
return nil, fmt.Errorf("admin.GetAdminByUsername: %w", err)
}
if last.Valid {
a.LastLoginAt = &last.Time
}
return &a, nil
}
// CreateAdmin inserts a new admin and returns its id.
func (s *DBStore) CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO admins (username, pw_hash, totp_secret, status, created_at)
VALUES (?, ?, ?, 'active', UTC_TIMESTAMP(6))`,
username, pwHash, totpSecretEnc)
if err != nil {
return 0, fmt.Errorf("admin.CreateAdmin: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("admin.CreateAdmin last id: %w", err)
}
return id, nil
}
// UpdateLastLogin records a successful login time.
func (s *DBStore) UpdateLastLogin(ctx context.Context, id int64, at time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE admins SET last_login_at = ? WHERE id = ?`, at.UTC(), id)
if err != nil {
return fmt.Errorf("admin.UpdateLastLogin: %w", err)
}
return nil
}
// ListNodes returns all nodes with up to eventsPerNode recent events each.
func (s *DBStore) ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier,
n.endpoint, n.status, n.weight, pr.name
FROM nodes n
JOIN providers pr ON pr.id = n.provider_id
ORDER BY n.id`)
if err != nil {
return nil, fmt.Errorf("admin.ListNodes: %w", err)
}
defer rows.Close()
var out []NodeRow
for rows.Next() {
var n NodeRow
if err := rows.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role,
&n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider); err != nil {
return nil, fmt.Errorf("admin.ListNodes scan: %w", err)
}
out = append(out, n)
}
if err := rows.Err(); err != nil {
return nil, err
}
if eventsPerNode > 0 {
for i := range out {
ev, err := s.QueryNodeEvents(ctx, out[i].ID, eventsPerNode)
if err != nil {
return nil, err
}
out[i].RecentEvents = ev
}
}
return out, nil
}
// GetNode loads a single node by id.
func (s *DBStore) GetNode(ctx context.Context, id int64) (*NodeRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier,
n.endpoint, n.status, n.weight, pr.name
FROM nodes n
JOIN providers pr ON pr.id = n.provider_id
WHERE n.id = ?`, id)
var n NodeRow
err := row.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role,
&n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("admin.GetNode: %w", err)
}
return &n, nil
}
// WriteAudit inserts an audit_log row.
func (s *DBStore) WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error {
if metaJSON == "" {
metaJSON = "null"
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_log (actor, action, target, meta, at)
VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
actor, action, target, metaJSON)
if err != nil {
return fmt.Errorf("admin.WriteAudit: %w", err)
}
return nil
}
// QueryAudit returns filtered audit_log entries plus the total match count.
func (s *DBStore) QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error) {
where := []string{"1=1"}
args := []any{}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.Target != "" {
where = append(where, "target LIKE ?")
args = append(args, "%"+f.Target+"%")
}
if f.From != nil {
where = append(where, "at >= ?")
args = append(args, f.From.UTC())
}
if f.To != nil {
where = append(where, "at <= ?")
args = append(args, f.To.UTC())
}
clause := strings.Join(where, " AND ")
var total int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM audit_log WHERE `+clause, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit count: %w", err)
}
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 50
}
q := `SELECT id, actor, action, target, COALESCE(meta, ''), at
FROM audit_log WHERE ` + clause + ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`
args = append(args, limit, f.Offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
var meta []byte
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Target, &meta, &e.At); err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit scan: %w", err)
}
e.Meta = string(meta)
out = append(out, e)
}
return out, total, rows.Err()
}
// QueryNodeEvents returns the most recent events for a node.
func (s *DBStore) QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, node_id, event, COALESCE(detail, ''), at
FROM node_events WHERE node_id = ? ORDER BY at DESC, id DESC LIMIT ?`,
nodeID, limit)
if err != nil {
return nil, fmt.Errorf("admin.QueryNodeEvents: %w", err)
}
defer rows.Close()
var out []NodeEvent
for rows.Next() {
var e NodeEvent
var detail []byte
if err := rows.Scan(&e.ID, &e.NodeID, &e.Event, &detail, &e.At); err != nil {
return nil, fmt.Errorf("admin.QueryNodeEvents scan: %w", err)
}
e.Detail = string(detail)
out = append(out, e)
}
return out, rows.Err()
}
+75
View File
@@ -0,0 +1,75 @@
package admin
import (
"embed"
"fmt"
"html/template"
"io"
"net/http"
"time"
)
//go:embed templates/*.html static/*
var assetsFS embed.FS
// pageData is the common envelope passed to every rendered page.
type pageData struct {
Username string
CSRF string
Active string // nav key for highlight
Flash string
Data any
}
// renderer holds the parsed template set.
type renderer struct {
pages map[string]*template.Template
}
var tmplFuncs = template.FuncMap{
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.UTC().Format("2006-01-02 15:04:05Z")
},
}
// newRenderer parses base.html with each page template into its own set.
func newRenderer() (*renderer, error) {
pages := map[string][]string{
"login": {"templates/login.html"},
"dashboard": {"templates/base.html", "templates/dashboard.html"},
"codes": {"templates/base.html", "templates/codes.html"},
"nodes": {"templates/base.html", "templates/nodes.html"},
"audit": {"templates/base.html", "templates/audit.html"},
}
r := &renderer{pages: make(map[string]*template.Template, len(pages))}
for name, files := range pages {
t, err := template.New("").Funcs(tmplFuncs).ParseFS(assetsFS, files...)
if err != nil {
return nil, fmt.Errorf("admin.newRenderer parse %s: %w", name, err)
}
r.pages[name] = t
}
return r, nil
}
// render writes the named page. The login page renders itself; all others
// render through the "base" layout.
func (r *renderer) render(w http.ResponseWriter, name string, data pageData) {
t, ok := r.pages[name]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
root := "base"
if name == "login" {
root = "login"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, root, data); err != nil {
// Header may already be written; log via stderr only.
fmt.Fprintf(io.Discard, "render error: %v", err)
}
}
@@ -0,0 +1,47 @@
{{define "title"}}审计{{end}}
{{define "content"}}
<h1>审计日志</h1>
<form method="get" action="/audit" class="filters">
<label>操作者<input type="text" name="actor" value="{{.Data.Filter.Actor}}"></label>
<label>动作<input type="text" name="action" value="{{.Data.Filter.Action}}"></label>
<label>目标<input type="text" name="target" value="{{.Data.Filter.Target}}"></label>
<label><input type="text" name="from" value="{{.Data.FromStr}}" placeholder="2006-01-02"></label>
<label><input type="text" name="to" value="{{.Data.ToStr}}" placeholder="2006-01-02"></label>
<button type="submit">筛选</button>
</form>
<table>
<thead><tr><th>ID</th><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>meta</th></tr></thead>
<tbody>
{{range .Data.Entries}}
<tr><td>{{.ID}}</td><td>{{fmtTime .At}}</td><td>{{.Actor}}</td><td>{{.Action}}</td><td>{{.Target}}</td><td class="meta">{{.Meta}}</td></tr>
{{else}}
<tr><td colspan="6" class="muted">无匹配记录</td></tr>
{{end}}
</tbody>
</table>
<div class="pager">
{{if .Data.HasPrev}}<a href="{{.Data.PrevURL}}">上一页</a>{{end}}
<span class="muted">共 {{.Data.Total}} 条</span>
{{if .Data.HasNext}}<a href="{{.Data.NextURL}}">下一页</a>{{end}}
</div>
<h2>节点事件</h2>
<form method="get" action="/audit" class="filters">
<input type="hidden" name="actor" value="{{.Data.Filter.Actor}}">
<input type="hidden" name="action" value="{{.Data.Filter.Action}}">
<input type="hidden" name="target" value="{{.Data.Filter.Target}}">
<label>节点 ID<input type="text" name="node_id" value="{{.Data.NodeIDStr}}"></label>
<button type="submit">查看节点事件</button>
</form>
<table>
<thead><tr><th>ID</th><th>时间</th><th>节点</th><th>事件</th><th>detail</th></tr></thead>
<tbody>
{{range .Data.NodeEvents}}
<tr><td>{{.ID}}</td><td>{{fmtTime .At}}</td><td>{{.NodeID}}</td><td>{{.Event}}</td><td class="meta">{{.Detail}}</td></tr>
{{else}}
<tr><td colspan="5" class="muted">输入节点 ID 查看其事件</td></tr>
{{end}}
</tbody>
</table>
{{end}}
+31
View File
@@ -0,0 +1,31 @@
{{define "base"}}<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}控制台{{end}} · Pangolin 管理后台</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<span class="brand">Pangolin 管理后台</span>
<nav>
<a href="/" class="{{if eq .Active "dashboard"}}on{{end}}">概览</a>
<a href="/codes" class="{{if eq .Active "codes"}}on{{end}}">码批次</a>
<a href="/nodes" class="{{if eq .Active "nodes"}}on{{end}}">节点</a>
<a href="/audit" class="{{if eq .Active "audit"}}on{{end}}">审计</a>
</nav>
<span class="who">
{{.Username}}
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button class="link" type="submit">退出</button>
</form>
</span>
</header>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<main>
{{block "content" .}}{{end}}
</main>
</body>
</html>{{end}}
@@ -0,0 +1,64 @@
{{define "title"}}码批次{{end}}
{{define "content"}}
<h1>码批次</h1>
<section class="card">
<h2>生成新批次</h2>
<p class="hint">明文激活码仅在本次 CSV 下载中出现一次,不落盘、不入日志。请妥善保存下载文件。</p>
<form method="post" action="/codes">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label>套餐
<select name="plan" required>
<option value="pro">pro</option>
<option value="team">team</option>
<option value="free">free</option>
</select>
</label>
<label>时长(天)<input type="number" name="duration_days" min="1" max="3650" value="30" required></label>
<label>数量<input type="number" name="count" min="1" max="5000" value="100" required></label>
<label>渠道
<select name="channel" required>
<option value="manual">manual</option>
<option value="store">store</option>
<option value="tg">tg</option>
<option value="line">line</option>
</select>
</label>
<label>备注<input type="text" name="note" maxlength="255" placeholder="可选"></label>
<button type="submit">生成并下载 CSV</button>
</form>
</section>
<section class="card">
<h2>批次列表</h2>
<table>
<thead><tr><th>ID</th><th>渠道</th><th>创建者</th><th>备注</th><th>创建时间</th><th>总数</th><th>已用</th><th>作废</th><th>未用</th><th>操作</th></tr></thead>
<tbody>
{{range .Data.Batches}}
<tr>
<td>{{.ID}}</td><td>{{.Channel}}</td><td>{{.CreatedBy}}</td><td>{{.Note}}</td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{.Total}}</td><td>{{.Redeemed}}</td><td>{{.Void}}</td><td>{{.Unused}}</td>
<td>
{{if gt .Unused 0}}
<form method="post" action="/codes/void" class="inline confirm" data-confirm="确认作废批次 {{.ID}} 的全部未使用激活码?此操作不可撤销。">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="batch_id" value="{{.ID}}">
<input type="hidden" name="confirm" value="yes">
<button class="danger" type="submit">整批作废</button>
</form>
{{else}}<span class="muted"></span>{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="10" class="muted">暂无批次</td></tr>
{{end}}
</tbody>
</table>
<div class="pager">
{{if .Data.HasPrev}}<a href="/codes?offset={{.Data.PrevOffset}}">上一页</a>{{end}}
{{if .Data.HasNext}}<a href="/codes?offset={{.Data.NextOffset}}">下一页</a>{{end}}
</div>
</section>
<script src="/static/confirm.js"></script>
{{end}}
@@ -0,0 +1,10 @@
{{define "title"}}概览{{end}}
{{define "content"}}
<h1>概览</h1>
<p>欢迎,{{.Username}}。请从上方导航选择操作。</p>
<ul class="cards">
<li><a href="/codes"><strong>码批次</strong><span>生成 / 导出 / 作废</span></a></li>
<li><a href="/nodes"><strong>节点</strong><span>状态 / 替换 / 上下线</span></a></li>
<li><a href="/audit"><strong>审计</strong><span>操作与事件查询</span></a></li>
</ul>
{{end}}
@@ -0,0 +1,20 @@
{{define "login"}}<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · Pangolin 管理后台</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="centered">
<form method="post" action="/login" class="card login">
<h1>管理后台登录</h1>
{{if .Flash}}<div class="error">{{.Flash}}</div>{{end}}
<label>用户名<input type="text" name="username" autocomplete="username" required autofocus></label>
<label>密码<input type="password" name="password" autocomplete="current-password" required></label>
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required></label>
<button type="submit">登录</button>
<p class="hint">仅限内网 / SSH 隧道访问。</p>
</form>
</body>
</html>{{end}}
@@ -0,0 +1,52 @@
{{define "title"}}节点{{end}}
{{define "content"}}
<h1>节点</h1>
{{if not .Data.ProvisionReady}}<div class="warn">节点替换服务(#14)尚未接入,相关按钮已置灰。</div>{{end}}
{{if not .Data.LifecycleReady}}<div class="warn">节点生命周期服务(#5)尚未接入,上/下线按钮已置灰。</div>{{end}}
<table>
<thead><tr><th>ID</th><th>名称</th><th>地区</th><th>tier</th><th>权重</th><th>状态</th><th>厂商</th><th>近期事件</th><th>操作</th></tr></thead>
<tbody>
{{range .Data.Nodes}}
<tr>
<td>{{.ID}}</td>
<td>{{.NameZH}}<br><span class="muted">{{.UUID}}</span></td>
<td>{{.Region}}</td>
<td>{{.Tier}}</td>
<td>{{.Weight}}</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
<td>{{.Provider}}</td>
<td>
{{range .RecentEvents}}<div class="ev">{{.Event}} <span class="muted">{{fmtTime .At}}</span></div>{{else}}<span class="muted"></span>{{end}}
</td>
<td class="ops">
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认替换节点 {{.ID}}{{.NameZH}})?将开通新节点并下线旧节点。">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="replace">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.ProvisionReady}}disabled{{end}}>替换</button>
</form>
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 draining(停止接入新连接)?">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="draining">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>draining</button>
</form>
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 up(恢复接入)?">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="up">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>up</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">暂无节点</td></tr>
{{end}}
</tbody>
</table>
<script src="/static/confirm.js"></script>
{{end}}
+213
View File
@@ -0,0 +1,213 @@
package admin
import (
"context"
"strings"
"time"
)
// --------------------------------------------------------------------------
// Fake Store
// --------------------------------------------------------------------------
type fakeStore struct {
admins map[string]*Admin
nodes []NodeRow
events map[int64][]NodeEvent
audits []AuditEntry
lastLogin map[int64]time.Time
}
func newFakeStore() *fakeStore {
return &fakeStore{
admins: map[string]*Admin{},
events: map[int64][]NodeEvent{},
lastLogin: map[int64]time.Time{},
}
}
func (f *fakeStore) GetAdminByUsername(_ context.Context, username string) (*Admin, error) {
a, ok := f.admins[username]
if !ok {
return nil, ErrAdminNotFound
}
return a, nil
}
func (f *fakeStore) CreateAdmin(_ context.Context, username, pwHash string, enc []byte) (int64, error) {
id := int64(len(f.admins) + 1)
f.admins[username] = &Admin{ID: id, Username: username, PwHash: pwHash, TOTPSecretEnc: enc, Status: "active"}
return id, nil
}
func (f *fakeStore) UpdateLastLogin(_ context.Context, id int64, at time.Time) error {
f.lastLogin[id] = at
return nil
}
func (f *fakeStore) ListNodes(_ context.Context, eventsPerNode int) ([]NodeRow, error) {
out := make([]NodeRow, len(f.nodes))
copy(out, f.nodes)
for i := range out {
ev := f.events[out[i].ID]
if eventsPerNode > 0 && len(ev) > eventsPerNode {
ev = ev[:eventsPerNode]
}
out[i].RecentEvents = ev
}
return out, nil
}
func (f *fakeStore) GetNode(_ context.Context, id int64) (*NodeRow, error) {
for i := range f.nodes {
if f.nodes[i].ID == id {
n := f.nodes[i]
return &n, nil
}
}
return nil, nil
}
func (f *fakeStore) WriteAudit(_ context.Context, actor, action, target, metaJSON string) error {
f.audits = append(f.audits, AuditEntry{
ID: int64(len(f.audits) + 1), Actor: actor, Action: action,
Target: target, Meta: metaJSON, At: time.Now().UTC(),
})
return nil
}
func (f *fakeStore) QueryAudit(_ context.Context, flt AuditFilter) ([]AuditEntry, int, error) {
var matched []AuditEntry
for _, e := range f.audits {
if flt.Actor != "" && e.Actor != flt.Actor {
continue
}
if flt.Action != "" && e.Action != flt.Action {
continue
}
if flt.Target != "" && !strings.Contains(e.Target, flt.Target) {
continue
}
matched = append(matched, e)
}
total := len(matched)
off := flt.Offset
if off > len(matched) {
off = len(matched)
}
matched = matched[off:]
if flt.Limit > 0 && len(matched) > flt.Limit {
matched = matched[:flt.Limit]
}
return matched, total, nil
}
func (f *fakeStore) QueryNodeEvents(_ context.Context, nodeID int64, limit int) ([]NodeEvent, error) {
ev := f.events[nodeID]
if limit > 0 && len(ev) > limit {
ev = ev[:limit]
}
return ev, nil
}
// auditFor returns the audit entries whose action matches.
func (f *fakeStore) auditFor(action string) []AuditEntry {
var out []AuditEntry
for _, e := range f.audits {
if e.Action == action {
out = append(out, e)
}
}
return out
}
// --------------------------------------------------------------------------
// Fake CodesService
// --------------------------------------------------------------------------
type fakeCodes struct {
batches []BatchSummary
created []CodeBatchParams
nextCodes []string
nextID int64
voided []int64
voidReturn int64
}
func (c *fakeCodes) CreateBatch(_ context.Context, p CodeBatchParams) (*GeneratedBatch, error) {
c.created = append(c.created, p)
c.nextID++
codes := c.nextCodes
if codes == nil {
codes = make([]string, p.Count)
for i := range codes {
codes[i] = "PLAINCODE" + itoa(i)
}
}
return &GeneratedBatch{
BatchID: c.nextID, Plan: p.Plan, DurationDays: p.DurationDays,
Channel: p.Channel, Codes: codes, GeneratedAt: time.Now().UTC(),
}, nil
}
func (c *fakeCodes) ListBatches(_ context.Context, limit, offset int) ([]BatchSummary, int, error) {
return c.batches, len(c.batches), nil
}
func (c *fakeCodes) VoidBatch(_ context.Context, batchID int64) (int64, error) {
c.voided = append(c.voided, batchID)
return c.voidReturn, nil
}
// --------------------------------------------------------------------------
// Recording Lifecycle / Provision services
// --------------------------------------------------------------------------
type lifeCall struct {
NodeID int64
Target string
Actor string
}
type recordingLifecycle struct {
ready bool
err error
calls []lifeCall
}
func (r *recordingLifecycle) TransitionStatus(_ context.Context, nodeID int64, target, actor string) error {
r.calls = append(r.calls, lifeCall{nodeID, target, actor})
return r.err
}
func (r *recordingLifecycle) Ready() bool { return r.ready }
type provCall struct {
NodeID int64
Actor string
}
type recordingProvision struct {
ready bool
err error
calls []provCall
}
func (r *recordingProvision) Replace(_ context.Context, nodeID int64, actor string) error {
r.calls = append(r.calls, provCall{nodeID, actor})
return r.err
}
func (r *recordingProvision) Ready() bool { return r.ready }
func itoa(i int) string {
if i == 0 {
return "0"
}
var b [20]byte
pos := len(b)
for i > 0 {
pos--
b[pos] = byte('0' + i%10)
i /= 10
}
return string(b[pos:])
}
+75
View File
@@ -0,0 +1,75 @@
package admin
import "time"
// Admin mirrors a row of the admins table (totp_secret kept encrypted).
type Admin struct {
ID int64
Username string
PwHash string
TOTPSecretEnc []byte // AES-GCM blob; decrypt with Config.SecretKey
Status string // active | disabled
CreatedAt time.Time
LastLoginAt *time.Time
}
// NodeRow is a node-catalogue row as shown in the admin node list.
type NodeRow struct {
ID int64
UUID string
Region string
NameZH string
NameEN string
Role string
Tier string
Endpoint string
Status string
Weight int
Provider string
// RecentEvents holds the most recent node_events for inline display.
RecentEvents []NodeEvent
}
// NodeEvent mirrors a node_events row.
type NodeEvent struct {
ID int64
NodeID int64
Event string
Detail string // raw JSON or ""
At time.Time
}
// AuditEntry mirrors an audit_log row.
type AuditEntry struct {
ID int64
Actor string
Action string
Target string
Meta string // raw JSON or ""
At time.Time
}
// AuditFilter narrows an audit_log query. Empty fields are ignored.
type AuditFilter struct {
Actor string
Action string
Target string
From *time.Time
To *time.Time
Limit int
Offset int
}
// BatchSummary is a code_batches row with aggregate code counts for the
// batch list view.
type BatchSummary struct {
ID int64
Channel string
CreatedBy string
Note string
CreatedAt time.Time
Total int
Redeemed int
Void int
Unused int
}
+103
View File
@@ -0,0 +1,103 @@
package codes
import (
"context"
"fmt"
"time"
)
// BatchInfo summarises a code_batches row with aggregate code counts, for the
// admin batch-list view. It carries no plaintext — only hashes live in `codes`.
type BatchInfo struct {
ID int64
Channel BatchChannel
CreatedBy string
Note string
CreatedAt time.Time
Total int
Redeemed int
Void int
Unused int
}
// ListBatches returns code batches newest-first with aggregate counts, plus the
// total number of batches (for pagination).
func (s *Store) ListBatches(ctx context.Context, limit, offset int) ([]BatchInfo, int, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM code_batches`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("store.ListBatches count: %w", err)
}
rows, err := s.db.QueryContext(ctx,
`SELECT b.id, b.channel, b.created_by, COALESCE(b.note, ''), b.created_at,
COUNT(c.id),
SUM(c.status = 'redeemed'),
SUM(c.status = 'void'),
SUM(c.status = 'unused')
FROM code_batches b
LEFT JOIN codes c ON c.batch_id = b.id
GROUP BY b.id, b.channel, b.created_by, b.note, b.created_at
ORDER BY b.id DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("store.ListBatches: %w", err)
}
defer rows.Close()
var out []BatchInfo
for rows.Next() {
var bi BatchInfo
// SUM over a possibly-empty group yields NULL; scan into nullable ints.
var redeemed, void, unused, totalCnt nullInt
if err := rows.Scan(&bi.ID, &bi.Channel, &bi.CreatedBy, &bi.Note, &bi.CreatedAt,
&totalCnt, &redeemed, &void, &unused); err != nil {
return nil, 0, fmt.Errorf("store.ListBatches scan: %w", err)
}
bi.Total = totalCnt.val
bi.Redeemed = redeemed.val
bi.Void = void.val
bi.Unused = unused.val
out = append(out, bi)
}
return out, total, rows.Err()
}
// VoidBatch marks every still-unused code in a batch as void and returns the
// number of codes voided. Already-redeemed codes are left untouched.
func (s *Store) VoidBatch(ctx context.Context, batchID int64) (int64, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE codes SET status = 'void' WHERE batch_id = ? AND status = 'unused'`, batchID)
if err != nil {
return 0, fmt.Errorf("store.VoidBatch: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("store.VoidBatch rows: %w", err)
}
return n, nil
}
// nullInt scans a possibly-NULL integer aggregate, defaulting to 0.
type nullInt struct{ val int }
func (n *nullInt) Scan(src any) error {
switch v := src.(type) {
case nil:
n.val = 0
case int64:
n.val = int(v)
case []byte:
_, err := fmt.Sscanf(string(v), "%d", &n.val)
return err
default:
return fmt.Errorf("nullInt: unsupported type %T", src)
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
// Package totp implements RFC 6238 time-based one-time passwords (TOTP) on
// top of RFC 4226 HOTP, using HMAC-SHA1, 6 digits, and a 30-second step.
//
// It is deliberately dependency-free (standard library only) so it can be
// shared between the admin backend two-factor login and the user-center 2FA
// (doc/05) without pulling in a third-party OTP package.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"fmt"
"net/url"
"strings"
"time"
)
const (
// Digits is the number of decimal digits in a generated code.
Digits = 6
// Period is the time step length.
Period = 30 * time.Second
// secretBytes is the length of a freshly generated shared secret. 20 bytes
// (160 bits) matches the RFC 4226 recommendation and the SHA-1 block size.
secretBytes = 20
)
// b32 is the no-padding, upper-case Base32 encoding used for OTP secrets
// (the alphabet authenticator apps expect).
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
// GenerateSecret returns a new cryptographically random Base32-encoded secret.
func GenerateSecret() (string, error) {
buf := make([]byte, secretBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("totp.GenerateSecret: %w", err)
}
return b32.EncodeToString(buf), nil
}
// Code returns the TOTP code for the given Base32 secret at time t.
func Code(secret string, t time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(t.UTC().Unix()) / uint64(Period.Seconds())
return hotp(key, counter), nil
}
// Validate reports whether code is a valid TOTP for secret at time t, allowing
// ±skew steps of clock drift (skew=1 accepts the previous, current, and next
// 30-second windows). Comparison is constant-time.
func Validate(secret, code string, t time.Time, skew int) bool {
key, err := decodeSecret(secret)
if err != nil {
return false
}
code = strings.TrimSpace(code)
if len(code) != Digits {
return false
}
if skew < 0 {
skew = 0
}
base := int64(uint64(t.UTC().Unix()) / uint64(Period.Seconds()))
for d := -skew; d <= skew; d++ {
c := base + int64(d)
if c < 0 {
continue
}
want := hotp(key, uint64(c))
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return true
}
}
return false
}
// ProvisioningURI builds an otpauth:// URI suitable for rendering as a QR code
// or pasting into an authenticator app.
func ProvisioningURI(secret, account, issuer string) string {
label := url.PathEscape(issuer + ":" + account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", fmt.Sprintf("%d", Digits))
q.Set("period", fmt.Sprintf("%d", int(Period.Seconds())))
return "otpauth://totp/" + label + "?" + q.Encode()
}
// decodeSecret accepts a Base32 secret with or without padding/whitespace.
func decodeSecret(secret string) ([]byte, error) {
s := strings.ToUpper(strings.TrimSpace(secret))
s = strings.ReplaceAll(s, " ", "")
s = strings.TrimRight(s, "=")
key, err := b32.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("totp: invalid secret: %w", err)
}
if len(key) == 0 {
return nil, fmt.Errorf("totp: empty secret")
}
return key, nil
}
// hotp implements RFC 4226 HOTP with dynamic truncation.
func hotp(key []byte, counter uint64) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
value := (uint32(sum[offset]&0x7f) << 24) |
(uint32(sum[offset+1]) << 16) |
(uint32(sum[offset+2]) << 8) |
uint32(sum[offset+3])
mod := uint32(1)
for i := 0; i < Digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", Digits, value%mod)
}
+94
View File
@@ -0,0 +1,94 @@
package totp
import (
"testing"
"time"
)
// rfc6238Secret is the Base32 encoding of the ASCII seed "12345678901234567890"
// from RFC 6238 Appendix B (the SHA-1 test vector).
const rfc6238Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
func TestCode_RFC6238Vectors(t *testing.T) {
// 8-digit reference values from RFC 6238 truncated to our 6 digits.
cases := []struct {
unix int64
want string
}{
{59, "287082"},
{1111111109, "081804"},
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
{20000000000, "353130"},
}
for _, c := range cases {
got, err := Code(rfc6238Secret, time.Unix(c.unix, 0).UTC())
if err != nil {
t.Fatalf("Code(%d): %v", c.unix, err)
}
if got != c.want {
t.Errorf("Code(%d) = %s; want %s", c.unix, got, c.want)
}
}
}
func TestValidate_SkewWindow(t *testing.T) {
secret, err := GenerateSecret()
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
code, err := Code(secret, now)
if err != nil {
t.Fatal(err)
}
if !Validate(secret, code, now, 1) {
t.Error("current code rejected")
}
// Previous window must be accepted with skew=1.
if !Validate(secret, code, now.Add(Period), 1) {
t.Error("code from previous step rejected with skew=1")
}
// Two steps away must be rejected.
if Validate(secret, code, now.Add(2*Period+time.Second), 1) {
t.Error("stale code accepted outside skew window")
}
// Wrong code rejected.
if Validate(secret, "000000", now, 1) && code != "000000" {
t.Error("validate accepted obviously wrong code")
}
}
func TestValidate_BadInput(t *testing.T) {
secret, _ := GenerateSecret()
now := time.Now().UTC()
if Validate(secret, "12345", now, 1) { // too short
t.Error("accepted 5-digit code")
}
if Validate("not-base32!!", "123456", now, 1) {
t.Error("accepted invalid secret")
}
}
func TestProvisioningURI(t *testing.T) {
uri := ProvisioningURI(rfc6238Secret, "admin", "Pangolin")
if uri == "" {
t.Fatal("empty URI")
}
for _, sub := range []string{"otpauth://totp/", "secret=" + rfc6238Secret, "issuer=Pangolin"} {
if !contains(uri, sub) {
t.Errorf("URI %q missing %q", uri, sub)
}
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admins;
+11
View File
@@ -0,0 +1,11 @@
-- 管理端账户(独立监听 + 内网白名单 + 2FA 的身份表)
-- 凭证最小化:密码 argon2idTOTP 密钥 AES-GCM 加密后以二进制存储,明文绝不入库。
CREATE TABLE admins (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
pw_hash VARCHAR(255) NOT NULL, -- argon2idPHC 编码)
totp_secret VARBINARY(255) NOT NULL, -- AES-GCM(密钥来自 ADMIN_SECRET_KEY)
status ENUM('active','disabled') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
last_login_at DATETIME(6) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;