From 6e99e32285c4994c08020c473f981976de121446 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 14:49:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E5=90=8E=E5=8F=B0=EF=BC=88=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9B=91=E5=90=AC=20+=20=E7=99=BD=E5=90=8D=E5=8D=95=20+=202FA?= =?UTF-8?q?=EF=BC=89tsk=5FSCMtcGF4F434?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现「一个二进制三个监听」中的管理端: - 独立监听 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 --- server/cmd/adminctl/main.go | 166 +++++++ server/cmd/server/main.go | 53 ++ server/go.mod | 4 +- server/internal/admin/auth.go | 132 +++++ server/internal/admin/auth_test.go | 116 +++++ server/internal/admin/config.go | 210 ++++++++ server/internal/admin/config_test.go | 95 ++++ server/internal/admin/crypto.go | 126 +++++ server/internal/admin/crypto_test.go | 60 +++ server/internal/admin/handlers.go | 464 ++++++++++++++++++ server/internal/admin/handlers_test.go | 352 +++++++++++++ server/internal/admin/mw_ipallow.go | 60 +++ server/internal/admin/mw_ipallow_test.go | 48 ++ server/internal/admin/mw_session.go | 57 +++ server/internal/admin/router.go | 76 +++ server/internal/admin/securitylog.go | 74 +++ server/internal/admin/services.go | 167 +++++++ server/internal/admin/session.go | 126 +++++ server/internal/admin/session_test.go | 84 ++++ server/internal/admin/static/confirm.js | 11 + server/internal/admin/static/style.css | 45 ++ server/internal/admin/store.go | 243 +++++++++ server/internal/admin/templates.go | 75 +++ server/internal/admin/templates/audit.html | 47 ++ server/internal/admin/templates/base.html | 31 ++ server/internal/admin/templates/codes.html | 64 +++ .../internal/admin/templates/dashboard.html | 10 + server/internal/admin/templates/login.html | 20 + server/internal/admin/templates/nodes.html | 52 ++ server/internal/admin/testsupport_test.go | 213 ++++++++ server/internal/admin/types.go | 75 +++ server/internal/codes/admin_support.go | 103 ++++ server/internal/totp/totp.go | 132 +++++ server/internal/totp/totp_test.go | 94 ++++ server/migrations/000008_admins.down.sql | 1 + server/migrations/000008_admins.up.sql | 11 + 36 files changed, 3695 insertions(+), 2 deletions(-) create mode 100644 server/cmd/adminctl/main.go create mode 100644 server/internal/admin/auth.go create mode 100644 server/internal/admin/auth_test.go create mode 100644 server/internal/admin/config.go create mode 100644 server/internal/admin/config_test.go create mode 100644 server/internal/admin/crypto.go create mode 100644 server/internal/admin/crypto_test.go create mode 100644 server/internal/admin/handlers.go create mode 100644 server/internal/admin/handlers_test.go create mode 100644 server/internal/admin/mw_ipallow.go create mode 100644 server/internal/admin/mw_ipallow_test.go create mode 100644 server/internal/admin/mw_session.go create mode 100644 server/internal/admin/router.go create mode 100644 server/internal/admin/securitylog.go create mode 100644 server/internal/admin/services.go create mode 100644 server/internal/admin/session.go create mode 100644 server/internal/admin/session_test.go create mode 100644 server/internal/admin/static/confirm.js create mode 100644 server/internal/admin/static/style.css create mode 100644 server/internal/admin/store.go create mode 100644 server/internal/admin/templates.go create mode 100644 server/internal/admin/templates/audit.html create mode 100644 server/internal/admin/templates/base.html create mode 100644 server/internal/admin/templates/codes.html create mode 100644 server/internal/admin/templates/dashboard.html create mode 100644 server/internal/admin/templates/login.html create mode 100644 server/internal/admin/templates/nodes.html create mode 100644 server/internal/admin/testsupport_test.go create mode 100644 server/internal/admin/types.go create mode 100644 server/internal/codes/admin_support.go create mode 100644 server/internal/totp/totp.go create mode 100644 server/internal/totp/totp_test.go create mode 100644 server/migrations/000008_admins.down.sql create mode 100644 server/migrations/000008_admins.up.sql diff --git a/server/cmd/adminctl/main.go b/server/cmd/adminctl/main.go new file mode 100644 index 0000000..bea0be3 --- /dev/null +++ b/server/cmd/adminctl/main.go @@ -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 ") + 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)") +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 0af43dc..f4ad576 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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 +} diff --git a/server/go.mod b/server/go.mod index 3a95d70..44e7fb6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/internal/admin/auth.go b/server/internal/admin/auth.go new file mode 100644 index 0000000..b120342 --- /dev/null +++ b/server/internal/admin/auth.go @@ -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() +} diff --git a/server/internal/admin/auth_test.go b/server/internal/admin/auth_test.go new file mode 100644 index 0000000..b89f357 --- /dev/null +++ b/server/internal/admin/auth_test.go @@ -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") + } +} diff --git a/server/internal/admin/config.go b/server/internal/admin/config.go new file mode 100644 index 0000000..1c77bea --- /dev/null +++ b/server/internal/admin/config.go @@ -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 +} diff --git a/server/internal/admin/config_test.go b/server/internal/admin/config_test.go new file mode 100644 index 0000000..505d9f7 --- /dev/null +++ b/server/internal/admin/config_test.go @@ -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) + } +} diff --git a/server/internal/admin/crypto.go b/server/internal/admin/crypto.go new file mode 100644 index 0000000..e24ae55 --- /dev/null +++ b/server/internal/admin/crypto.go @@ -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=...$$. +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) +} diff --git a/server/internal/admin/crypto_test.go b/server/internal/admin/crypto_test.go new file mode 100644 index 0000000..7b127c7 --- /dev/null +++ b/server/internal/admin/crypto_test.go @@ -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") + } +} diff --git a/server/internal/admin/handlers.go b/server/internal/admin/handlers.go new file mode 100644 index 0000000..278c783 --- /dev/null +++ b/server/internal/admin/handlers.go @@ -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 +} diff --git a/server/internal/admin/handlers_test.go b/server/internal/admin/handlers_test.go new file mode 100644 index 0000000..04d88b5 --- /dev/null +++ b/server/internal/admin/handlers_test.go @@ -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") + } +} diff --git a/server/internal/admin/mw_ipallow.go b/server/internal/admin/mw_ipallow.go new file mode 100644 index 0000000..be974d6 --- /dev/null +++ b/server/internal/admin/mw_ipallow.go @@ -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 +} diff --git a/server/internal/admin/mw_ipallow_test.go b/server/internal/admin/mw_ipallow_test.go new file mode 100644 index 0000000..11b7601 --- /dev/null +++ b/server/internal/admin/mw_ipallow_test.go @@ -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") + } +} diff --git a/server/internal/admin/mw_session.go b/server/internal/admin/mw_session.go new file mode 100644 index 0000000..4388f37 --- /dev/null +++ b/server/internal/admin/mw_session.go @@ -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 +} diff --git a/server/internal/admin/router.go b/server/internal/admin/router.go new file mode 100644 index 0000000..778af3d --- /dev/null +++ b/server/internal/admin/router.go @@ -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 +} diff --git a/server/internal/admin/securitylog.go b/server/internal/admin/securitylog.go new file mode 100644 index 0000000..6496bb9 --- /dev/null +++ b/server/internal/admin/securitylog.go @@ -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 +} diff --git a/server/internal/admin/services.go b/server/internal/admin/services.go new file mode 100644 index 0000000..1c8f295 --- /dev/null +++ b/server/internal/admin/services.go @@ -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 +} diff --git a/server/internal/admin/session.go b/server/internal/admin/session.go new file mode 100644 index 0000000..f75e502 --- /dev/null +++ b/server/internal/admin/session.go @@ -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 +} diff --git a/server/internal/admin/session_test.go b/server/internal/admin/session_test.go new file mode 100644 index 0000000..01aa5e9 --- /dev/null +++ b/server/internal/admin/session_test.go @@ -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") + } +} diff --git a/server/internal/admin/static/confirm.js b/server/internal/admin/static/confirm.js new file mode 100644 index 0000000..c31a883 --- /dev/null +++ b/server/internal/admin/static/confirm.js @@ -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); diff --git a/server/internal/admin/static/style.css b/server/internal/admin/static/style.css new file mode 100644 index 0000000..4d24df0 --- /dev/null +++ b/server/internal/admin/static/style.css @@ -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; } diff --git a/server/internal/admin/store.go b/server/internal/admin/store.go new file mode 100644 index 0000000..152dfd1 --- /dev/null +++ b/server/internal/admin/store.go @@ -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() +} diff --git a/server/internal/admin/templates.go b/server/internal/admin/templates.go new file mode 100644 index 0000000..c38c4a6 --- /dev/null +++ b/server/internal/admin/templates.go @@ -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) + } +} diff --git a/server/internal/admin/templates/audit.html b/server/internal/admin/templates/audit.html new file mode 100644 index 0000000..1206413 --- /dev/null +++ b/server/internal/admin/templates/audit.html @@ -0,0 +1,47 @@ +{{define "title"}}审计{{end}} +{{define "content"}} +

