merge: 管理端最小后台 + TOTP 双因素 [tsk_SCMtcGF4F434]

以 main 现有 cmd/server 设计为准(保留 probe + /v1 路由),叠加分支新增:internal/admin/ 自包含后台(session/2FA/IP 白名单/审计/模板)、internal/totp/ + cmd/adminctl/(TOTP + 管理员创建 CLI)、cmd/server/main.go 的 startAdminIfConfigured()(opt-in:ADMIN_SECRET_KEY+DB_DSN 才起)。统一 getenvDefault 命名;go.mod 取 HEAD + go mod tidy(x/term 提为直接依赖);admins 迁移与 main 既有 000008_provision 撞号,重排为 000009_admins。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 18:47:38 +08:00
36 changed files with 3687 additions and 3 deletions
+46 -2
View File
@@ -9,11 +9,13 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/wangjia/pangolin/server/internal/redisutil"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
"github.com/wangjia/pangolin/server/api/gen"
"github.com/wangjia/pangolin/server/internal/admin"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/httpapi"
"github.com/wangjia/pangolin/server/internal/redisutil"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)
func main() {
@@ -28,6 +30,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)
@@ -77,6 +83,44 @@ func main() {
}
}
// 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