- 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 通过
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
// 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
|
||
}
|