审计日志

+
+ + + + + + +
+ + + + + {{range .Data.Entries}} + + {{else}} + + {{end}} + +
ID时间操作者动作目标meta
{{.ID}}{{fmtTime .At}}{{.Actor}}{{.Action}}{{.Target}}{{.Meta}}
无匹配记录
+
+ {{if .Data.HasPrev}}上一页{{end}} + 共 {{.Data.Total}} 条 + {{if .Data.HasNext}}下一页{{end}} +
+ +

节点事件

+
+ + + + + +
+ + + + {{range .Data.NodeEvents}} + + {{else}} + + {{end}} + +
ID时间节点事件detail
{{.ID}}{{fmtTime .At}}{{.NodeID}}{{.Event}}{{.Detail}}
输入节点 ID 查看其事件
+{{end}} diff --git a/server/internal/admin/templates/base.html b/server/internal/admin/templates/base.html new file mode 100644 index 0000000..e20298f --- /dev/null +++ b/server/internal/admin/templates/base.html @@ -0,0 +1,31 @@ +{{define "base"}} + + + + +{{block "title" .}}控制台{{end}} · Pangolin 管理后台 + + + +
+ Pangolin 管理后台 + + + {{.Username}} +
+ + +
+
+
+{{if .Flash}}
{{.Flash}}
{{end}} +
+{{block "content" .}}{{end}} +
+ +{{end}} diff --git a/server/internal/admin/templates/codes.html b/server/internal/admin/templates/codes.html new file mode 100644 index 0000000..eccf561 --- /dev/null +++ b/server/internal/admin/templates/codes.html @@ -0,0 +1,64 @@ +{{define "title"}}码批次{{end}} +{{define "content"}} +

