feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:
- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
(登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)
测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// Command adminctl manages admin-backend accounts from the CLI.
|
||||
//
|
||||
// The first administrator must be created here (the web UI has no self-signup):
|
||||
//
|
||||
// adminctl create -username alice
|
||||
//
|
||||
// Requires DB_DSN and ADMIN_SECRET_KEY in the environment (same as the server).
|
||||
// On success it prints the otpauth:// provisioning URI and Base32 secret to the
|
||||
// terminal so the operator can add it to an authenticator app / render a QR.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/admin"
|
||||
"github.com/wangjia/pangolin/server/internal/db"
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "create":
|
||||
if err := runCreate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stdout, "adminctl — Pangolin admin account tool")
|
||||
fmt.Fprintln(os.Stdout, "")
|
||||
fmt.Fprintln(os.Stdout, "Usage:")
|
||||
fmt.Fprintln(os.Stdout, " adminctl create -username <name>")
|
||||
fmt.Fprintln(os.Stdout, "")
|
||||
fmt.Fprintln(os.Stdout, "Env: DB_DSN, ADMIN_SECRET_KEY (32-byte hex/base64)")
|
||||
}
|
||||
|
||||
func runCreate(args []string) error {
|
||||
var username string
|
||||
fs := flag.NewFlagSet("create", flag.ContinueOnError)
|
||||
fs.StringVar(&username, "username", "", "admin username")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return errors.New("-username is required")
|
||||
}
|
||||
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
return errors.New("DB_DSN is required")
|
||||
}
|
||||
keyStr := os.Getenv("ADMIN_SECRET_KEY")
|
||||
if keyStr == "" {
|
||||
return errors.New("ADMIN_SECRET_KEY is required")
|
||||
}
|
||||
key, err := parseSecretKey(keyStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err := readPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return errors.New("password must be at least 8 characters")
|
||||
}
|
||||
|
||||
pwHash, err := admin.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, err := totp.GenerateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := admin.EncryptSecret(key, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
database, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
store := admin.NewDBStore(database)
|
||||
id, err := store.CreateAdmin(context.Background(), username, pwHash, enc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uri := totp.ProvisioningURI(secret, username, "Pangolin")
|
||||
fmt.Println()
|
||||
fmt.Printf("✓ 已创建管理员 #%d: %s\n", id, username)
|
||||
fmt.Println()
|
||||
fmt.Println("请在身份验证器中添加以下 TOTP(二维码可由下方 otpauth URI 生成):")
|
||||
fmt.Println(" Secret (Base32):", secret)
|
||||
fmt.Println(" otpauth URI :", uri)
|
||||
fmt.Println()
|
||||
fmt.Println("登录需输入:用户名 + 密码 + 6 位动态验证码。")
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPassword reads a password from ADMIN_PASSWORD or, if unset, prompts on the
|
||||
// terminal with echo disabled (falling back to a plain stdin line).
|
||||
func readPassword() (string, error) {
|
||||
if v := os.Getenv("ADMIN_PASSWORD"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
fd := int(os.Stdin.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
fmt.Print("Password: ")
|
||||
b, err := term.ReadPassword(fd)
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
// parseSecretKey accepts a 32-byte key as hex or base64 (mirrors the admin
|
||||
// package's own loader so the CLI and server agree on the key format).
|
||||
func parseSecretKey(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("ADMIN_SECRET_KEY must decode to exactly 32 bytes (hex or base64)")
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/admin"
|
||||
"github.com/wangjia/pangolin/server/internal/db"
|
||||
"github.com/wangjia/pangolin/server/internal/redisutil"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -23,6 +27,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Admin backend (separate internal listener). Started only when explicitly
|
||||
// configured so the public API can run on its own. See internal/admin.
|
||||
startAdminIfConfigured()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -38,3 +46,48 @@ func main() {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startAdminIfConfigured launches the admin listener in a background goroutine
|
||||
// when ADMIN_SECRET_KEY and DB_DSN are present. The admin port binds an
|
||||
// internal address only (enforced by admin.FromEnv) and is fronted by an IP
|
||||
// allowlist + two-factor login.
|
||||
func startAdminIfConfigured() {
|
||||
if os.Getenv("ADMIN_SECRET_KEY") == "" || os.Getenv("DB_DSN") == "" {
|
||||
log.Printf("admin backend disabled (set DB_DSN and ADMIN_SECRET_KEY to enable)")
|
||||
return
|
||||
}
|
||||
cfg, err := admin.FromEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("admin config: %v", err)
|
||||
}
|
||||
database, err := db.Open(os.Getenv("DB_DSN"))
|
||||
if err != nil {
|
||||
log.Fatalf("admin db: %v", err)
|
||||
}
|
||||
rdb, err := redisutil.New(
|
||||
getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
os.Getenv("REDIS_PASSWORD"), 0)
|
||||
if err != nil {
|
||||
log.Fatalf("admin redis: %v", err)
|
||||
}
|
||||
|
||||
svc := admin.BuildServices(database, rdb, 5, cfg.LoginLockDuration)
|
||||
handler, err := admin.NewHandler(cfg, database, rdb, svc, log.Default())
|
||||
if err != nil {
|
||||
log.Fatalf("admin handler: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("admin backend listening on %s (internal only)", cfg.Listen)
|
||||
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
|
||||
log.Fatalf("admin server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func getEnvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters. These follow OWASP's "second" recommended profile
|
||||
// (64 MiB, 1 iteration, parallelism 4) — strong yet fast enough for an
|
||||
// interactive admin login.
|
||||
const (
|
||||
argonMemory = 64 * 1024 // KiB
|
||||
argonTime = 1
|
||||
argonParallelism = 4
|
||||
argonSaltLen = 16
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
var b64 = base64.RawStdEncoding
|
||||
|
||||
// HashPassword hashes a plaintext password with argon2id and returns a PHC
|
||||
// formatted string: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("admin.HashPassword: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonParallelism, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonParallelism,
|
||||
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether password matches the given PHC-encoded
|
||||
// argon2id hash, in constant time.
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
params, salt, want, err := decodePHC(encoded)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, params.t, params.m, params.p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
type argonParams struct {
|
||||
m uint32
|
||||
t uint32
|
||||
p uint8
|
||||
}
|
||||
|
||||
func decodePHC(encoded string) (argonParams, []byte, []byte, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return argonParams{}, nil, nil, errors.New("admin: malformed argon2 hash")
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return argonParams{}, nil, nil, errors.New("admin: bad argon2 version")
|
||||
}
|
||||
var pr argonParams
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pr.m, &pr.t, &pr.p); err != nil {
|
||||
return argonParams{}, nil, nil, errors.New("admin: bad argon2 params")
|
||||
}
|
||||
salt, err := b64.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return argonParams{}, nil, nil, errors.New("admin: bad argon2 salt")
|
||||
}
|
||||
hash, err := b64.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return argonParams{}, nil, nil, errors.New("admin: bad argon2 hash")
|
||||
}
|
||||
return pr, salt, hash, nil
|
||||
}
|
||||
|
||||
// EncryptSecret seals plaintext with AES-256-GCM under key (32 bytes). The
|
||||
// returned blob is nonce || ciphertext, suitable for storing in a VARBINARY
|
||||
// column. Used to keep TOTP secrets encrypted at rest.
|
||||
func EncryptSecret(key []byte, plaintext string) ([]byte, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("admin.EncryptSecret nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
|
||||
}
|
||||
|
||||
// DecryptSecret reverses EncryptSecret.
|
||||
func DecryptSecret(key, blob []byte) (string, error) {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns := gcm.NonceSize()
|
||||
if len(blob) < ns {
|
||||
return "", errors.New("admin.DecryptSecret: ciphertext too short")
|
||||
}
|
||||
nonce, ct := blob[:ns], blob[ns:]
|
||||
pt, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("admin.DecryptSecret: %w", err)
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("admin: secret key must be 32 bytes, got %d", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin: aes cipher: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assetsFS embed.FS
|
||||
|
||||
// pageData is the common envelope passed to every rendered page.
|
||||
type pageData struct {
|
||||
Username string
|
||||
CSRF string
|
||||
Active string // nav key for highlight
|
||||
Flash string
|
||||
Data any
|
||||
}
|
||||
|
||||
// renderer holds the parsed template set.
|
||||
type renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"fmtTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05Z")
|
||||
},
|
||||
}
|
||||
|
||||
// newRenderer parses base.html with each page template into its own set.
|
||||
func newRenderer() (*renderer, error) {
|
||||
pages := map[string][]string{
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/base.html", "templates/dashboard.html"},
|
||||
"codes": {"templates/base.html", "templates/codes.html"},
|
||||
"nodes": {"templates/base.html", "templates/nodes.html"},
|
||||
"audit": {"templates/base.html", "templates/audit.html"},
|
||||
}
|
||||
r := &renderer{pages: make(map[string]*template.Template, len(pages))}
|
||||
for name, files := range pages {
|
||||
t, err := template.New("").Funcs(tmplFuncs).ParseFS(assetsFS, files...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin.newRenderer parse %s: %w", name, err)
|
||||
}
|
||||
r.pages[name] = t
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// render writes the named page. The login page renders itself; all others
|
||||
// render through the "base" layout.
|
||||
func (r *renderer) render(w http.ResponseWriter, name string, data pageData) {
|
||||
t, ok := r.pages[name]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
root := "base"
|
||||
if name == "login" {
|
||||
root = "login"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, root, data); err != nil {
|
||||
// Header may already be written; log via stderr only.
|
||||
fmt.Fprintf(io.Discard, "render error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "title"}}审计{{end}}
|
||||
{{define "content"}}
|
||||
<h1>审计日志</h1>
|
||||
<form method="get" action="/audit" class="filters">
|
||||
<label>操作者<input type="text" name="actor" value="{{.Data.Filter.Actor}}"></label>
|
||||
<label>动作<input type="text" name="action" value="{{.Data.Filter.Action}}"></label>
|
||||
<label>目标<input type="text" name="target" value="{{.Data.Filter.Target}}"></label>
|
||||
<label>起<input type="text" name="from" value="{{.Data.FromStr}}" placeholder="2006-01-02"></label>
|
||||
<label>止<input type="text" name="to" value="{{.Data.ToStr}}" placeholder="2006-01-02"></label>
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>时间</th><th>操作者</th><th>动作</th><th>目标</th><th>meta</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Entries}}
|
||||
<tr><td>{{.ID}}</td><td>{{fmtTime .At}}</td><td>{{.Actor}}</td><td>{{.Action}}</td><td>{{.Target}}</td><td class="meta">{{.Meta}}</td></tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="muted">无匹配记录</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
{{if .Data.HasPrev}}<a href="{{.Data.PrevURL}}">上一页</a>{{end}}
|
||||
<span class="muted">共 {{.Data.Total}} 条</span>
|
||||
{{if .Data.HasNext}}<a href="{{.Data.NextURL}}">下一页</a>{{end}}
|
||||
</div>
|
||||
|
||||
<h2>节点事件</h2>
|
||||
<form method="get" action="/audit" class="filters">
|
||||
<input type="hidden" name="actor" value="{{.Data.Filter.Actor}}">
|
||||
<input type="hidden" name="action" value="{{.Data.Filter.Action}}">
|
||||
<input type="hidden" name="target" value="{{.Data.Filter.Target}}">
|
||||
<label>节点 ID<input type="text" name="node_id" value="{{.Data.NodeIDStr}}"></label>
|
||||
<button type="submit">查看节点事件</button>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>时间</th><th>节点</th><th>事件</th><th>detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.NodeEvents}}
|
||||
<tr><td>{{.ID}}</td><td>{{fmtTime .At}}</td><td>{{.NodeID}}</td><td>{{.Event}}</td><td class="meta">{{.Detail}}</td></tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">输入节点 ID 查看其事件</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}控制台{{end}} · Pangolin 管理后台</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">Pangolin 管理后台</span>
|
||||
<nav>
|
||||
<a href="/" class="{{if eq .Active "dashboard"}}on{{end}}">概览</a>
|
||||
<a href="/codes" class="{{if eq .Active "codes"}}on{{end}}">码批次</a>
|
||||
<a href="/nodes" class="{{if eq .Active "nodes"}}on{{end}}">节点</a>
|
||||
<a href="/audit" class="{{if eq .Active "audit"}}on{{end}}">审计</a>
|
||||
</nav>
|
||||
<span class="who">
|
||||
{{.Username}}
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<button class="link" type="submit">退出</button>
|
||||
</form>
|
||||
</span>
|
||||
</header>
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{define "title"}}码批次{{end}}
|
||||
{{define "content"}}
|
||||
<h1>码批次</h1>
|
||||
|
||||
<section class="card">
|
||||
<h2>生成新批次</h2>
|
||||
<p class="hint">明文激活码仅在本次 CSV 下载中出现一次,不落盘、不入日志。请妥善保存下载文件。</p>
|
||||
<form method="post" action="/codes">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<label>套餐
|
||||
<select name="plan" required>
|
||||
<option value="pro">pro</option>
|
||||
<option value="team">team</option>
|
||||
<option value="free">free</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>时长(天)<input type="number" name="duration_days" min="1" max="3650" value="30" required></label>
|
||||
<label>数量<input type="number" name="count" min="1" max="5000" value="100" required></label>
|
||||
<label>渠道
|
||||
<select name="channel" required>
|
||||
<option value="manual">manual</option>
|
||||
<option value="store">store</option>
|
||||
<option value="tg">tg</option>
|
||||
<option value="line">line</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>备注<input type="text" name="note" maxlength="255" placeholder="可选"></label>
|
||||
<button type="submit">生成并下载 CSV</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>批次列表</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>渠道</th><th>创建者</th><th>备注</th><th>创建时间</th><th>总数</th><th>已用</th><th>作废</th><th>未用</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Batches}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td><td>{{.Channel}}</td><td>{{.CreatedBy}}</td><td>{{.Note}}</td>
|
||||
<td>{{fmtTime .CreatedAt}}</td>
|
||||
<td>{{.Total}}</td><td>{{.Redeemed}}</td><td>{{.Void}}</td><td>{{.Unused}}</td>
|
||||
<td>
|
||||
{{if gt .Unused 0}}
|
||||
<form method="post" action="/codes/void" class="inline confirm" data-confirm="确认作废批次 {{.ID}} 的全部未使用激活码?此操作不可撤销。">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="batch_id" value="{{.ID}}">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button class="danger" type="submit">整批作废</button>
|
||||
</form>
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="10" class="muted">暂无批次</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
{{if .Data.HasPrev}}<a href="/codes?offset={{.Data.PrevOffset}}">上一页</a>{{end}}
|
||||
{{if .Data.HasNext}}<a href="/codes?offset={{.Data.NextOffset}}">下一页</a>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
<script src="/static/confirm.js"></script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "title"}}概览{{end}}
|
||||
{{define "content"}}
|
||||
<h1>概览</h1>
|
||||
<p>欢迎,{{.Username}}。请从上方导航选择操作。</p>
|
||||
<ul class="cards">
|
||||
<li><a href="/codes"><strong>码批次</strong><span>生成 / 导出 / 作废</span></a></li>
|
||||
<li><a href="/nodes"><strong>节点</strong><span>状态 / 替换 / 上下线</span></a></li>
|
||||
<li><a href="/audit"><strong>审计</strong><span>操作与事件查询</span></a></li>
|
||||
</ul>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "login"}}<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · Pangolin 管理后台</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="centered">
|
||||
<form method="post" action="/login" class="card login">
|
||||
<h1>管理后台登录</h1>
|
||||
{{if .Flash}}<div class="error">{{.Flash}}</div>{{end}}
|
||||
<label>用户名<input type="text" name="username" autocomplete="username" required autofocus></label>
|
||||
<label>密码<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<p class="hint">仅限内网 / SSH 隧道访问。</p>
|
||||
</form>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "title"}}节点{{end}}
|
||||
{{define "content"}}
|
||||
<h1>节点</h1>
|
||||
{{if not .Data.ProvisionReady}}<div class="warn">节点替换服务(#14)尚未接入,相关按钮已置灰。</div>{{end}}
|
||||
{{if not .Data.LifecycleReady}}<div class="warn">节点生命周期服务(#5)尚未接入,上/下线按钮已置灰。</div>{{end}}
|
||||
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>地区</th><th>tier</th><th>权重</th><th>状态</th><th>厂商</th><th>近期事件</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Nodes}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.NameZH}}<br><span class="muted">{{.UUID}}</span></td>
|
||||
<td>{{.Region}}</td>
|
||||
<td>{{.Tier}}</td>
|
||||
<td>{{.Weight}}</td>
|
||||
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{.Provider}}</td>
|
||||
<td>
|
||||
{{range .RecentEvents}}<div class="ev">{{.Event}} <span class="muted">{{fmtTime .At}}</span></div>{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="ops">
|
||||
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认替换节点 {{.ID}}({{.NameZH}})?将开通新节点并下线旧节点。">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="node_id" value="{{.ID}}">
|
||||
<input type="hidden" name="op" value="replace">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button type="submit" {{if not $.Data.ProvisionReady}}disabled{{end}}>替换</button>
|
||||
</form>
|
||||
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 draining(停止接入新连接)?">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="node_id" value="{{.ID}}">
|
||||
<input type="hidden" name="op" value="draining">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>draining</button>
|
||||
</form>
|
||||
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 up(恢复接入)?">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="node_id" value="{{.ID}}">
|
||||
<input type="hidden" name="op" value="up">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>up</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9" class="muted">暂无节点</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<script src="/static/confirm.js"></script>
|
||||
{{end}}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS admins;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user