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:
2026-08-29 22:43:27 +08:00
parent f70b344c85
commit ae45aba607
46 changed files with 2878 additions and 0 deletions
+53
View File
@@ -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")
}
+57
View File
@@ -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})
}
+55
View File
@@ -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 实现")
}
+68
View File
@@ -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)
}
+113
View File
@@ -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
}
+17
View File
@@ -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
}
+84
View File
@@ -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
}
+244
View File
@@ -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_SNAKESessionTTL → SESSION_TTL
// OTPTTL → OTP_TTLTrustedProxies → 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()
}
+107
View File
@@ -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)
}
}
}
+31
View File
@@ -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)")
})
}
+186
View File
@@ -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 执行 AutoMigrateCLI migrate 子命令入口)。
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(AllModels()...)
}
+39
View File
@@ -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
}
+42
View File
@@ -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
}
+81
View File
@@ -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 构建根 routerAPI 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")
{
// M1captcha / 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:embeddev 阶段由 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(),
)
}
}
+58
View File
@@ -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)
}
}
+115
View File
@@ -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
}
+51
View File
@@ -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 实现")
}
+82
View File
@@ -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
}
+32
View File
@@ -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)
}
+205
View File
@@ -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
}
+1
View File
@@ -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 M0Go (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
File diff suppressed because one or more lines are too long
+13
View File
@@ -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>
+58
View File
@@ -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))
})
}