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/吊销即时失效/禁用清空/删除回收)
This commit is contained in:
+101
-33
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -57,26 +58,34 @@ type Manager interface {
|
||||
}
|
||||
|
||||
// 本地实现注入的命令白名单(与 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。
|
||||
func New(cfg config.SystemConfig) Manager {
|
||||
return &localManager{cfg: cfg}
|
||||
// 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)直接执行;
|
||||
// 模式: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] {
|
||||
@@ -84,6 +93,9 @@ func (m *localManager) run(ctx context.Context, cmd string, args ...string) (str
|
||||
}
|
||||
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 (
|
||||
@@ -165,16 +177,12 @@ func (m *localManager) SetLock(ctx context.Context, username string, locked bool
|
||||
// passwdPath 为系统账号数据库路径(测试可覆盖为临时文件)。
|
||||
var passwdPath = "/etc/passwd"
|
||||
|
||||
// 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
|
||||
// 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 false, err
|
||||
return 0, 0, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
@@ -183,32 +191,50 @@ func (m *localManager) Exists(ctx context.Context, username string) (bool, error
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(line, ":", 2)
|
||||
if len(fields) > 0 && fields[0] == username {
|
||||
return true, nil
|
||||
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 false, err
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return false, nil
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
// SyncAuthorizedKeys 全量重写 authorized_keys:
|
||||
// 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. 以 <lockBase>/<username>.lock 文件锁串行化并发写(flock);
|
||||
// 2. 写临时文件(0600),再 rename 原子替换;
|
||||
// 3. 无有效密钥时写入空文件(SSH 行为一致,避免文件缺失导致的歧义)。
|
||||
// 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)需要进程具备对家目录
|
||||
// .ssh 的写权限——生产部署经 sudoers 白名单授予受控脚本实现(M2 细化)。
|
||||
// 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
|
||||
}
|
||||
home := filepath.Join(m.cfg.HomeBase, username)
|
||||
sshDir := filepath.Join(home, m.cfg.AuthorizedKeysDir)
|
||||
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)
|
||||
@@ -220,18 +246,24 @@ func (m *localManager) SyncAuthorizedKeys(ctx context.Context, username string,
|
||||
|
||||
if m.cfg.DryRun {
|
||||
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")
|
||||
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
|
||||
}
|
||||
|
||||
// 生产路径:并发锁 + 原子写
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
// 并发锁串行化(进程内;跨节点由单实例部署保证,PLAN §11)
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -242,11 +274,47 @@ func (m *localManager) SyncAuthorizedKeys(ctx context.Context, username string,
|
||||
}
|
||||
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, filepath.Join(sshDir, "authorized_keys")); err != nil {
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,6 +11,11 @@ import (
|
||||
"ws_usernode/internal/config"
|
||||
)
|
||||
|
||||
// discardLog 供测试注入的静默日志器。
|
||||
func discardLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func testCfg() config.SystemConfig {
|
||||
cfg := config.Default()
|
||||
return cfg.System
|
||||
@@ -17,7 +24,7 @@ func testCfg() config.SystemConfig {
|
||||
func TestDryRunDoesNotExecute(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.DryRun = true
|
||||
m := New(cfg)
|
||||
m := New(cfg, discardLog())
|
||||
ctx := context.Background()
|
||||
|
||||
// dry-run 下创建/删除不应报错(只打印计划)
|
||||
@@ -52,7 +59,7 @@ func TestExistsParsesPasswd(t *testing.T) {
|
||||
passwdPath = p
|
||||
t.Cleanup(func() { passwdPath = old })
|
||||
|
||||
m := New(testCfg())
|
||||
m := New(testCfg(), discardLog())
|
||||
ctx := context.Background()
|
||||
// 已带前缀与未带前缀都会命中同一账号
|
||||
for _, name := range []string{"ext_zhangsan", "zhangsan"} {
|
||||
@@ -85,3 +92,68 @@ func TestPrefixNormalization(t *testing.T) {
|
||||
t.Fatalf("sysName(x) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAuthorizedKeysDirect(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.DryRun = false
|
||||
cfg.Sudo = false
|
||||
cfg.HomeBase = t.TempDir() // 临时家目录基路径,进程用户直写
|
||||
m := New(cfg, discardLog())
|
||||
ctx := context.Background()
|
||||
|
||||
keys := []Key{
|
||||
{Type: "ssh-ed25519", PublicKey: "AAAAC3NzaC1lZDI1NTE5AAAAIB-test"},
|
||||
{Type: "ssh-rsa", PublicKey: "AAAAB3NzaC1yc2EAAAADAQAB-test"},
|
||||
}
|
||||
if err := m.SyncAuthorizedKeys(ctx, "ext_zhangsan", keys); err != nil {
|
||||
t.Fatalf("sync keys: %v", err)
|
||||
}
|
||||
sshDir := filepath.Join(cfg.HomeBase, "ext_zhangsan", ".ssh")
|
||||
dest := filepath.Join(sshDir, "authorized_keys")
|
||||
content, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("read authorized_keys: %v", err)
|
||||
}
|
||||
want := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB-test\nssh-rsa AAAAB3NzaC1yc2EAAAADAQAB-test\n"
|
||||
if string(content) != want {
|
||||
t.Fatalf("authorized_keys = %q, want %q", content, want)
|
||||
}
|
||||
// .ssh 目录 0700、文件 0600
|
||||
di, err := os.Stat(sshDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat .ssh: %v", err)
|
||||
}
|
||||
if di.Mode().Perm() != 0o700 {
|
||||
t.Fatalf(".ssh mode = %v, want 0700", di.Mode().Perm())
|
||||
}
|
||||
fi, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat authorized_keys: %v", err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("authorized_keys mode = %v, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
// 无有效密钥 → 写空文件(SSH 行为一致)
|
||||
if err := m.SyncAuthorizedKeys(ctx, "ext_zhangsan", nil); err != nil {
|
||||
t.Fatalf("sync empty: %v", err)
|
||||
}
|
||||
content, err = os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read authorized_keys: %v", err)
|
||||
}
|
||||
if len(content) != 0 {
|
||||
t.Fatalf("empty sync left content: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAuthorizedKeysDryRunSudoPlan(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.DryRun = true
|
||||
cfg.Sudo = true
|
||||
m := New(cfg, discardLog())
|
||||
// sudo 模式的 dry-run 只打印计划(install 落位路径),不执行任何命令
|
||||
if err := m.SyncAuthorizedKeys(context.Background(), "ext_zhangsan", []Key{{Type: "ssh-ed25519", PublicKey: "AAA"}}); err != nil {
|
||||
t.Fatalf("dry-run sudo plan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user