Files
usernode/internal/system/system.go
T
cao.wangrenbo ae45aba607 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 通过
2026-08-29 22:43:27 +08:00

206 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}