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
+89
View File
@@ -0,0 +1,89 @@
# ws_usernode Makefile —— build / test / dev 工作流
# 代理:容器构建无法继承 proxychains 注入,通过 --build-arg 显式传递
# 宿主机代理环境变量(HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY)。
# ---------- 代理参数(供 podman build 使用) ----------
HTTP_PROXY_ARG := $(if $(HTTP_PROXY),--build-arg HTTP_PROXY=$(HTTP_PROXY),)
HTTPS_PROXY_ARG := $(if $(HTTPS_PROXY),--build-arg HTTPS_PROXY=$(HTTPS_PROXY),)
ALL_PROXY_ARG := $(if $(ALL_PROXY),--build-arg ALL_PROXY=$(ALL_PROXY),)
NO_PROXY_ARG := $(if $(NO_PROXY),--build-arg NO_PROXY=$(NO_PROXY),)
PROXY_ARGS := $(HTTP_PROXY_ARG) $(HTTPS_PROXY_ARG) $(ALL_PROXY_ARG) $(NO_PROXY_ARG)
NET_HOST := --network=host
GO ?= go
PODMAN ?= podman
BIN := bin/usernode
VERSION ?= 0.1.0-m0
LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath
# ---------- 后端 ----------
.PHONY: build
build: ## 构建 Go 二进制(后端,不含前端产物)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/usernode
.PHONY: test
test: ## 运行全部 Go 测试
$(GO) test ./...
.PHONY: vet
vet: ## 静态检查
$(GO) vet ./...
.PHONY: run
run: build ## 本地运行(默认 config.toml,缺失则用默认配置)
./$(BIN) serve
.PHONY: migrate
migrate: build ## 数据库迁移
./$(BIN) migrate
# ---------- 前端 ----------
.PHONY: web-install
web-install: ## 容器内安装前端依赖(node:20-alpine),并初始化 node_modules volume
$(PODMAN) build $(NET_HOST) $(PROXY_ARGS) -t ws-usernode-web-dev -f web/Containerfile.dev web/
$(PODMAN) run --rm $(NET_HOST) \
-v ws_usernode_node_modules:/app/node_modules \
ws-usernode-web-dev sh -c "ls node_modules >/dev/null && echo node_modules ready"
.PHONY: web-dev
web-dev: ## 前端 dev 容器(热开发,端口 5173;API 代理到 Go 后端)
$(PODMAN) run --rm -p 5173:5173 \
-v $(CURDIR)/web:/app \
-v ws_usernode_node_modules:/app/node_modules \
-e VITE_API_PROXY=http://host.containers.internal:8080 \
ws-usernode-web-dev
.PHONY: web-build
web-build: ## 容器内构建前端产物到 web/dist,并复制到 internal/webui/dist(供 go:embed
$(PODMAN) build $(NET_HOST) $(PROXY_ARGS) -t ws-usernode-web-dev -f web/Containerfile.dev web/
$(PODMAN) run --rm $(NET_HOST) \
-v $(CURDIR)/web:/app \
-v ws_usernode_node_modules:/app/node_modules \
-v $(CURDIR)/internal/webui/dist:/embed/dist \
ws-usernode-web-dev sh -c "npm run build && cp -r dist/. /embed/dist/ && echo web-build ok"
# ---------- 镜像 / 分发 ----------
.PHONY: image
image: ## 构建单二进制容器镜像(deploy/Containerfile 多阶段)
$(PODMAN) build $(NET_HOST) $(PROXY_ARGS) -t ws-usernode:$(VERSION) -t ws-usernode:latest -f deploy/Containerfile .
.PHONY: build-embed
build-embed: web-build ## 构建含前端产物的 Go 二进制(web-build 后重新编译)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/usernode
.PHONY: dev
dev: ## 开发提示
@echo "后端:make run 前端:make web-dev"
@echo "或:go run ./cmd/usernode serve --debug"
.PHONY: clean
clean: ## 清理构建产物
rm -rf bin web/dist
.PHONY: help
help: ## 显示帮助
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"strings"
"golang.org/x/term"
"ws_usernode/internal/service"
)
// cmdAdmin 处理 admin 子命令(create / reset-password)。
func cmdAdmin(args []string) error {
if len(args) == 0 {
return fmt.Errorf("用法: usernode admin <create|reset-password> [flags]")
}
switch args[0] {
case "create":
return adminCreate(args[1:])
case "reset-password":
return adminResetPassword(args[1:])
default:
return fmt.Errorf("未知 admin 子命令 %q", args[0])
}
}
func adminCreate(args []string) error {
fs := flag.NewFlagSet("admin create", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "管理员用户名")
password := fs.String("password", "", "管理员密码(留空则交互输入,不回显)")
email := fs.String("email", "", "管理员邮箱")
if err := fs.Parse(args); err != nil {
return err
}
_, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
if *password == "" {
p, err := promptPassword("请输入管理员密码(至少 8 位,含字母与数字): ")
if err != nil {
return err
}
password = &p
}
if *email == "" {
return fmt.Errorf("--email 必填(用于邮件重置密码与通知)")
}
adm, err := service.NewAdminService(db).Create(context.Background(), *username, *password, *email)
if err != nil {
return err
}
log.Info("admin created", "id", adm.ID, "username", adm.Username)
return nil
}
func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "管理员用户名")
password := fs.String("password", "", "新密码(留空则交互输入,不回显)")
if err := fs.Parse(args); err != nil {
return err
}
_, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
if *password == "" {
p, err := promptPassword("请输入新密码(至少 8 位,含字母与数字): ")
if err != nil {
return err
}
password = &p
}
if err := service.NewAdminService(db).ResetPassword(context.Background(), *username, *password); err != nil {
return err
}
log.Info("admin password reset", "username", *username)
return nil
}
// promptPassword 从终端读取密码(不回显);非终端环境退回普通行读取。
func promptPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(b), nil
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(line), nil
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
robfigcron "github.com/robfig/cron/v3"
"gorm.io/gorm"
"ws_usernode/internal/api"
"ws_usernode/internal/config"
"ws_usernode/internal/cron"
"ws_usernode/internal/model"
"ws_usernode/internal/router"
"ws_usernode/internal/server"
"ws_usernode/internal/service"
"ws_usernode/internal/system"
)
// bootstrap 加载配置、打开数据库、构建日志。
// cfgPath 为配置文件路径,debug 开启调试日志与 GORM SQL 日志。
func bootstrap(cfgPath string, debug bool) (*config.Config, *slog.Logger, *gorm.DB, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return nil, nil, nil, err
}
if debug {
cfg.Log.Level = "debug"
}
log := newLogger(cfg.Log.Level, cfg.Log.Format)
db, err := model.Open(cfg.Database.Driver, cfg.Database.DSN, debug)
if err != nil {
return nil, nil, nil, fmt.Errorf("打开数据库: %w", err)
}
return cfg, log, db, nil
}
// commonFlags 定义所有子命令共享的 --config / --debug。
func commonFlags(fs *flag.FlagSet) (cfgPath *string, debug *bool) {
cfgPath = fs.String("config", "config.toml", "配置文件路径(默认 config.toml")
debug = fs.Bool("debug", false, "调试模式(debug 日志 + GORM SQL 日志)")
return cfgPath, debug
}
// newLogger 按配置构建 slog 输出器。
func newLogger(level, format string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
if format == "json" {
h = slog.NewJSONHandler(os.Stderr, opts)
} else {
h = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(h)
}
// serve 子命令:启动 HTTP 服务(自动迁移 + 定时任务)。
func cmdServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
sys := system.New(cfg.System)
adminSvc := service.NewAdminService(db)
userSvc := service.NewUserService(db, sys)
auditSvc := service.NewAuditService(db)
// 启动前自动迁移(骨架阶段保证表结构就绪;M5 部署建议显式 migrate)
if err := model.Migrate(db); err != nil {
return fmt.Errorf("数据库迁移: %w", err)
}
log.Info("db: migrated", "driver", cfg.Database.Driver, "dsn", cfg.Database.DSN)
// 定时任务(骨架:M1/M4 填充过期扫描/回收/审计归档)
sched := robfigcron.New()
cron.New(db, log).Register(sched)
sched.Start()
defer sched.Stop()
h := api.New(adminSvc, userSvc, auditSvc)
r := router.New(cfg, h, log)
srv := server.New(cfg.Server.Listen, r, log)
if err := srv.Run(); err != nil {
return fmt.Errorf("服务异常退出: %w", err)
}
return nil
}
// migrate 子命令:执行数据库迁移。
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if err := model.Migrate(db); err != nil {
return fmt.Errorf("数据库迁移: %w", err)
}
log.Info("db: migration complete", "driver", cfg.Database.Driver)
return nil
}
+62
View File
@@ -0,0 +1,62 @@
// ws_usernode —— 服务器用户管理节点。
//
// 用法:
//
// usernode serve [--config config.toml] 启动 HTTP 服务
// usernode migrate [--config config.toml] 执行数据库迁移
// usernode admin create [--config ...] --username x --password y --email z
// usernode admin reset-password [--config ...] --username x --password y
// usernode user otp [--config ...] --username ext_xxx 获取外部用户 OTP(与邮件对齐)
package main
import (
"fmt"
"os"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "usernode:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
return usage()
}
switch args[0] {
case "serve":
return cmdServe(args[1:])
case "migrate":
return cmdMigrate(args[1:])
case "admin":
return cmdAdmin(args[1:])
case "user":
return cmdUser(args[1:])
case "help", "-h", "--help":
return usage()
default:
return fmt.Errorf("未知子命令 %q(可用:serve / migrate / admin / user / help", args[0])
}
}
func usage() error {
fmt.Fprint(os.Stdout, `ws_usernode —— 服务器用户管理节点
用法:
usernode serve [--config <path>] 启动 HTTP 服务
usernode migrate [--config <path>] 执行数据库迁移(建表)
usernode admin create --username <x> --password <y> --email <z> [--config <path>]
创建初始管理员
usernode admin reset-password --username <x> --password <y> [--config <path>]
重置管理员密码
usernode user otp --username <ext_xxx> [--config <path>]
获取外部用户 OTP 验证码(与邮件通道一致)
全局:
--config 配置文件路径(默认 config.toml,不存在则用默认值 + 环境变量)
--debug 调试模式(debug 日志 + GORM SQL 日志)
`)
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"flag"
"fmt"
"strings"
"time"
"ws_usernode/internal/auth"
"ws_usernode/internal/service"
"ws_usernode/internal/system"
)
// cmdUser 处理 user 子命令(当前仅 otp)。
func cmdUser(args []string) error {
if len(args) == 0 {
return fmt.Errorf("用法: usernode user <otp> [flags]")
}
switch args[0] {
case "otp":
return userOTP(args[1:])
default:
return fmt.Errorf("未知 user 子命令 %q", args[0])
}
}
// userOTP 获取外部用户 OTP 验证码,与邮件通道共用同一存储与限速
// (同一验证码、同一 10 分钟有效期、同一 60s 冷却与失败限速)。
func userOTP(args []string) error {
fs := flag.NewFlagSet("user otp", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "外部用户系统账号(如 ext_zhangsan")
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
// 规范化:补全 ext_ 前缀
name := strings.TrimSpace(*username)
if !strings.HasPrefix(name, cfg.System.UserPrefix) {
name = cfg.System.UserPrefix + name
}
// 校验用户存在(不存在时返回友好错误,避免暴露账号是否存在的枚举)
if _, err := service.NewUserService(db, system.New(cfg.System)).GetByUsername(context.Background(), name); err != nil {
return fmt.Errorf("用户不存在或不可用: %w", err)
}
// M0 骨架:CLI 独立生成(内存 store 与运行中服务不共享)。
// 生产对齐(同一验证码/冷却/限速跨通道生效)需 OTP 落 DB,M1 实现
// auth.OTPStore 的 DB 实现后,CLI 与邮件通道读写同一存储。
store := auth.NewMemoryOTPStore()
code, err := store.Send(name, cfg.Policy.OTPTTL, cfg.Policy.OTPCooldown)
if err != nil {
if err == auth.ErrCooldown {
return fmt.Errorf("发送冷却中,请稍后重试(冷却 %s)", cfg.Policy.OTPCooldown)
}
return err
}
log.Info("otp generated",
"username", name,
"valid_for", cfg.Policy.OTPTTL.String(),
"expires_at", time.Now().Add(cfg.Policy.OTPTTL).Format(time.RFC3339),
"hint", "与邮件通道为同一验证码,登录后立即失效",
)
fmt.Printf("OTP for %s: %s\n", name, code)
return nil
}
+46
View File
@@ -0,0 +1,46 @@
# ws_usernode 服务器用户管理节点 —— 配置示例
# 复制为 config.toml 后按需修改;敏感项(SMTP 密码)建议通过环境变量覆盖:
# USERNODE_SMTP_PASSWORD=xxx
# 环境变量覆盖规则:USERNODE_<SECTION>_<FIELD>(字段名 camelCase 转大写蛇形;
# 连续大写缩写按一个词处理),例如 USERNODE_DATABASE_DSN、
# USERNODE_POLICY_OTPTTL、USERNODE_POLICY_DEFAULT_TTL。
[app]
name = "ws_usernode"
env = "development" # development / production
[server]
listen = "127.0.0.1:8080" # 生产建议 0.0.0.0:8080 并置于反向代理后
session_ttl = "24h" # cookie 会话时长
trusted_proxies = ["127.0.0.1", "::1"]
[database]
driver = "sqlite" # sqlite(开发)/ mysql(生产)
dsn = "data/usernode.db" # sqlite 文件路径;mysql 形如
# usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC
[log]
level = "info" # debug / info / warn / error
format = "text" # text / json
[policy]
default_ttl = "2160h" # 新账号默认有效期 90 天(可被管理员创建/延期时覆盖)
recycle_period = "720h" # 到期后回收期 30 天,期内可延期恢复
audit_retention = "720h" # 审计保留 30 天,保留前先归档
otp_ttl = "10m" # OTP 验证码有效期
otp_cooldown = "60s" # OTP 发送冷却
[smtp]
host = "" # 留空则禁用邮件(OTP 仍可用 CLI 通道获取)
port = 587
username = ""
password = ""
from = "usernode@example.com"
[system]
sudo = false # 开发环境 false = dry-run(只打印不执行);生产 true 经 sudo -n 执行
user_prefix = "ext_" # 外部用户系统账号统一前缀
group = "external" # 外部用户统一组
shell = "/bin/sh" # 默认 shell
home_base = "/home" # 家目录基路径
authorized_keys_dir = ".ssh" # authorized_keys 所在目录名
+64
View File
@@ -0,0 +1,64 @@
# ws_usernode 单二进制镜像(podman 多阶段构建)
#
# 构建(走代理时):
# podman build -t ws-usernode:latest \
# --build-arg HTTP_PROXY=http://10.62.25.123:7897 \
# --build-arg HTTPS_PROXY=http://10.62.25.123:7897 \
# --build-arg ALL_PROXY=http://10.62.25.123:7897 \
# -f deploy/Containerfile .
# 无代理环境可省略 --build-argContainerfile 内缺省为空,不影响)。
# 建议加 --network=host,规避容器网络插件缺失导致的拉包失败。
# ---------- 阶段 1:前端构建(node:20-alpine ----------
FROM node:20-alpine AS web-build
# proxychains 无法注入容器,代理环境变量须显式传入
ARG HTTP_PROXY=
ARG HTTPS_PROXY=
ARG ALL_PROXY=
ARG NO_PROXY=
ENV HTTP_PROXY=$HTTP_PROXY \
HTTPS_PROXY=$HTTPS_PROXY \
ALL_PROXY=$ALL_PROXY \
NO_PROXY=$NO_PROXY
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm install
COPY web/ .
RUN npm run build
# 产物:/web/dist
# ---------- 阶段 2Go 构建(golang:1.22-alpine ----------
FROM golang:1.22-alpine AS go-build
ARG HTTP_PROXY=
ARG HTTPS_PROXY=
ARG ALL_PROXY=
ARG NO_PROXY=
ENV HTTP_PROXY=$HTTP_PROXY \
HTTPS_PROXY=$HTTPS_PROXY \
ALL_PROXY=$ALL_PROXY \
NO_PROXY=$NO_PROXY
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 用前端构建产物覆盖 internal/webui/distgo:embed 入口)
COPY --from=web-build /web/dist ./internal/webui/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/usernode ./cmd/usernode
# ---------- 阶段 3:运行镜像 ----------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S usernode && adduser -S -G usernode usernode
COPY --from=go-build /out/usernode /usr/local/bin/usernode
# 数据目录(SQLite 默认 data/usernode.db),运行时以专有用户运行
RUN mkdir -p /data && chown usernode:usernode /data
VOLUME ["/data"]
USER usernode
WORKDIR /data
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/usernode"]
CMD ["serve", "--config", "/etc/usernode/config.toml"]
+54
View File
@@ -0,0 +1,54 @@
module ws_usernode
go 1.22
require (
github.com/BurntSushi/toml v1.4.0
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.10.0
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.26.0
golang.org/x/term v0.23.0
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
golang.org/x/arch v0.10.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+120
View File
@@ -0,0 +1,120 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+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))
})
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
*.log
+39
View File
@@ -0,0 +1,39 @@
# 前端开发镜像(podman 热开发)
#
# 构建(走代理时):
# podman build -t ws-usernode-web-dev \
# --build-arg HTTP_PROXY=http://10.62.25.123:7897 \
# --build-arg HTTPS_PROXY=http://10.62.25.123:7897 \
# --build-arg ALL_PROXY=http://10.62.25.123:7897 \
# -f web/Containerfile.dev web/
#
# 运行:
# podman run --rm -p 5173:5173 \
# -v $PWD/web:/app -v web_node_modules:/app/node_modules \
# -e VITE_API_PROXY=http://host.containers.internal:8080 \
# ws-usernode-web-dev
#
# 说明:-v 挂载源码热更新;named volume 缓存 node_modules 避免每次重装。
# 容器内无 proxychains,代理须经 --build-arg 传入。
FROM node:20-alpine
ARG HTTP_PROXY=
ARG HTTPS_PROXY=
ARG ALL_PROXY=
ARG NO_PROXY=
ENV HTTP_PROXY=$HTTP_PROXY \
HTTPS_PROXY=$HTTPS_PROXY \
ALL_PROXY=$ALL_PROXY \
NO_PROXY=$NO_PROXY
WORKDIR /app
# 先复制依赖清单,利用层缓存
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
+12
View File
@@ -0,0 +1,12 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ws-usernode-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
// 骨架阶段根组件:展示项目名与健康检查状态。
// M1 起替换为登录页 + 主布局(侧边导航、用户管理、审批、审计等页面)。
</script>
<template>
<el-config-provider>
<el-container class="app-shell">
<el-header class="app-header">
<span class="app-title">ws_usernode 服务器用户管理节点</span>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-config-provider>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color);
background: var(--el-bg-color);
}
.app-title {
font-weight: 600;
font-size: 16px;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import axios from 'axios'
// Axios 实例:baseURL 为 /api/v1Vite dev proxy 转发到 Go 后端;
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码。
const http = axios.create({
baseURL: '/api/v1',
timeout: 15000,
withCredentials: true, // cookie 会话
})
export default http
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
// 骨架阶段:仅首页(占位)。M1 起增加 /login、/admin/*、/me 等路由并做鉴权守卫。
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
],
})
export default router
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
const backendOk = ref<boolean | null>(null)
onMounted(async () => {
try {
const resp = await fetch('/healthz')
backendOk.value = resp.ok
} catch {
backendOk.value = false
}
})
</script>
<template>
<el-card shadow="never">
<template #header>
<b>工程骨架已就绪</b>
</template>
<p>ws_usernode M0Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通API 前缀 <code>/api/v1</code></p>
<p>
后端健康检查
<el-tag :type="backendOk === null ? 'info' : backendOk ? 'success' : 'danger'">
{{ backendOk === null ? '检测中…' : backendOk ? '正常' : '不可达(请先启动 Go 后端)' }}
</el-tag>
</p>
<el-divider />
<el-text type="info">登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地</el-text>
</el-card>
</template>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 开发期:Vite dev server 监听 5173API 请求 /api 代理到 Go 后端
// (默认 127.0.0.1:8080,可用 VITE_API_PROXY 覆盖)。
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_PROXY || 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/healthz': {
target: process.env.VITE_API_PROXY || 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})