Files
usernode/internal/system/system.go
T
cao.wangrenbo a5f501dba4 feat(M2): SSH 密钥管理 — 公钥上传/重命名/吊销、authorized_keys 原子同步与吊销即时失效
- KeyService:crypto/ssh 解析校验(单行/类型/长度/去重指纹,拒 ssh-dss 与 RSA<2048),
  Create/Rename/Revoke/List,变更后以 DB 状态全量重写 authorized_keys(同步失败回滚)
- system 层:SyncAuthorizedKeys 完善 —— sudo 模式经白名单命令(mkdir/chown/chmod/install)
  落位并修正属主(sshd StrictModes),direct 模式 root 时同样修正属主;dry-run 计划日志
- API:GET/POST /me/keys、PATCH/DELETE /me/keys/:id(user 会话)、GET /users/:id/keys(admin),
  密钥操作带审计;deploy/sudoers.example 补充密钥同步白名单
- 版本 0.3.0-m2;测试:service 单元(校验/生命周期/回滚/权限)、system 直写落盘、
  API 全流程集成;容器 E2E 32 项 PASS(真实 useradd/authorized_keys/吊销即时失效/禁用清空/删除回收)
2026-08-29 23:55:39 +08:00

323 lines
11 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)。
//
// 执行模式(config system.*):
// - dry_run=true:只打印计划命令不执行(开发演练);
// - dry_run=false + sudo=false:直接执行(容器/测试用户验证真实建号);
// - dry_run=false + sudo=true:经 sudo -n 提权执行白名单命令(生产,
// 需配置 deploy/sudoers)。真实系统账号操作只在测试用户/容器中验证,
// 不在生产直接跑 useradd。
package system
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"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
// Exists 检查系统账号是否存在(读 /etc/passwd,无需提权)。
Exists(ctx context.Context, username string) (bool, error)
// SyncAuthorizedKeys 以 DB 状态全量重写 authorized_keys(原子写 + 并发锁),
// 吊销密钥即从文件移除、立即失效。
SyncAuthorizedKeys(ctx context.Context, username string, keys []Key) error
}
// 本地实现注入的命令白名单(与 deploy/sudoers 保持一致)。
// useradd/usermod/userdel/passwd 为账号生命周期;mkdir/chmod/chown/install
// 用于 authorized_keys 原子同步(M2):sudo 模式下经白名单命令落位并修正属主,
// 保证 sshd StrictModes 通过。
var allowedCommands = map[string]bool{
"useradd": true,
"usermod": true,
"userdel": true,
"passwd": true,
"chsh": true,
"mkdir": true,
"chmod": true,
"chown": true,
"install": true,
}
// localManager 为本地实现。
type localManager struct {
cfg config.SystemConfig
log *slog.Logger
}
// New 创建系统账号 Manager。log 用于 dry-run 模式打印计划命令(演练提示)。
func New(cfg config.SystemConfig, log *slog.Logger) Manager {
return &localManager{cfg: cfg, log: log}
}
// run 执行白名单命令。参数在调用处强校验。
// 模式:dry-run 只返回命令文本(并打印计划);direct(sudo=false)直接执行;
// sudo 经 sudo -n 提权执行(对应 deploy/sudoers 白名单)。
func (m *localManager) run(ctx context.Context, cmd string, args ...string) (string, error) {
if !allowedCommands[cmd] {
return "", errors.New("system: command not allowed: " + cmd)
}
cmdline := strings.Join(append([]string{cmd}, args...), " ")
if m.cfg.DryRun {
if m.log != nil {
m.log.Info("system: dry-run", "cmd", cmdline)
}
return cmdline, nil
}
var (
out []byte
err error
)
if m.cfg.Sudo {
cmdline = "sudo -n " + cmdline
out, err = exec.CommandContext(ctx, "sudo", append([]string{"-n", cmd}, args...)...).CombinedOutput()
} else {
out, err = exec.CommandContext(ctx, cmd, args...).CombinedOutput()
}
if err != nil {
if len(out) > 0 {
return cmdline, fmt.Errorf("%s: %s: %w", cmdline, strings.TrimSpace(string(out)), err)
}
return cmdline, fmt.Errorf("%s: %w", cmdline, 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 err
}
// 锁定口令,仅密钥登录
if _, err := m.run(ctx, "passwd", "-l", username); err != nil {
return err
}
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 err
}
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 err
}
return nil
}
// passwdPath 为系统账号数据库路径(测试可覆盖为临时文件)。
var passwdPath = "/etc/passwd"
// passwdEntry 解析 /etc/passwd 中指定用户名的一行,返回 uid/gid。
// 文件为世界可读,无需提权。
func passwdEntry(username string) (uid, gid int, ok bool, err error) {
f, err := os.Open(passwdPath)
if err != nil {
return 0, 0, false, err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if line == "" {
continue
}
fields := strings.Split(line, ":")
if len(fields) >= 4 && fields[0] == username {
uid, err1 := strconv.Atoi(fields[2])
gid, err2 := strconv.Atoi(fields[3])
if err1 != nil || err2 != nil {
return 0, 0, false, fmt.Errorf("system: parse passwd entry %q: %v/%v", username, err1, err2)
}
return uid, gid, true, nil
}
}
if err := sc.Err(); err != nil {
return 0, 0, false, err
}
return 0, 0, false, nil
}
// Exists 检查系统账号是否存在(读 /etc/passwd,无需提权)。
func (m *localManager) Exists(ctx context.Context, username string) (bool, error) {
username = m.sysName(username)
if err := pkg.ValidateSystemAccount(username); err != nil {
return false, err
}
_ = ctx
_, _, ok, err := passwdEntry(username)
return ok, err
}
// SyncAuthorizedKeys 全量重写 authorized_keysPLAN F3:原子写 + 并发锁,
// 全量重写基于 DB 状态):
//
// 1. 以 <tmp>/usernode-keys-<user>.lock 文件锁串行化并发写(flock);
// 2. 无有效密钥时写入空文件(SSH 行为一致,避免文件缺失导致的歧义);
// 3. sudo 模式经白名单命令 mkdir/chown/chmod/install 落位并修正属主
// sshd StrictModes 要求 .ssh 属用户且 0700);direct 模式进程直写,
// root 时同样修正属主。
//
// dry-run 模式只打印计划;真实写路径(direct/sudo)见 deploy/sudoers.example。
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
}
sshDir := filepath.Join(m.cfg.HomeBase, username, m.cfg.AuthorizedKeysDir)
dest := filepath.Join(sshDir, "authorized_keys")
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.DryRun {
var plan strings.Builder
if m.cfg.Sudo {
plan.WriteString("sudo -n mkdir -p " + sshDir + " && sudo -n chown " + username + ":" + m.cfg.Group + " " + sshDir + " && sudo -n chmod 0700 " + sshDir + "\n")
plan.WriteString("write " + filepath.Join(os.TempDir(), "usernode-ak-"+username+".tmp") + " (0600)\n")
plan.WriteString("sudo -n install -o " + username + " -g " + m.cfg.Group + " -m 0600 <tmp> " + dest + "\n")
} else {
plan.WriteString("mkdir -p " + sshDir + " (0700)\n")
plan.WriteString("flock " + lockPath + "\n")
plan.WriteString("write " + dest + " (0600, tmp+rename)\n")
}
plan.WriteString(content.String())
// 演练模式:仅日志输出计划,不落盘
if m.log != nil {
m.log.Info("system: dry-run plan", "account", username, "plan", "\n"+plan.String())
}
return nil
}
// 并发锁串行化(进程内;跨节点由单实例部署保证,PLAN §11)
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)
if m.cfg.Sudo {
// .ssh 目录与属主:sshd StrictModes 要求属用户且 0700
if _, err := m.run(ctx, "mkdir", "-p", sshDir); err != nil {
return err
}
if _, err := m.run(ctx, "chown", username+":"+m.cfg.Group, sshDir); err != nil {
return err
}
if _, err := m.run(ctx, "chmod", "0700", sshDir); err != nil {
return err
}
// 临时文件写入进程可写目录,经 install 原子落位(dest 目录内临时文件 + rename
tmp := filepath.Join(os.TempDir(), "usernode-ak-"+username+"-"+strconv.Itoa(os.Getpid()))
if err := os.WriteFile(tmp, []byte(content.String()), 0o600); err != nil {
return err
}
defer os.Remove(tmp)
if _, err := m.run(ctx, "install", "-o", username, "-g", m.cfg.Group, "-m", "0600", tmp, dest); err != nil {
return err
}
return nil
}
// direct 模式:进程直写(容器/测试用户验证);root 时修正属主
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return err
}
if os.Geteuid() == 0 {
if uid, gid, ok, err := passwdEntry(username); err != nil {
return err
} else if ok {
if err := os.Chown(sshDir, uid, gid); err != nil {
return err
}
}
}
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, dest); err != nil {
os.Remove(tmp)
return err
}
return nil
}