码批次

+ +
+

生成新批次

+

明文激活码仅在本次 CSV 下载中出现一次,不落盘、不入日志。请妥善保存下载文件。

+
+ + + + + + + +
+
+ +
+

批次列表

+ + + + {{range .Data.Batches}} + + + + + + + {{else}} + + {{end}} + +
ID渠道创建者备注创建时间总数已用作废未用操作
{{.ID}}{{.Channel}}{{.CreatedBy}}{{.Note}}{{fmtTime .CreatedAt}}{{.Total}}{{.Redeemed}}{{.Void}}{{.Unused}} + {{if gt .Unused 0}} +
+ + + + +
+ {{else}}{{end}} +
暂无批次
+
+ {{if .Data.HasPrev}}上一页{{end}} + {{if .Data.HasNext}}下一页{{end}} +
+
+ +{{end}} diff --git a/server/internal/admin/templates/dashboard.html b/server/internal/admin/templates/dashboard.html new file mode 100644 index 0000000..7142f7c --- /dev/null +++ b/server/internal/admin/templates/dashboard.html @@ -0,0 +1,10 @@ +{{define "title"}}概览{{end}} +{{define "content"}} +

概览

+

欢迎,{{.Username}}。请从上方导航选择操作。

+ +{{end}} diff --git a/server/internal/admin/templates/login.html b/server/internal/admin/templates/login.html new file mode 100644 index 0000000..e167976 --- /dev/null +++ b/server/internal/admin/templates/login.html @@ -0,0 +1,20 @@ +{{define "login"}} + + + + +登录 · Pangolin 管理后台 + + + + + +{{end}} diff --git a/server/internal/admin/templates/nodes.html b/server/internal/admin/templates/nodes.html new file mode 100644 index 0000000..531efab --- /dev/null +++ b/server/internal/admin/templates/nodes.html @@ -0,0 +1,52 @@ +{{define "title"}}节点{{end}} +{{define "content"}} +

