// 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_keys(PLAN F3:原子写 + 并发锁, // 全量重写基于 DB 状态): // // 1. 以 /usernode-keys-.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 " + 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 }