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