节点

+{{if not .Data.ProvisionReady}}
节点替换服务(#14)尚未接入,相关按钮已置灰。
{{end}} +{{if not .Data.LifecycleReady}}
节点生命周期服务(#5)尚未接入,上/下线按钮已置灰。
{{end}} + + + + + {{range .Data.Nodes}} + + + + + + + + + + + + {{else}} + + {{end}} + +
ID名称地区tier权重状态厂商近期事件操作
{{.ID}}{{.NameZH}}
{{.UUID}}
{{.Region}}{{.Tier}}{{.Weight}}{{.Status}}{{.Provider}} + {{range .RecentEvents}}
{{.Event}} {{fmtTime .At}}
{{else}}{{end}} +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
暂无节点
+ +{{end}} diff --git a/server/internal/admin/testsupport_test.go b/server/internal/admin/testsupport_test.go new file mode 100644 index 0000000..b37eaf4 --- /dev/null +++ b/server/internal/admin/testsupport_test.go @@ -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:]) +} diff --git a/server/internal/admin/types.go b/server/internal/admin/types.go new file mode 100644 index 0000000..eb2f3b1 --- /dev/null +++ b/server/internal/admin/types.go @@ -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 +} diff --git a/server/internal/codes/admin_support.go b/server/internal/codes/admin_support.go new file mode 100644 index 0000000..9ca8940 --- /dev/null +++ b/server/internal/codes/admin_support.go @@ -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 +} diff --git a/server/internal/totp/totp.go b/server/internal/totp/totp.go new file mode 100644 index 0000000..28f5c6a --- /dev/null +++ b/server/internal/totp/totp.go @@ -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) +} diff --git a/server/internal/totp/totp_test.go b/server/internal/totp/totp_test.go new file mode 100644 index 0000000..a56c481 --- /dev/null +++ b/server/internal/totp/totp_test.go @@ -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 +} diff --git a/server/migrations/000008_admins.down.sql b/server/migrations/000008_admins.down.sql new file mode 100644 index 0000000..a86e790 --- /dev/null +++ b/server/migrations/000008_admins.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS admins; diff --git a/server/migrations/000008_admins.up.sql b/server/migrations/000008_admins.up.sql new file mode 100644 index 0000000..194e35f --- /dev/null +++ b/server/migrations/000008_admins.up.sql @@ -0,0 +1,11 @@ +-- 管理端账户(独立监听 + 内网白名单 + 2FA 的身份表) +-- 凭证最小化:密码 argon2id,TOTP 密钥 AES-GCM 加密后以二进制存储,明文绝不入库。 +CREATE TABLE admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + pw_hash VARCHAR(255) NOT NULL, -- argon2id(PHC 编码) + 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;