重构:拆出 auth / blocking / runtimesettings / help / mqttforward 包

第二批:把根目录中纯逻辑领域文件(cache、service、admin route)按业务边界
迁到 internal/ 下的子包。各子包暴露 RegisterRoutes 给 web 包调用,根目录
只留下一行 bridge 文件保留旧名字别名。

新增包
- internal/auth/        SessionClaims / Manager / RequireAdmin / HashPassword /
                        VerifyPassword / AdminUserResponse 等。原 auth.go 中
                        被两个 admin route 依赖的 sessionClaims 现在以 auth.
                        SessionClaims 形式被它们 import;不再被锁在 main 包。
- internal/blocking/    Cache + RegisterRoutes,以前散在 blocking_cache.go
                        和 admin_blocking_routes.go 里。
- internal/runtimesettings/  Cache + RegisterRoutes。
- internal/help/        RenderMarkdown / RegisterPublicRoutes /
                        RegisterAdminRoutes(拆分原来的 registerHelpRoutes
                        和 registerAdminHelpRoutes 两条入口)。
- internal/mqttforward/ Manager / Reloader / Stats / RegisterRoutes。
                        forwarder runner、循环抑制 cache 等运行时逻辑随之迁入。
- internal/webutil/     ParseListOptions / WriteListResponse[WithTotal] /
                        ParseMapReportListOptions / ParseMapReportViewportOptions
                        以及 PtrString/PtrInt64/... 等指针解引用 helper。
                        以前散在 web.go 中,现在被各 admin route 子包共享,
                        避免 internal/blocking → internal/web → internal/blocking
                        的循环依赖。
- internal/store/testutil/  OpenStore(t) helper,让其它包测试零样板拿到 store。

根目录新增 bridge 文件
- blocking_bridge.go / runtime_settings_bridge.go / help_bridge.go /
  mqttforward_bridge.go:用 type alias + thin wrapper 把上述子包的导出
  名映射到旧的小写名(blockingCache、registerAdminBlockingRoutes 等),
  让 main.go / web.go 等仍未迁出的文件无须改动。

修改
- auth.go 改为对 internal/auth 的 bridge;web.go 中 sessions.newCookie /
  clearCookie 改为 NewCookie / ClearCookie。
- main_test.go 中 BlockingViolationForRecord* 测试不再直接构造未导出字段,
  改成走 store.CreateNodeBlocking → newBlockingCache 的真实路径。
- internal/mqttforward 把以前 *_store.go 中没有方法依赖的运行时类型
  (forwarder runner、loop cache)和 admin route 一并归位;mqtt_status.go
  暂时仍留在根目录(依赖 main 中的 mqttClientInfoFromClient)。

go build ./... / go test ./... 全部通过;测试数量未变。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 14:23:56 +08:00
co-authored by Claude
parent eff4972668
commit c527a9fd9a
23 changed files with 786 additions and 313 deletions
+169
View File
@@ -0,0 +1,169 @@
// Package auth 实现 admin 端 cookie session 与密码散列。
//
// 拆离自原来 main 包的 auth.go:让所有 admin route 包都可以直接 import 这里的
// SessionClaims / Manager / RequireAdmin,而不是被锁在根 main 包里。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"meshtastic_mqtt_server/internal/config"
"meshtastic_mqtt_server/internal/store"
)
const (
AdminRole = "admin"
adminSessionCookie = "mesh_admin_session"
// AdminClaimsKey 是中间件挂在 gin.Context 上的 keyhandler 可用
// `c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)` 取出。
AdminClaimsKey = "admin_claims"
)
// AdminUserDTO 是 /me /login 等接口返回给前端的最小用户视图。
type AdminUserDTO struct {
Username string `json:"username"`
Role string `json:"role"`
}
// SessionClaims 是 cookie 中持久化的会话内容。
type SessionClaims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
Expires int64 `json:"expires"`
}
// Manager 持有签名密钥与 cookie 配置,是发布 / 校验 cookie 的入口。
type Manager struct {
secret []byte
secure bool
ttl time.Duration
}
// NewManager 根据配置构造 Manager。如果 SessionSecret 空,会随机生成 32 字节。
func NewManager(cfg config.WebAdminConfig) (*Manager, error) {
secret := strings.TrimSpace(cfg.SessionSecret)
if secret == "" {
generated := make([]byte, 32)
if _, err := rand.Read(generated); err != nil {
return nil, fmt.Errorf("generate admin session secret: %w", err)
}
return &Manager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
}
return &Manager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
}
// HashPassword 用 bcrypt 默认 cost 散列;用于建账号、改密码。
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword 校验明文密码是否与散列匹配。
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// AdminUserResponse 把 store.UserRecord 转成对外 DTO。
func AdminUserResponse(user store.UserRecord) AdminUserDTO {
return AdminUserDTO{Username: user.Username, Role: user.Role}
}
// NewCookie 为已登录用户构造一份带签名的 session cookie。
func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) {
claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
data, err := json.Marshal(claims)
if err != nil {
return nil, err
}
payload := base64.RawURLEncoding.EncodeToString(data)
signature := sm.sign(payload)
return &http.Cookie{
Name: adminSessionCookie,
Value: payload + "." + signature,
Path: "/",
MaxAge: int(sm.ttl.Seconds()),
HttpOnly: true,
Secure: sm.secure,
SameSite: http.SameSiteLaxMode,
}, nil
}
// ClearCookie 返回一个把 cookie 立即清掉的 *http.Cookie,供 logout 使用。
func (sm *Manager) ClearCookie() *http.Cookie {
return &http.Cookie{
Name: adminSessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: sm.secure,
SameSite: http.SameSiteLaxMode,
}
}
// ClaimsFromRequest 校验请求 cookie 的签名 / 过期 / 角色。
func (sm *Manager) ClaimsFromRequest(c *gin.Context) (*SessionClaims, error) {
cookie, err := c.Cookie(adminSessionCookie)
if err != nil {
return nil, err
}
parts := strings.Split(cookie, ".")
if len(parts) != 2 {
return nil, errors.New("invalid session")
}
if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) {
return nil, errors.New("invalid session signature")
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, err
}
var claims SessionClaims
if err := json.Unmarshal(data, &claims); err != nil {
return nil, err
}
if claims.Expires <= time.Now().Unix() {
return nil, errors.New("session expired")
}
if claims.Role != AdminRole {
return nil, errors.New("admin required")
}
return &claims, nil
}
func (sm *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, sm.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
func RequireAdmin(sm *Manager) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := sm.ClaimsFromRequest(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
c.Abort()
return
}
c.Set(AdminClaimsKey, claims)
c.Next()
}
}