feat(M0): 工程骨架 — Go 后端 + Vue3 前端脚手架
- Go 工程:cmd/usernode CLI(serve/migrate/admin create/reset-password/user otp)
+ internal/{config,model,service,system,auth,cron,api,router,server,pkg,webui}
- 配置:TOML + 环境变量覆盖(USERNODE_<SEC>_<FIELD>),config.example.toml
- 数据:GORM 双驱动(SQLite/MySQL)8 表模型,migrate 子命令可跑通
- 系统层:system.Manager 接口(useradd/userdel/passwd 白名单,dev dry-run)
- HTTP:Gin 路由骨架(/api/v1 + 501 占位),healthz,slog 结构化日志,优雅启停
- 前端:Vite + Vue3 + TS + Element Plus 最小可运行(web/),go:embed 打通
- 构建:deploy/Containerfile 多阶段单二进制镜像,web/Containerfile.dev 前端 dev
镜像,Makefile(build/test/dev/web-build/image);go.mod 锁定 go 1.22
- 验证:go build/vet/test 通过;podman 镜像构建运行 healthz+embed 通过
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// AdminHandler 管理员账号相关接口(M1 完成登录;create/reset 走 CLI)。
|
||||
type AdminHandler struct {
|
||||
svc *service.AdminService
|
||||
}
|
||||
|
||||
// AdminCreateRequest 管理员创建请求。
|
||||
type AdminCreateRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
// Create 创建管理员(仅初始引导用,M1 前可经此接口快速建号)。
|
||||
func (h *AdminHandler) Create(c *gin.Context) {
|
||||
if h.svc == nil {
|
||||
fail(c, http.StatusNotImplemented, "管理员服务未初始化")
|
||||
return
|
||||
}
|
||||
var req AdminCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
adm, err := h.svc.Create(c.Request.Context(), req.Username, req.Password, req.Email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "已存在"):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
case strings.Contains(err.Error(), "过弱"):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": adm.ID, "username": adm.Username})
|
||||
}
|
||||
|
||||
// Me 返回当前管理员(M1 接入会话后启用)。
|
||||
func (h *AdminHandler) Me(c *gin.Context) {
|
||||
fail(c, http.StatusNotImplemented, "会话尚未接入(M1)")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package api 为 HTTP handler 层(RESTful v1)。
|
||||
// M0 提供健康检查与模块路由骨架;各模块 handler 在对应里程碑填充。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// Handler 聚合各模块 handler,作为路由注册的挂载点。
|
||||
type Handler struct {
|
||||
Health *HealthHandler
|
||||
Admin *AdminHandler
|
||||
User *UserHandler
|
||||
// Auth / Keys / Approval / Audit / Settings 等模块在 M1~M4 填充
|
||||
}
|
||||
|
||||
// New 创建 handler 集合。M0 阶段部分服务可为 nil,路由只挂已实现模块。
|
||||
func New(adminSvc *service.AdminService, userSvc *service.UserService, auditSvc *service.AuditService) *Handler {
|
||||
h := &Handler{
|
||||
Health: &HealthHandler{startedAt: time.Now()},
|
||||
Admin: &AdminHandler{svc: adminSvc},
|
||||
User: &UserHandler{svc: userSvc},
|
||||
}
|
||||
_ = auditSvc
|
||||
return h
|
||||
}
|
||||
|
||||
// HealthHandler 健康检查。
|
||||
type HealthHandler struct {
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"version": "0.1.0-m0",
|
||||
"uptime": time.Since(h.startedAt).String(),
|
||||
"go": runtime.Version(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// ok 统一成功响应。
|
||||
func ok(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// fail 统一错误响应。
|
||||
func fail(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// UserHandler 外部用户接口(列表/详情/创建等,M1 填充 CRUD 与系统操作)。
|
||||
type UserHandler struct {
|
||||
svc *service.UserService
|
||||
}
|
||||
|
||||
// UserCreateRequest 管理员创建外部用户请求。
|
||||
type UserCreateRequest struct {
|
||||
Username string `json:"username" binding:"required"` // 不含 ext_ 前缀
|
||||
Email string `json:"email" binding:"required"`
|
||||
Supervisor string `json:"supervisor"`
|
||||
Purpose string `json:"purpose"`
|
||||
TTLDays int64 `json:"ttl_days"` // 0 表示用配置默认
|
||||
}
|
||||
|
||||
// Create 管理员创建外部用户(自动建系统账号)。
|
||||
func (h *UserHandler) Create(c *gin.Context) {
|
||||
if h.svc == nil {
|
||||
fail(c, http.StatusNotImplemented, "用户服务未初始化")
|
||||
return
|
||||
}
|
||||
var req UserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Create(c.Request.Context(), req.Username, req.Email, req.Supervisor, req.Purpose, req.TTLDays*86400)
|
||||
if err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "用户名"):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
case strings.Contains(err.Error(), "已存在"):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": u.ID, "username": u.Username, "status": u.Status})
|
||||
}
|
||||
|
||||
// List 用户列表(M1 实现分页筛选)。
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
fail(c, http.StatusNotImplemented, "用户列表将在 M1 实现")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// ErrCaptchaInvalid 表示图形验证码校验失败。
|
||||
var ErrCaptchaInvalid = errors.New("auth: 图形验证码错误")
|
||||
|
||||
// Captcha 图形验证码(防机器人,登录前置)。
|
||||
type Captcha struct {
|
||||
ID string
|
||||
Text string // M1 生成图像渲染,此处仅存文本
|
||||
}
|
||||
|
||||
// CaptchaStore 为图形验证码存储(M1 实现图像渲染)。
|
||||
type CaptchaStore interface {
|
||||
// New 生成一个验证码并返回其 ID。
|
||||
New() (*Captcha, error)
|
||||
// Verify 校验并一次性消费。失败或过期返回 false。
|
||||
Verify(id, answer string) bool
|
||||
}
|
||||
|
||||
// MemoryCaptchaStore 单实例内存实现。
|
||||
type MemoryCaptchaStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*captchaEntry
|
||||
}
|
||||
|
||||
type captchaEntry struct {
|
||||
text string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryCaptchaStore 创建内存图形验证码存储。
|
||||
func NewMemoryCaptchaStore() *MemoryCaptchaStore {
|
||||
return &MemoryCaptchaStore{entries: make(map[string]*captchaEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryCaptchaStore) New() (*Captcha, error) {
|
||||
text, err := pkg.RandomDigits(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := pkg.RandomHex(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[id] = &captchaEntry{text: text, expiresAt: time.Now().Add(5 * time.Minute)}
|
||||
return &Captcha{ID: id, Text: text}, nil
|
||||
}
|
||||
|
||||
func (s *MemoryCaptchaStore) Verify(id, answer string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.entries[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, id) // 一次性
|
||||
return e.text == answer && time.Now().Before(e.expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package auth 提供认证相关能力:OTP(双通道)、会话、bcrypt、图形验证码。
|
||||
//
|
||||
// OTP 双通道对齐:邮件发送与 CLI 获取共用同一 OTPStore(同一验证码、同一
|
||||
// 10 分钟有效期、同一 60s 冷却与失败限速),邮件失败不阻断 CLI 通道。
|
||||
// 单实例用内存存储;多实例需改为 DB/Redis(PLAN §6 注明)。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// OTP 错误。
|
||||
var (
|
||||
ErrCooldown = errors.New("auth: otp 发送冷却中")
|
||||
ErrInvalidCode = errors.New("auth: 验证码错误")
|
||||
ErrTooManyFails = errors.New("auth: 失败次数过多,请稍后再试")
|
||||
)
|
||||
|
||||
const (
|
||||
otpCodeLen = 6
|
||||
maxFailures = 5 // 单账号连续失败限速阈值
|
||||
failureWin = 10 * time.Minute // 失败计数窗口
|
||||
)
|
||||
|
||||
// OTPStore 为 OTP 验证码存储。内存实现为单实例默认实现。
|
||||
type OTPStore interface {
|
||||
// Send 为 username 生成新验证码(覆盖旧码)。冷却期内调用返回 ErrCooldown。
|
||||
// 邮件与 CLI 双通道都走该方法,保证对齐。
|
||||
Send(username string, ttl, cooldown time.Duration) (string, error)
|
||||
// Verify 校验验证码并一次性消费。失败累计计数(达到阈值返回 ErrTooManyFails)。
|
||||
Verify(username, code string) (bool, error)
|
||||
// Failures 返回 username 当前失败计数。
|
||||
Failures(username string) (int, error)
|
||||
}
|
||||
|
||||
type otpEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
cooldownAt time.Time
|
||||
failures int
|
||||
}
|
||||
|
||||
// MemoryOTPStore 为单实例内存实现。
|
||||
type MemoryOTPStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*otpEntry
|
||||
}
|
||||
|
||||
// NewMemoryOTPStore 创建内存 OTP 存储。
|
||||
func NewMemoryOTPStore() *MemoryOTPStore {
|
||||
return &MemoryOTPStore{entries: make(map[string]*otpEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Send(username string, ttl, cooldown time.Duration) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if e, ok := s.entries[username]; ok && now.Before(e.cooldownAt) {
|
||||
return "", ErrCooldown
|
||||
}
|
||||
code, err := pkg.RandomDigits(otpCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.entries[username] = &otpEntry{
|
||||
code: code,
|
||||
expiresAt: now.Add(ttl),
|
||||
cooldownAt: now.Add(cooldown),
|
||||
failures: 0,
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Verify(username, code string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
e, ok := s.entries[username]
|
||||
if !ok {
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(e.expiresAt) {
|
||||
delete(s.entries, username)
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
if e.failures >= maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
if e.code != code {
|
||||
e.failures++
|
||||
if e.failures >= maxFailures {
|
||||
return false, ErrTooManyFails
|
||||
}
|
||||
return false, ErrInvalidCode
|
||||
}
|
||||
delete(s.entries, username) // 一次性
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *MemoryOTPStore) Failures(username string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if e, ok := s.entries[username]; ok {
|
||||
return e.failures, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword 使用 bcrypt 对明文口令加盐哈希。管理员口令存储唯一用途。
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验明文口令与哈希是否匹配。
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// 会话用户类型。
|
||||
const (
|
||||
SessionUserAdmin = "admin"
|
||||
SessionUserUser = "user"
|
||||
)
|
||||
|
||||
// Session 为一次会话的元数据(对 model.Session 的轻量封装)。
|
||||
type Session struct {
|
||||
ID string
|
||||
UserType string
|
||||
RefID uint
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
// ErrSessionNotFound 表示会话不存在或已过期。
|
||||
var ErrSessionNotFound = errors.New("auth: session not found")
|
||||
|
||||
// SessionStore 为会话存储接口(DB 实现,兼容多实例)。
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, userType string, refID uint, ttl time.Duration, ip, userAgent string) (string, error)
|
||||
Get(ctx context.Context, sessionID string) (*Session, error)
|
||||
Delete(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
// DBSessionStore 基于 model.Session 的存储实现。
|
||||
type DBSessionStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDBSessionStore 创建会话存储。
|
||||
func NewDBSessionStore(db *gorm.DB) *DBSessionStore {
|
||||
return &DBSessionStore{db: db}
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Create(ctx context.Context, userType string, refID uint, ttl time.Duration, ip, userAgent string) (string, error) {
|
||||
id, err := pkg.RandomHex(24)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sess := model.Session{
|
||||
ID: id,
|
||||
UserType: userType,
|
||||
RefID: refID,
|
||||
ExpireAt: time.Now().Add(ttl),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&sess).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Get(ctx context.Context, sessionID string) (*Session, error) {
|
||||
var m model.Session
|
||||
err := s.db.WithContext(ctx).First(&m, "id = ?", sessionID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(m.ExpireAt) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return &Session{ID: m.ID, UserType: m.UserType, RefID: m.RefID, ExpireAt: m.ExpireAt}, nil
|
||||
}
|
||||
|
||||
func (s *DBSessionStore) Delete(ctx context.Context, sessionID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", sessionID).Error
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Package config 负责加载 TOML 配置文件并支持环境变量覆盖。
|
||||
//
|
||||
// 覆盖规则:环境变量名 = USERNODE_<SECTION>_<FIELD>(字段名 camelCase 转
|
||||
// SCREAMING_SNAKE),例如 server.listen 对应 USERNODE_SERVER_LISTEN,
|
||||
// database.driver 对应 USERNODE_DATABASE_DRIVER。仅当环境变量存在时覆盖,
|
||||
// 便于容器/CI 场景注入敏感配置(如 SMTP 密码)而不落地到文件。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
const envPrefix = "USERNODE_"
|
||||
|
||||
// Config 为全部配置的根。各子结构对应 config.example.toml 的一个 section。
|
||||
type Config struct {
|
||||
App AppConfig `toml:"app"`
|
||||
Server ServerConfig `toml:"server"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Log LogConfig `toml:"log"`
|
||||
Policy PolicyConfig `toml:"policy"`
|
||||
SMTP SMTPConfig `toml:"smtp"`
|
||||
System SystemConfig `toml:"system"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `toml:"name"`
|
||||
Env string `toml:"env"` // development / production
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Listen string `toml:"listen"` // 监听地址,如 0.0.0.0:8080
|
||||
SessionTTL time.Duration `toml:"session_ttl"`
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string `toml:"driver"` // sqlite / mysql
|
||||
DSN string `toml:"dsn"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `toml:"level"` // debug / info / warn / error
|
||||
Format string `toml:"format"` // text / json
|
||||
}
|
||||
|
||||
// PolicyConfig 为账号生命周期与 OTP 策略的全局默认值,可在 settings 表按需覆盖(M4)。
|
||||
type PolicyConfig struct {
|
||||
DefaultTTL time.Duration `toml:"default_ttl"` // 新账号默认有效期
|
||||
RecyclePeriod time.Duration `toml:"recycle_period"` // 到期后回收期,期内可延期恢复
|
||||
AuditRetention time.Duration `toml:"audit_retention"` // 审计保留时长
|
||||
OTPTTL time.Duration `toml:"otp_ttl"` // OTP 验证码有效期
|
||||
OTPCooldown time.Duration `toml:"otp_cooldown"` // OTP 发送冷却
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
From string `toml:"from"`
|
||||
}
|
||||
|
||||
// SystemConfig 为系统账号操作层的本地实现配置(sudoers 白名单模式)。
|
||||
type SystemConfig struct {
|
||||
Sudo bool `toml:"sudo"` // 是否通过 sudo -n 执行系统命令;开发环境 false 时 dry-run
|
||||
UserPrefix string `toml:"user_prefix"` // 外部用户系统账号前缀,默认 ext_
|
||||
Group string `toml:"group"` // 外部用户所属组,默认 external
|
||||
Shell string `toml:"shell"` // 默认 shell
|
||||
HomeBase string `toml:"home_base"` // 家目录基路径
|
||||
AuthorizedKeysDir string `toml:"authorized_keys_dir"` // authorized_keys 所在目录(测试可覆盖)
|
||||
}
|
||||
|
||||
// Default 返回带开发环境默认值的配置,作为 config.example.toml 与未配置项的兜底。
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
App: AppConfig{Name: "ws_usernode", Env: "development"},
|
||||
Server: ServerConfig{
|
||||
Listen: "127.0.0.1:8080",
|
||||
SessionTTL: 24 * time.Hour,
|
||||
TrustedProxies: []string{"127.0.0.1", "::1"},
|
||||
},
|
||||
Database: DatabaseConfig{Driver: "sqlite", DSN: "data/usernode.db"},
|
||||
Log: LogConfig{Level: "info", Format: "text"},
|
||||
Policy: PolicyConfig{
|
||||
DefaultTTL: 90 * 24 * time.Hour,
|
||||
RecyclePeriod: 30 * 24 * time.Hour,
|
||||
AuditRetention: 30 * 24 * time.Hour,
|
||||
OTPTTL: 10 * time.Minute,
|
||||
OTPCooldown: 60 * time.Second,
|
||||
},
|
||||
SMTP: SMTPConfig{Port: 587},
|
||||
System: SystemConfig{
|
||||
Sudo: false,
|
||||
UserPrefix: "ext_",
|
||||
Group: "external",
|
||||
Shell: "/bin/sh",
|
||||
HomeBase: "/home",
|
||||
AuthorizedKeysDir: ".ssh",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 加载配置文件并以环境变量覆盖。path 为空时仅使用默认值 + 环境变量。
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := Default()
|
||||
if path != "" {
|
||||
if _, err := toml.DecodeFile(path, cfg); err != nil {
|
||||
return nil, fmt.Errorf("load config %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if err := applyEnvOverrides(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefault 仅用于测试或未指定配置文件时的最小加载。
|
||||
func LoadDefault() (*Config, error) { return Load("") }
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.App.Env == "" {
|
||||
c.App.Env = "development"
|
||||
}
|
||||
switch c.Database.Driver {
|
||||
case "sqlite", "mysql":
|
||||
default:
|
||||
return fmt.Errorf("config: unsupported database driver %q (want sqlite or mysql)", c.Database.Driver)
|
||||
}
|
||||
if c.Database.DSN == "" {
|
||||
return errors.New("config: database.dsn is required")
|
||||
}
|
||||
if c.System.UserPrefix == "" {
|
||||
return errors.New("config: system.user_prefix must not be empty")
|
||||
}
|
||||
if c.System.Group == "" {
|
||||
return errors.New("config: system.group must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides 通过反射遍历各 section,按 USERNODE_<SECTION>_<FIELD> 覆盖。
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sec := t.Field(i)
|
||||
secVal := v.Field(i)
|
||||
if secVal.Kind() != reflect.Struct {
|
||||
continue
|
||||
}
|
||||
prefix := envPrefix + strings.ToUpper(sec.Name) + "_"
|
||||
for j := 0; j < secVal.Type().NumField(); j++ {
|
||||
f := secVal.Type().Field(j)
|
||||
envKey := prefix + strings.ToUpper(camelToSnake(f.Name))
|
||||
if val, ok := os.LookupEnv(envKey); ok {
|
||||
if err := setField(secVal.Field(j), val); err != nil {
|
||||
return fmt.Errorf("config: env %s: %w", envKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setField 按字段类型解析环境变量字符串。
|
||||
func setField(f reflect.Value, val string) error {
|
||||
switch f.Kind() {
|
||||
case reflect.String:
|
||||
f.SetString(val)
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.SetBool(b)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// time.Duration 的底层是 int64,需先尝试 ParseDuration
|
||||
if _, ok := f.Interface().(time.Duration); ok {
|
||||
d, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.SetInt(n)
|
||||
case reflect.Slice:
|
||||
// 逗号分隔,如 USERNODE_SERVER_TRUSTED_PROXIES=1.2.3.4,5.6.7.8
|
||||
if f.Type().Elem().Kind() != reflect.String {
|
||||
return fmt.Errorf("unsupported slice element type %v", f.Type().Elem())
|
||||
}
|
||||
parts := strings.Split(val, ",")
|
||||
out := reflect.MakeSlice(f.Type(), 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = reflect.Append(out, reflect.ValueOf(p))
|
||||
}
|
||||
}
|
||||
f.Set(out)
|
||||
default:
|
||||
return fmt.Errorf("unsupported field type %v", f.Type())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// camelToSnake 将 camelCase 转为 SCREAMING_SNAKE(SessionTTL → SESSION_TTL,
|
||||
// OTPTTL → OTP_TTL,TrustedProxies → TRUSTED_PROXIES)。
|
||||
func camelToSnake(s string) string {
|
||||
var b strings.Builder
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) {
|
||||
// 单词边界:前一个字符是小写/数字,或当前大写且其后是小写(连续大写结尾)
|
||||
if i > 0 {
|
||||
prev := runes[i-1]
|
||||
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
if unicode.IsLower(prev) || unicode.IsDigit(prev) || nextLower && unicode.IsUpper(prev) {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeTemp(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadDefault(t *testing.T) {
|
||||
cfg, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefault: %v", err)
|
||||
}
|
||||
if cfg.Database.Driver != "sqlite" {
|
||||
t.Errorf("default driver = %q, want sqlite", cfg.Database.Driver)
|
||||
}
|
||||
if cfg.Policy.DefaultTTL != 90*24*time.Hour {
|
||||
t.Errorf("default ttl = %v", cfg.Policy.DefaultTTL)
|
||||
}
|
||||
if cfg.System.UserPrefix != "ext_" {
|
||||
t.Errorf("default user_prefix = %q", cfg.System.UserPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileOverrides(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
[server]
|
||||
listen = "0.0.0.0:9999"
|
||||
session_ttl = "2h"
|
||||
|
||||
[policy]
|
||||
default_ttl = "720h"
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != "0.0.0.0:9999" {
|
||||
t.Errorf("listen = %q", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.SessionTTL != 2*time.Hour {
|
||||
t.Errorf("session_ttl = %v", cfg.Server.SessionTTL)
|
||||
}
|
||||
if cfg.Policy.DefaultTTL != 30*24*time.Hour {
|
||||
t.Errorf("default_ttl = %v", cfg.Policy.DefaultTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
t.Setenv("USERNODE_DATABASE_DRIVER", "mysql")
|
||||
t.Setenv("USERNODE_DATABASE_DSN", "u:p@tcp(h:3306)/db")
|
||||
t.Setenv("USERNODE_POLICY_OTPTTL", "5m")
|
||||
t.Setenv("USERNODE_SYSTEM_SUDO", "true")
|
||||
t.Setenv("USERNODE_SERVER_TRUSTED_PROXIES", "10.0.0.1, 10.0.0.2")
|
||||
|
||||
cfg, err := LoadDefault()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefault: %v", err)
|
||||
}
|
||||
if cfg.Database.Driver != "mysql" || cfg.Database.DSN != "u:p@tcp(h:3306)/db" {
|
||||
t.Errorf("database = %+v", cfg.Database)
|
||||
}
|
||||
if cfg.Policy.OTPTTL != 5*time.Minute {
|
||||
t.Errorf("otp_ttl = %v", cfg.Policy.OTPTTL)
|
||||
}
|
||||
if !cfg.System.Sudo {
|
||||
t.Error("system.sudo should be true")
|
||||
}
|
||||
if len(cfg.Server.TrustedProxies) != 2 || cfg.Server.TrustedProxies[0] != "10.0.0.1" {
|
||||
t.Errorf("trusted_proxies = %v", cfg.Server.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidDriver(t *testing.T) {
|
||||
path := writeTemp(t, "[database]\ndriver = \"oracle\"\ndsn = \"x\"\n")
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected error for unsupported driver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCamelToSnake(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"SessionTTL": "session_ttl",
|
||||
"Listen": "listen",
|
||||
"OTPTTL": "otpttl", // 全大写缩写按一个词处理(与 strcase 行为一致)
|
||||
"OTPCooldown": "otp_cooldown",
|
||||
"TrustedProxies": "trusted_proxies",
|
||||
"AuthorizedKeysDir": "authorized_keys_dir",
|
||||
"HomeBase": "home_base",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := camelToSnake(in); got != want {
|
||||
t.Errorf("camelToSnake(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package cron 提供定时任务:过期扫描、回收、审计归档。
|
||||
// M0 注册任务框架(任务函数在 M1/M4 接入),单实例部署说明见 PLAN §11。
|
||||
package cron
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Jobs 聚合定时任务依赖与注册。
|
||||
type Jobs struct {
|
||||
db *gorm.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 创建定时任务集合。
|
||||
func New(db *gorm.DB, log *slog.Logger) *Jobs {
|
||||
return &Jobs{db: db, log: log}
|
||||
}
|
||||
|
||||
// Register 将任务注册到 cron。骨架阶段注册空任务,M1/M4 填充实现。
|
||||
func (j *Jobs) Register(c *cron.Cron) {
|
||||
// 过期扫描(M4):每日扫描到期账号,锁系统账号 + 邮件通知
|
||||
// 回收任务(M4):超回收期账号自动回收(userdel + 保留审计)
|
||||
// 审计归档(M4):每日将过期审计记录导出归档后清理
|
||||
c.AddFunc("@daily", func() {
|
||||
j.log.Info("cron: daily maintenance tick (tasks to be implemented in M1/M4)")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package model 定义 GORM 模型与数据库接入。
|
||||
//
|
||||
// 兼容性约束(见 PLAN §6):
|
||||
// - 避免平台特有类型,时间统一 UTC,字段尽量用通用类型;
|
||||
// - detail 等 JSON 内容以 string 存储(入库前序列化),避免依赖
|
||||
// datatypes.JSON 的平台差异;
|
||||
// - 由本包 AutoMigrate 保证 SQLite / MySQL 均可平滑迁移。
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 外部用户账号状态。
|
||||
const (
|
||||
UserStatusActive = "active" // 正常可用
|
||||
UserStatusDisabled = "disabled" // 管理员禁用
|
||||
UserStatusExpired = "expired" // 已到期,等待回收或延期
|
||||
)
|
||||
|
||||
// 密钥 / 申请 / 审计结果等通用状态。
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusRevoked = "revoked"
|
||||
StatusPending = "pending"
|
||||
StatusApproved = "approved"
|
||||
StatusRejected = "rejected"
|
||||
ResultSuccess = "success"
|
||||
ResultFailed = "failed"
|
||||
)
|
||||
|
||||
// AdminUser 管理端账号。
|
||||
type AdminUser struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
Role string `gorm:"size:32;not null;default:admin" json:"role"`
|
||||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User 外部用户(1:1 对应系统账号 ext_<name>)。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` // 含 ext_ 前缀
|
||||
Email string `gorm:"size:255;not null" json:"email"`
|
||||
Supervisor string `gorm:"size:128" json:"supervisor"` // 挂靠老师
|
||||
Purpose string `gorm:"size:512" json:"purpose"` // 用途
|
||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
||||
ExpireAt *time.Time `gorm:"index" json:"expire_at"`
|
||||
Shell string `gorm:"size:64;not null" json:"shell"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
RecycledAt *time.Time `json:"recycled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SSHKey 用户上传的 SSH 公钥。仅用户自行上传(source=user_uploaded),管理员不代签。
|
||||
type SSHKey struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
KeyType string `gorm:"size:32;not null" json:"key_type"` // ssh-ed25519 / ssh-rsa ...
|
||||
PublicKey string `gorm:"type:text;not null" json:"public_key"`
|
||||
Fingerprint string `gorm:"size:64;index" json:"fingerprint"`
|
||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
||||
Source string `gorm:"size:32;not null;default:user_uploaded" json:"source"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Approval 新账号申请单(仅新账号申请走审批流,延期由管理员直接操作)。
|
||||
type Approval struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UsernameRequested string `gorm:"size:64;index;not null" json:"username_requested"` // 不含 ext_ 前缀
|
||||
Email string `gorm:"size:255;not null" json:"email"`
|
||||
Supervisor string `gorm:"size:128" json:"supervisor"`
|
||||
Purpose string `gorm:"size:512" json:"purpose"`
|
||||
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
|
||||
ReviewerID *uint `json:"reviewer_id"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
Reason string `gorm:"size:512" json:"reason"` // 拒绝理由
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuditLog 审计日志,append-only:业务代码只允许 Create,禁止 Update/Delete。
|
||||
type AuditLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ActorID uint `gorm:"index" json:"actor_id"`
|
||||
ActorName string `gorm:"size:64" json:"actor_name"`
|
||||
Action string `gorm:"size:64;index;not null" json:"action"`
|
||||
ResourceType string `gorm:"size:32;index" json:"resource_type"`
|
||||
ResourceID string `gorm:"size:64;index" json:"resource_id"`
|
||||
Detail string `gorm:"type:text" json:"detail"` // JSON 序列化后的详情
|
||||
IP string `gorm:"size:64" json:"ip"`
|
||||
Result string `gorm:"size:16;not null" json:"result"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
// Session 服务端会话(cookie 会话,DB 存储,兼容多实例)。
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"session_id"`
|
||||
UserType string `gorm:"size:16;not null" json:"user_type"` // admin / user
|
||||
RefID uint `gorm:"index;not null" json:"ref_id"`
|
||||
ExpireAt time.Time `gorm:"index;not null" json:"expire_at"`
|
||||
IP string `gorm:"size:64" json:"ip"`
|
||||
UserAgent string `gorm:"size:255" json:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Setting 系统设置(SMTP、默认有效期等,config 提供默认值,settings 表可覆盖)。
|
||||
type Setting struct {
|
||||
Key string `gorm:"primaryKey;size:128" json:"key"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MailLog 邮件发送记录(队列 + 重试 + 失败记录)。
|
||||
type MailLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
To string `gorm:"size:255;index;not null" json:"to"`
|
||||
Subject string `gorm:"size:255" json:"subject"`
|
||||
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
RetryCount int `gorm:"not null;default:0" json:"retry_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AllModels 供 AutoMigrate 使用的全部模型。
|
||||
func AllModels() []any {
|
||||
return []any{
|
||||
&AdminUser{}, &User{}, &SSHKey{}, &Approval{},
|
||||
&AuditLog{}, &Session{}, &Setting{}, &MailLog{},
|
||||
}
|
||||
}
|
||||
|
||||
// Open 按 driver 打开 GORM 连接。
|
||||
//
|
||||
// driver: sqlite | mysql
|
||||
// dsn: sqlite 为文件路径(自动创建父目录);mysql 为 DSN 字符串
|
||||
func Open(driver, dsn string, debug bool) (*gorm.DB, error) {
|
||||
logLevel := gormlogger.Warn
|
||||
if debug {
|
||||
logLevel = gormlogger.Info
|
||||
}
|
||||
cfg := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(logLevel),
|
||||
// 表名默认复数(users / ssh_keys / approvals / ...),与 PLAN §6 一致。
|
||||
}
|
||||
|
||||
var dialector gorm.Dialector
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
if dsn != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("model: create sqlite dir: %w", err)
|
||||
}
|
||||
}
|
||||
dialector = sqlite.Open(dsn)
|
||||
case "mysql":
|
||||
dialector = mysql.Open(dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("model: unsupported driver %q", driver)
|
||||
}
|
||||
return gorm.Open(dialector, cfg)
|
||||
}
|
||||
|
||||
// Migrate 执行 AutoMigrate(CLI migrate 子命令入口)。
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(AllModels()...)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package pkg 提供通用工具:安全随机数、输入校验、时间处理。
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RandomBytes 返回 n 字节密码学安全随机数。
|
||||
func RandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("pkg: rand read: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// RandomDigits 返回 n 位十进制数字串(如 OTP 验证码),首位可能为 0。
|
||||
func RandomDigits(n int) (string, error) {
|
||||
b, err := RandomBytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, n)
|
||||
for i, v := range b {
|
||||
out[i] = '0' + v%10
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// RandomHex 返回 n 字节随机数的十六进制串(如会话 ID)。
|
||||
func RandomHex(n int) (string, error) {
|
||||
b, err := RandomBytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// 外部用户系统账号约束:ext_ 前缀(可配置)+ 小写字母开头的 2~31 位 [a-z0-9],
|
||||
// 总长不超过 32(Linux 系统账号名上限)。
|
||||
var (
|
||||
reUserName = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}$`)
|
||||
reEmail = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
)
|
||||
|
||||
// ValidateUserName 校验申请/创建时使用的用户名(不含 ext_ 前缀)。
|
||||
func ValidateUserName(name string) error {
|
||||
if !reUserName.MatchString(name) {
|
||||
return fmt.Errorf("用户名只能由小写字母与数字组成,以字母开头,长度 2~32(不含前缀)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSystemAccount 校验最终系统账号(含前缀)合法性。
|
||||
func ValidateSystemAccount(account string) error {
|
||||
if len(account) > 32 || len(account) == 0 {
|
||||
return fmt.Errorf("系统账号长度必须在 1~32 之间")
|
||||
}
|
||||
for _, r := range account {
|
||||
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
|
||||
return fmt.Errorf("系统账号只能包含小写字母、数字、下划线和连字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEmail 做基础格式校验。
|
||||
func ValidateEmail(email string) error {
|
||||
if len(email) > 255 || !reEmail.MatchString(email) {
|
||||
return fmt.Errorf("邮箱格式不合法")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package router 负责路由注册与中间件装配。
|
||||
package router
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/api"
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/webui"
|
||||
)
|
||||
|
||||
// New 构建根 router:API v1 + 前端静态资源(go:embed)。
|
||||
// production 模式启用 gin.ReleaseMode;否则启用调试模式与开发日志。
|
||||
func New(cfg *config.Config, h *api.Handler, log *slog.Logger) *gin.Engine {
|
||||
if cfg.App.Env == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), requestLogger(log))
|
||||
|
||||
// 健康检查(不进 /api/v1 前缀,便于负载均衡/探针)
|
||||
r.GET("/healthz", h.Health.Healthz)
|
||||
|
||||
// RESTful API v1
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
auth := v1.Group("/auth")
|
||||
{
|
||||
// M1:captcha / otp/send / otp/login / admin/login / logout / me
|
||||
auth.GET("/captcha", notImplemented("图形验证码(M1)"))
|
||||
auth.POST("/otp/send", notImplemented("OTP 发送(M1)"))
|
||||
auth.POST("/otp/login", notImplemented("OTP 登录(M1)"))
|
||||
auth.POST("/admin/login", notImplemented("管理员登录(M1)"))
|
||||
auth.POST("/logout", notImplemented("登出(M1)"))
|
||||
auth.GET("/me", notImplemented("当前会话(M1)"))
|
||||
}
|
||||
v1.GET("/users", h.User.List)
|
||||
v1.POST("/users", h.User.Create)
|
||||
v1.POST("/users/:id/disable", notImplemented("禁用用户(M1)"))
|
||||
v1.POST("/users/:id/enable", notImplemented("启用用户(M1)"))
|
||||
v1.POST("/users/:id/extend", notImplemented("延期(M1)"))
|
||||
v1.POST("/approvals", notImplemented("提交申请(M3)"))
|
||||
v1.GET("/approvals", notImplemented("申请列表(M3)"))
|
||||
v1.POST("/approvals/:id/review", notImplemented("审批(M3)"))
|
||||
v1.GET("/audit", notImplemented("审计查询(M4)"))
|
||||
v1.GET("/audit/export", notImplemented("审计导出(M4)"))
|
||||
v1.GET("/settings", notImplemented("设置(M4)"))
|
||||
v1.PUT("/settings", notImplemented("设置(M4)"))
|
||||
}
|
||||
|
||||
// 前端静态资源(go:embed;dev 阶段由 Vite dev server 代理,见 Makefile dev)
|
||||
r.NoRoute(gin.WrapH(webui.NewHandler()))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// notImplemented 返回 501 占位 handler,标注里程碑。
|
||||
func notImplemented(what string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "接口 " + what + " 尚未实现"})
|
||||
}
|
||||
}
|
||||
|
||||
// requestLogger 以 slog 输出结构化请求日志。
|
||||
func requestLogger(log *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
log.Info("http",
|
||||
"method", c.Request.Method,
|
||||
"path", c.Request.URL.Path,
|
||||
"status", c.Writer.Status(),
|
||||
"ip", c.ClientIP(),
|
||||
"latency_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package server 提供 HTTP 服务与优雅启停。
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server 封装 http.Server 与优雅关闭。
|
||||
type Server struct {
|
||||
http *http.Server
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 创建 Server。
|
||||
func New(addr string, handler http.Handler, log *slog.Logger) *Server {
|
||||
return &Server{
|
||||
http: &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动并阻塞,直到收到 SIGINT/SIGTERM 完成优雅关闭。
|
||||
func (s *Server) Run() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
s.log.Info("server: listening", "addr", s.http.Addr)
|
||||
errCh <- s.http.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
s.log.Info("server: shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return s.http.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package service 承载业务逻辑,隔离 handler 与数据层/系统层。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/auth"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// 管理员服务错误。
|
||||
var (
|
||||
ErrAdminExists = errors.New("service: 管理员已存在")
|
||||
ErrAdminNotFound = errors.New("service: 管理员不存在")
|
||||
ErrWeakPassword = errors.New("service: 密码过弱(至少 8 位,需含字母与数字)")
|
||||
)
|
||||
|
||||
// AdminService 管理端账号服务(CLI admin create / reset-password 与登录共用)。
|
||||
type AdminService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminService 创建管理员服务。
|
||||
func NewAdminService(db *gorm.DB) *AdminService {
|
||||
return &AdminService{db: db}
|
||||
}
|
||||
|
||||
// Create 创建初始管理员。password 为空时由调用方提示交互输入。
|
||||
func (s *AdminService) Create(ctx context.Context, username, password, email string) (*model.AdminUser, error) {
|
||||
if err := validatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pkg.ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.AdminUser{}).Where("username = ?", username).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, ErrAdminExists
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adm := &model.AdminUser{
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
Email: email,
|
||||
Role: "admin",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(adm).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adm, nil
|
||||
}
|
||||
|
||||
// ResetPassword 重置管理员密码(CLI 或邮件重置兜底)。
|
||||
func (s *AdminService) ResetPassword(ctx context.Context, username, newPassword string) error {
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
var adm model.AdminUser
|
||||
if err := s.db.WithContext(ctx).First(&adm, "username = ?", username).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&adm).Update("password_hash", hash).Error
|
||||
}
|
||||
|
||||
// GetByUsername 按用户名查询管理员。
|
||||
func (s *AdminService) GetByUsername(ctx context.Context, username string) (*model.AdminUser, error) {
|
||||
var adm model.AdminUser
|
||||
if err := s.db.WithContext(ctx).First(&adm, "username = ?", username).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrAdminNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &adm, nil
|
||||
}
|
||||
|
||||
// validatePassword 校验管理员密码强度(骨架阶段基础规则,M1 可加策略)。
|
||||
func validatePassword(p string) error {
|
||||
if len(p) < 8 {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
hasLetter, hasDigit := false, false
|
||||
for _, r := range p {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
|
||||
hasLetter = true
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasLetter || !hasDigit {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// AuditService 审计服务:append-only 记录(业务代码仅允许 INSERT),
|
||||
// 保留策略与 CSV 导出归档在 M4 完成,这里给出接口与最小实现。
|
||||
type AuditService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAuditService 创建审计服务。
|
||||
func NewAuditService(db *gorm.DB) *AuditService {
|
||||
return &AuditService{db: db}
|
||||
}
|
||||
|
||||
// Record 记录一条管理操作审计。detail 为任意结构体,入库前 JSON 序列化。
|
||||
func (s *AuditService) Record(ctx context.Context, actorID uint, actorName, action, resourceType, resourceID string, detail any, ip, result string) error {
|
||||
b, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := model.AuditLog{
|
||||
ActorID: actorID,
|
||||
ActorName: actorName,
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
Detail: string(b),
|
||||
IP: ip,
|
||||
Result: result,
|
||||
}
|
||||
return s.db.WithContext(ctx).Create(&entry).Error
|
||||
}
|
||||
|
||||
// ExportCSV 导出审计为 CSV。M0 骨架:返回占位错误,M4 实现手动导出 + 每日归档。
|
||||
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
|
||||
_ = w
|
||||
_ = since
|
||||
_ = until
|
||||
return errors.New("service: 审计 CSV 导出将在 M4 实现")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
"ws_usernode/internal/system"
|
||||
)
|
||||
|
||||
// 用户服务错误。
|
||||
var (
|
||||
ErrUserNotFound = errors.New("service: 用户不存在")
|
||||
ErrUserExists = errors.New("service: 用户名已存在")
|
||||
)
|
||||
|
||||
// UserService 外部用户生命周期服务。
|
||||
// M0 提供查询与创建骨架;建号(useradd)、禁用/延期等系统操作 M1 接入 system.Manager。
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
sys system.Manager
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务。
|
||||
func NewUserService(db *gorm.DB, sys system.Manager) *UserService {
|
||||
return &UserService{db: db, sys: sys}
|
||||
}
|
||||
|
||||
// GetByUsername 按用户名查询外部用户(含或不含 ext_ 前缀均可)。
|
||||
func (s *UserService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
|
||||
if !strings.HasPrefix(username, "ext_") {
|
||||
username = "ext_" + username
|
||||
}
|
||||
var u model.User
|
||||
if err := s.db.WithContext(ctx).First(&u, "username = ?", username).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Create 创建外部用户记录并调用系统层建号。M0 阶段系统层为 dry-run。
|
||||
// username 为不含前缀的申请名,内部加 ext_ 前缀。
|
||||
func (s *UserService) Create(ctx context.Context, username, email, supervisor, purpose string, ttlSeconds int64) (*model.User, error) {
|
||||
if err := pkg.ValidateUserName(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pkg.ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full := "ext_" + username
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", full).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
u := &model.User{
|
||||
Username: full,
|
||||
Email: email,
|
||||
Supervisor: supervisor,
|
||||
Purpose: purpose,
|
||||
Status: model.UserStatusActive,
|
||||
Shell: "/bin/sh",
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 系统账号创建(dry-run / 真实),失败时回滚 DB 记录
|
||||
if err := s.sys.CreateUser(ctx, system.Account{Username: full}); err != nil {
|
||||
_ = s.db.WithContext(ctx).Delete(u).Error
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// flock 对 fd 加排他锁,等待直至获得或 ctx 取消。
|
||||
func flock(ctx context.Context, f *os.File) error {
|
||||
for {
|
||||
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// funlock 释放文件锁。
|
||||
func funlock(f *os.File) {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package system 提供系统账号操作的抽象层。
|
||||
//
|
||||
// Manager 接口面向 service 层;本地实现(localManager)通过 sudoers 白名单
|
||||
// 执行 useradd/usermod/userdel/passwd 等固定命令并做参数强校验。未来多节点
|
||||
// agent 模式只需新增远程实现替换本地实现(PLAN §5.1)。
|
||||
//
|
||||
// 权限模型:节点以专有用户(如 usernode)运行,经 sudo -n 提权执行白名单
|
||||
// 命令;开发环境 config system.sudo=false 进入 dry-run(只打印计划不执行),
|
||||
// 避免在开发机上直接操作系统账号。系统命令的真实系统效果在 M1 用测试用户/
|
||||
// 容器验证,不在生产直接跑 useradd。
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// Account 描述一个外部用户的系统账号。
|
||||
type Account struct {
|
||||
Username string // 用户名(可含或不含前缀,由 Manager 规范化)
|
||||
Shell string // 登录 shell,空则用配置默认
|
||||
HomeDir string // 家目录,空则用配置基路径 + 用户名
|
||||
}
|
||||
|
||||
// Key 表示一条待同步的 SSH 公钥。
|
||||
type Key struct {
|
||||
Type string // ssh-ed25519 / ssh-rsa ...
|
||||
PublicKey string // base64 主体
|
||||
}
|
||||
|
||||
// Manager 系统账号操作接口。所有方法都要在返回错误前尽量保证系统状态一致。
|
||||
type Manager interface {
|
||||
// CreateUser 创建系统账号(-m 建家目录、加入 external 组、指定 shell)。
|
||||
// 创建后立即锁定口令(passwd -l),外部用户仅密钥登录。
|
||||
CreateUser(ctx context.Context, acc Account) error
|
||||
// RemoveUser 删除系统账号及家目录(userdel -r)。
|
||||
RemoveUser(ctx context.Context, username string) error
|
||||
// SetLock 锁定/解锁系统账号口令(passwd -l / -u)。
|
||||
SetLock(ctx context.Context, username string, locked bool) error
|
||||
// SyncAuthorizedKeys 以 DB 状态全量重写 authorized_keys(原子写 + 并发锁),
|
||||
// 吊销密钥即从文件移除、立即失效。M0 提供 dry-run 实现,M2 完成生产路径。
|
||||
SyncAuthorizedKeys(ctx context.Context, username string, keys []Key) error
|
||||
}
|
||||
|
||||
// 本地实现注入的命令白名单(与 deploy/sudoers 保持一致)。
|
||||
var allowedCommands = map[string]bool{
|
||||
"useradd": true,
|
||||
"usermod": true,
|
||||
"userdel": true,
|
||||
"passwd": true,
|
||||
"chsh": true,
|
||||
}
|
||||
|
||||
// localManager 为本地实现。
|
||||
type localManager struct {
|
||||
cfg config.SystemConfig
|
||||
}
|
||||
|
||||
// New 创建系统账号 Manager。
|
||||
func New(cfg config.SystemConfig) Manager {
|
||||
return &localManager{cfg: cfg}
|
||||
}
|
||||
|
||||
// run 执行白名单命令:sudo -n <cmd> <args...>,参数在调用处强校验。
|
||||
// dry-run 模式下只返回将执行的命令文本,不真正执行。
|
||||
func (m *localManager) run(ctx context.Context, cmd string, args ...string) (string, error) {
|
||||
if !allowedCommands[cmd] {
|
||||
return "", errors.New("system: command not allowed: " + cmd)
|
||||
}
|
||||
argv := append([]string{"-n", cmd}, args...)
|
||||
cmdline := strings.Join(append([]string{"sudo", "-n", cmd}, args...), " ")
|
||||
if !m.cfg.Sudo {
|
||||
return cmdline, nil // dry-run
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "sudo", argv...).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), err
|
||||
}
|
||||
return cmdline, nil
|
||||
}
|
||||
|
||||
// sysName 返回带前缀的完整系统账号(调用前应已通过 pkg.ValidateSystemAccount)。
|
||||
func (m *localManager) sysName(username string) string {
|
||||
if !strings.HasPrefix(username, m.cfg.UserPrefix) {
|
||||
return m.cfg.UserPrefix + username
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func (m *localManager) CreateUser(ctx context.Context, acc Account) error {
|
||||
username := m.sysName(acc.Username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
return err
|
||||
}
|
||||
shell := acc.Shell
|
||||
if shell == "" {
|
||||
shell = m.cfg.Shell
|
||||
}
|
||||
home := acc.HomeDir
|
||||
if home == "" {
|
||||
home = filepath.Join(m.cfg.HomeBase, username)
|
||||
}
|
||||
if _, err := m.run(ctx, "useradd", "-m", "-d", home, "-s", shell, "-g", m.cfg.Group, username); err != nil {
|
||||
return errors.New("system: useradd: " + err.Error())
|
||||
}
|
||||
// 锁定口令,仅密钥登录
|
||||
if _, err := m.run(ctx, "passwd", "-l", username); err != nil {
|
||||
return errors.New("system: passwd -l: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *localManager) RemoveUser(ctx context.Context, username string) error {
|
||||
username = m.sysName(username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.run(ctx, "userdel", "-r", username); err != nil {
|
||||
return errors.New("system: userdel: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *localManager) SetLock(ctx context.Context, username string, locked bool) error {
|
||||
username = m.sysName(username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
return err
|
||||
}
|
||||
flag := "-u"
|
||||
if locked {
|
||||
flag = "-l"
|
||||
}
|
||||
if _, err := m.run(ctx, "passwd", flag, username); err != nil {
|
||||
return errors.New("system: passwd: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncAuthorizedKeys 全量重写 authorized_keys:
|
||||
//
|
||||
// 1. 以 <lockBase>/<username>.lock 文件锁串行化并发写(flock);
|
||||
// 2. 写临时文件(0600),再 rename 原子替换;
|
||||
// 3. 无有效密钥时写入空文件(SSH 行为一致,避免文件缺失导致的歧义)。
|
||||
//
|
||||
// 生产模式(sudo=true)下节点进程需具备对家目录 .ssh 的写权限——部署时经
|
||||
// sudoers 白名单授予固定命令/受控脚本实现(M2 细化);当前实现直接做文件
|
||||
// 操作并假设权限已配置,dry-run 模式打印计划命令。
|
||||
func (m *localManager) SyncAuthorizedKeys(ctx context.Context, username string, keys []Key) error {
|
||||
username = m.sysName(username)
|
||||
if err := pkg.ValidateSystemAccount(username); err != nil {
|
||||
return err
|
||||
}
|
||||
home := filepath.Join(m.cfg.HomeBase, username)
|
||||
sshDir := filepath.Join(home, m.cfg.AuthorizedKeysDir)
|
||||
lockPath := filepath.Join(os.TempDir(), "usernode-keys-"+username+".lock")
|
||||
|
||||
content := new(strings.Builder)
|
||||
for _, k := range keys {
|
||||
if k.Type != "" && k.PublicKey != "" {
|
||||
content.WriteString(k.Type + " " + k.PublicKey + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !m.cfg.Sudo {
|
||||
var plan strings.Builder
|
||||
plan.WriteString("mkdir -p " + sshDir + " (0700)\n")
|
||||
plan.WriteString("flock " + lockPath + "\n")
|
||||
plan.WriteString("write " + filepath.Join(sshDir, "authorized_keys") + " (0600)\n")
|
||||
plan.WriteString(content.String())
|
||||
// 骨架阶段:仅日志输出计划,不落盘
|
||||
return nil
|
||||
}
|
||||
|
||||
// 生产路径:并发锁 + 原子写
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Close()
|
||||
if err := flock(ctx, lock); err != nil {
|
||||
return err
|
||||
}
|
||||
defer funlock(lock)
|
||||
|
||||
tmp := filepath.Join(sshDir, "authorized_keys.tmp."+strconv.Itoa(os.Getpid()))
|
||||
if err := os.WriteFile(tmp, []byte(content.String()), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, filepath.Join(sshDir, "authorized_keys")); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as i,o as _,c as f,w as o,r as m,a,b as v,e as l,f as n,g as s,t as w}from"./index-DU8VsiSb.js";const g=i({__name:"HomeView",setup(x){const t=m(null);return _(async()=>{try{const r=await fetch("/healthz");t.value=r.ok}catch{t.value=!1}}),(r,e)=>{const u=a("el-tag"),d=a("el-divider"),p=a("el-text"),c=a("el-card");return v(),f(c,{shadow:"never"},{header:o(()=>[...e[0]||(e[0]=[l("b",null,"工程骨架已就绪",-1)])]),default:o(()=>[e[3]||(e[3]=l("p",null,[n("ws_usernode M0:Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通,API 前缀 "),l("code",null,"/api/v1"),n("。")],-1)),l("p",null,[e[1]||(e[1]=n(" 后端健康检查: ",-1)),s(u,{type:t.value===null?"info":t.value?"success":"danger"},{default:o(()=>[n(w(t.value===null?"检测中…":t.value?"正常":"不可达(请先启动 Go 后端)"),1)]),_:1},8,["type"])]),s(d),s(p,{type:"info"},{default:o(()=>[...e[2]||(e[2]=[n("登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地。",-1)])]),_:1})]),_:1})}}});export{g as default};
|
||||
File diff suppressed because one or more lines are too long
+92
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ws_usernode 用户管理</title>
|
||||
<script type="module" crossorigin src="/assets/index-DU8VsiSb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C0mNeYZw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package webui 嵌入前端构建产物(go:embed)。
|
||||
//
|
||||
// 构建方式:先构建 web/(Vite 产物输出到 web/dist),再 go build。
|
||||
// 未构建前端时(骨架阶段 / go test / 纯后端开发)由 NewHandler 提供
|
||||
// SPA fallback 占位,保证服务可启动、/healthz 可用。
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// NewHandler 返回静态资源 handler。前端产物存在时提供 SPA fallback
|
||||
// (非 API 路径回退 index.html,交给前端路由);否则返回占位页。
|
||||
func NewHandler() http.Handler {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
return placeholderHandler("前端产物未嵌入(先执行 make web-build)")
|
||||
}
|
||||
indexBytes, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
return placeholderHandler("前端产物未嵌入(先执行 make web-build)")
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
// 存在的静态资源直接返回(含版本化 assets/ 与 favicon)
|
||||
if f, err := sub.Open(p); err == nil {
|
||||
f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback:其余路径回退 index.html
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(indexBytes))
|
||||
})
|
||||
}
|
||||
|
||||
func placeholderHandler(msg string) http.Handler {
|
||||
body := "<!doctype html><html><head><meta charset=\"utf-8\"><title>ws_usernode</title></head>" +
|
||||
"<body><h1>ws_usernode</h1><p>" + msg + "</p>" +
|
||||
"<p>健康检查:<a href=\"/healthz\">/healthz</a></p></body></html>"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user