- 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/吊销即时失效/禁用清空/删除回收)
160 lines
4.5 KiB
Go
160 lines
4.5 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"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
|
|
}
|
|
|
|
func TestDryRunDoesNotExecute(t *testing.T) {
|
|
cfg := testCfg()
|
|
cfg.DryRun = true
|
|
m := New(cfg, discardLog())
|
|
ctx := context.Background()
|
|
|
|
// dry-run 下创建/删除不应报错(只打印计划)
|
|
if err := m.CreateUser(ctx, Account{Username: "ext_zhangsan"}); err != nil {
|
|
t.Fatalf("dry-run create: %v", err)
|
|
}
|
|
if err := m.RemoveUser(ctx, "ext_zhangsan"); err != nil {
|
|
t.Fatalf("dry-run remove: %v", err)
|
|
}
|
|
if err := m.SetLock(ctx, "ext_zhangsan", true); err != nil {
|
|
t.Fatalf("dry-run lock: %v", err)
|
|
}
|
|
if err := m.SyncAuthorizedKeys(ctx, "ext_zhangsan", nil); err != nil {
|
|
t.Fatalf("dry-run keys: %v", err)
|
|
}
|
|
// 参数校验在 dry-run 下仍然生效
|
|
if err := m.CreateUser(ctx, Account{Username: "ext_..bad"}); err == nil {
|
|
t.Fatal("expected validation error for illegal account name")
|
|
}
|
|
}
|
|
|
|
func TestExistsParsesPasswd(t *testing.T) {
|
|
// 用临时 passwd 文件验证解析逻辑
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "passwd")
|
|
content := "root:x:0:0:root:/root:/bin/sh\n" +
|
|
"ext_zhangsan:x:1001:1001::/home/ext_zhangsan:/bin/sh\n"
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := passwdPath
|
|
passwdPath = p
|
|
t.Cleanup(func() { passwdPath = old })
|
|
|
|
m := New(testCfg(), discardLog())
|
|
ctx := context.Background()
|
|
// 已带前缀与未带前缀都会命中同一账号
|
|
for _, name := range []string{"ext_zhangsan", "zhangsan"} {
|
|
ok, err := m.Exists(ctx, name)
|
|
if err != nil {
|
|
t.Fatalf("exists(%q): %v", name, err)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("user %q should exist", name)
|
|
}
|
|
}
|
|
// 不存在的账号返回 false
|
|
if ok, err := m.Exists(ctx, "ghost_xyz"); err != nil || ok {
|
|
t.Fatalf("exists(ghost) = %v/%v, want false/nil", ok, err)
|
|
}
|
|
// 非法账号名直接报错
|
|
if _, err := m.Exists(ctx, "bad..name"); err == nil {
|
|
t.Fatal("expected validation error")
|
|
}
|
|
}
|
|
|
|
func TestPrefixNormalization(t *testing.T) {
|
|
cfg := testCfg()
|
|
lm := &localManager{cfg: cfg}
|
|
// sysName 逻辑:已带前缀不重复加
|
|
if got := lm.sysName("ext_x"); got != "ext_x" {
|
|
t.Fatalf("sysName(ext_x) = %q", got)
|
|
}
|
|
if got := lm.sysName("x"); got != "ext_x" {
|
|
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)
|
|
}
|
|
}
|