diff --git a/backend/cmd/smail-admin/main.go b/backend/cmd/smail-admin/main.go index 99d431a..fa6acda 100644 --- a/backend/cmd/smail-admin/main.go +++ b/backend/cmd/smail-admin/main.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "os/signal" "strings" + "syscall" "time" "git.pcux.cn/xuans/Smail/internal/buildinfo" @@ -31,26 +33,42 @@ func run(args []string) int { fmt.Fprintln(os.Stderr, "smail-admin 只允许由 systemctl start smail-admin 无参数启动") return 2 } - if err := adminreset.AuthorizeCurrentProcess(); err != nil { - fmt.Fprintln(os.Stderr, "超级管理员重置已拒绝:当前进程不是受信任的部署管理员") + signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + invocationContext, closeInvocation, err := adminreset.OpenInvocation(signalContext) + if err != nil { + fmt.Fprintln(os.Stderr, "超级管理员重置已拒绝:请在本机管理员终端使用 systemctl start smail-admin,停止请使用 systemctl stop smail-admin") return 1 } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer closeInvocation() + ctx, cancel := context.WithTimeout(invocationContext, 5*time.Minute) defer cancel() conn, err := adminreset.DialControl(ctx, adminreset.DefaultControlPath) if err != nil { - return finishReset("超级管理员重置通道不可用", 1) + return finishResetContext(ctx, "超级管理员重置通道不可用", 1) } defer conn.Close() - if err := adminreset.SendVerifiedResetWithInput(ctx, conn, adminreset.ReadVisibleInputContext, adminreset.ReadProtectedPassword); err != nil { - return finishReset("超级管理员重置失败:"+safeError(err), 1) + stopClosing := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopClosing() + if err := adminreset.SendVerifiedResetWithInput(ctx, conn, adminreset.ReadVisibleInputContext, func() (string, error) { + return adminreset.ReadProtectedPasswordContext(ctx) + }); err != nil { + if ctx.Err() != nil { + fmt.Fprintln(os.Stderr, "超级管理员重置已取消") + return 0 + } + return finishResetContext(ctx, "超级管理员重置失败:"+safeError(err), 1) } - return finishReset("超级管理员密码重置成功,请使用新密码登录", 0) + return finishResetContext(ctx, "超级管理员密码重置成功,请使用新密码登录", 0) } func finishReset(message string, code int) int { + return finishResetContext(context.Background(), message, code) +} + +func finishResetContext(ctx context.Context, message string, code int) int { fmt.Fprintln(os.Stderr, message) - adminreset.ShowResult(message) + adminreset.ShowResultContext(ctx, message) return code } diff --git a/backend/cmd/smail-admin/systemd_policy_test.go b/backend/cmd/smail-admin/systemd_policy_test.go index 06c4e74..723cb95 100644 --- a/backend/cmd/smail-admin/systemd_policy_test.go +++ b/backend/cmd/smail-admin/systemd_policy_test.go @@ -22,6 +22,7 @@ func TestSystemdUnitIsManualStartOnly(t *testing.T) { "Restart=always", "Restart=on-failure", "ExecReload=", + "ReadWritePaths=/run/systemd/ask-password", "PartOf=", "BindsTo=", "Wants=", @@ -41,6 +42,7 @@ func TestSystemdUnitIsManualStartOnly(t *testing.T) { "Restart=no", "PrivateNetwork=true", "RestrictAddressFamilies=AF_UNIX", + "KillMode=mixed", } { if !strings.Contains(content, required) { t.Fatalf("smail-admin unit is missing %q", required) diff --git a/backend/services/adminreset/doctor_linux.go b/backend/services/adminreset/doctor_linux.go index ca13dcb..1cbf740 100644 --- a/backend/services/adminreset/doctor_linux.go +++ b/backend/services/adminreset/doctor_linux.go @@ -14,6 +14,9 @@ func AuthorizeCurrentProcess() error { if !authorizeLinuxProcess(os.Geteuid(), os.Getpid(), os.Getppid(), os.Getenv("INVOCATION_ID"), os.Getenv("SYSTEMD_EXEC_PID")) || !authorizedSystemdStartJob() { return ErrUnauthorizedPeer } + if _, err := findStartCaller(); err != nil { + return err + } return nil } @@ -62,12 +65,13 @@ func validInvocationID(value string) bool { func CheckEnvironment() EnvironmentReport { _, err := exec.LookPath("systemd-ask-password") serviceManaged := os.Getppid() == 1 && validInvocationID(os.Getenv("INVOCATION_ID")) && authorizedSystemdStartJob() + _, callerErr := findStartCaller() return EnvironmentReport{ Platform: "linux", Supported: true, Privileged: os.Geteuid() == 0, - ServiceManaged: serviceManaged, - PasswordAgentAvailable: err == nil, + ServiceManaged: serviceManaged && callerErr == nil, + PasswordAgentAvailable: callerErr == nil || err == nil, ControlPath: DefaultControlPath, } } diff --git a/backend/services/adminreset/invocation_linux.go b/backend/services/adminreset/invocation_linux.go new file mode 100644 index 0000000..a0b38e1 --- /dev/null +++ b/backend/services/adminreset/invocation_linux.go @@ -0,0 +1,204 @@ +//go:build linux + +package adminreset + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +type callerIdentity struct { + pid int + started uint64 + group int + terminal int + foreground int +} + +type invocationTerminal struct { + file *os.File + cancel context.CancelFunc +} + +type invocationTerminalKey struct{} + +// OpenInvocation 绑定显式启动命令及其终端;systemd 的重启任务进入启动阶段后也会标为 start。 +// 这是部署命令约束,受保护通道仍独立校验本机身份和安全凭据。 +func OpenInvocation(parent context.Context) (context.Context, func(), error) { + if err := AuthorizeCurrentProcess(); err != nil { + return nil, nil, err + } + caller, err := findStartCaller() + if err != nil { + return nil, nil, err + } + fd, err := unix.Open(fmt.Sprintf("/proc/%d/fd/0", caller.pid), unix.O_RDWR|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, nil, ErrUnauthorizedPeer + } + file := os.NewFile(uintptr(fd), "smail-admin-terminal") + var info unix.Stat_t + err = unix.Fstat(fd, &info) + device := uint32(caller.terminal) + expectedDevice := unix.Mkdev((device>>8)&0xfff, (device&0xff)|((device>>12)&0xfff00)) + // 服务不属于调用终端的会话,不能使用 TIOCGPGRP;前台关系由 /proc 校验。 + if err != nil || info.Rdev != expectedDevice || !term.IsTerminal(fd) { + file.Close() + return nil, nil, ErrUnauthorizedPeer + } + ctx, cancel := context.WithCancel(parent) + terminal := &invocationTerminal{file: file, cancel: cancel} + ctx = context.WithValue(ctx, invocationTerminalKey{}, terminal) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + current, err := readCallerIdentity(caller.pid) + if err != nil || current != caller { + cancel() + return + } + } + } + }() + return ctx, func() { + cancel() + <-done + file.Close() + }, nil +} + +func findStartCaller() (callerIdentity, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return callerIdentity{}, ErrUnauthorizedPeer + } + self, err := readCallerIdentity(os.Getpid()) + if err != nil { + return callerIdentity{}, ErrUnauthorizedPeer + } + var callers []callerIdentity + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + base := filepath.Join("/proc", entry.Name()) + executable, err := os.Readlink(filepath.Join(base, "exe")) + if err != nil || (executable != "/usr/bin/systemctl" && executable != "/bin/systemctl") { + continue + } + data, err := os.ReadFile(filepath.Join(base, "cmdline")) + if err != nil { + continue + } + targets, allowed := classifyAdminCommand(strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")) + if !targets { + continue + } + // 同时存在重启、停止或多个启动请求时拒绝,不能借用另一个 start 进程授权。 + if !allowed { + return callerIdentity{}, ErrUnauthorizedPeer + } + status, err := os.ReadFile(filepath.Join(base, "status")) + if err != nil || !rootProcessStatus(string(status)) { + return callerIdentity{}, ErrUnauthorizedPeer + } + caller, err := readCallerIdentity(pid) + if err != nil || caller.started > self.started || caller.terminal == 0 || caller.group != caller.foreground { + return callerIdentity{}, ErrUnauthorizedPeer + } + callers = append(callers, caller) + } + if len(callers) != 1 { + return callerIdentity{}, ErrUnauthorizedPeer + } + return callers[0], nil +} + +func classifyAdminCommand(args []string) (targets, allowed bool) { + if len(args) < 3 { + return false, false + } + mutating := false + for _, arg := range args[1:] { + switch arg { + case "start", "stop", "restart", "try-restart", "reload", "reload-or-restart", "try-reload-or-restart", "force-reload", "condrestart": + mutating = true + } + for _, unit := range []string{"smail-admin", "smail-admin.service"} { + if match, _ := filepath.Match(arg, unit); match { + targets = true + } + } + } + if !mutating || !targets { + return false, false + } + var positional []string + for _, arg := range args[1:] { + switch arg { + case "--system", "--no-pager", "--no-legend", "--plain", "--quiet", "-q", "--wait": + continue + default: + positional = append(positional, arg) + } + } + return true, len(positional) == 2 && positional[0] == "start" && (positional[1] == "smail-admin" || positional[1] == "smail-admin.service") +} + +func rootProcessStatus(status string) bool { + for _, line := range strings.Split(status, "\n") { + fields := strings.Fields(line) + if len(fields) == 5 && fields[0] == "Uid:" { + return fields[1] == "0" && fields[2] == "0" && fields[3] == "0" && fields[4] == "0" + } + } + return false +} + +func readCallerIdentity(pid int) (callerIdentity, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return callerIdentity{}, err + } + return parseCallerIdentity(pid, string(data)) +} + +func parseCallerIdentity(pid int, data string) (callerIdentity, error) { + end := strings.LastIndex(data, ") ") + if end < 0 { + return callerIdentity{}, ErrUnauthorizedPeer + } + fields := strings.Fields(data[end+2:]) + if len(fields) < 20 || fields[0] == "Z" || fields[0] == "X" { + return callerIdentity{}, ErrUnauthorizedPeer + } + values := make([]int, 3) + for i, index := range []int{2, 4, 5} { + value, err := strconv.Atoi(fields[index]) + if err != nil { + return callerIdentity{}, ErrUnauthorizedPeer + } + values[i] = value + } + started, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return callerIdentity{}, ErrUnauthorizedPeer + } + return callerIdentity{pid: pid, started: started, group: values[0], terminal: values[1], foreground: values[2]}, nil +} diff --git a/backend/services/adminreset/invocation_linux_test.go b/backend/services/adminreset/invocation_linux_test.go new file mode 100644 index 0000000..9e9d793 --- /dev/null +++ b/backend/services/adminreset/invocation_linux_test.go @@ -0,0 +1,83 @@ +//go:build linux + +package adminreset + +import ( + "os" + "strconv" + "strings" + "testing" +) + +func TestAdminCommandRequiresExplicitInteractiveStart(t *testing.T) { + tests := []struct { + command string + targets bool + allowed bool + }{ + {"systemctl start smail-admin", true, true}, + {"/usr/bin/systemctl --system start smail-admin.service --no-pager", true, true}, + {"systemctl --wait --quiet start smail-admin", true, true}, + {"systemctl restart smail-admin", true, false}, + {"systemctl reload smail-admin", true, false}, + {"systemctl stop smail-admin", true, false}, + {"systemctl try-restart smail-admin", true, false}, + {"systemctl reload-or-restart smail-admin", true, false}, + {"systemctl try-reload-or-restart smail-admin", true, false}, + {"systemctl force-reload smail-admin", true, false}, + {"systemctl --no-block start smail-admin", true, false}, + {"systemctl --no-ask-password start smail-admin", true, false}, + {"systemctl --user start smail-admin", true, false}, + {"systemctl --host=other start smail-admin", true, false}, + {"systemctl start smail-admin smail", true, false}, + {"systemctl start smail-*", true, false}, + {"systemctl restart smail-*", true, false}, + {"systemctl status smail-admin", false, false}, + {"systemctl show smail-admin", false, false}, + {"systemctl list-jobs", false, false}, + {"systemctl restart smail", false, false}, + } + for _, test := range tests { + t.Run(test.command, func(t *testing.T) { + targets, allowed := classifyAdminCommand(strings.Fields(test.command)) + if targets != test.targets || allowed != test.allowed { + t.Fatalf("target=%v allowed=%v", targets, allowed) + } + }) + } +} + +func TestCallerIdentityIncludesStartTimeAndTerminal(t *testing.T) { + fields := strings.Fields("S 1 123 123 34817 123 0 0 0 0 0 0 0 0 0 0 0 1 0 9000") + data := "123 (name with ) spaces) " + strings.Join(fields, " ") + caller, err := parseCallerIdentity(123, data) + if err != nil || caller.pid != 123 || caller.started != 9000 || caller.group != 123 || caller.terminal != 34817 || caller.foreground != 123 { + t.Fatalf("identity=%+v err=%v", caller, err) + } + fields[19] = "9001" + reused, err := parseCallerIdentity(123, "123 (systemctl) "+strings.Join(fields, " ")) + if err != nil || reused == caller { + t.Fatal("PID 重用未被识别") + } + fields[0] = "Z" + if _, err := parseCallerIdentity(123, "123 (systemctl) "+strings.Join(fields, " ")); err == nil { + t.Fatal("僵尸进程被接受") + } + self, err := readCallerIdentity(os.Getpid()) + if err != nil || self.pid != os.Getpid() || self.started == 0 { + t.Fatalf("真实进程标识读取失败:%+v %v", self, err) + } +} + +func TestCallerRequiresRootIdentity(t *testing.T) { + if !rootProcessStatus("Name:\tsystemctl\nUid:\t0\t0\t0\t0\n") { + t.Fatal("root 身份未通过") + } + for index := 0; index < 4; index++ { + ids := []string{"0", "0", "0", "0"} + ids[index] = strconv.Itoa(1000) + if rootProcessStatus("Uid:\t" + strings.Join(ids, "\t")) { + t.Fatal("非 root 身份被接受") + } + } +} diff --git a/backend/services/adminreset/invocation_other.go b/backend/services/adminreset/invocation_other.go new file mode 100644 index 0000000..bcb9959 --- /dev/null +++ b/backend/services/adminreset/invocation_other.go @@ -0,0 +1,27 @@ +//go:build !linux + +package adminreset + +import "context" + +// OpenInvocation 保留其他平台原有的本机管理员身份校验。 +func OpenInvocation(parent context.Context) (context.Context, func(), error) { + if err := AuthorizeCurrentProcess(); err != nil { + return nil, nil, err + } + ctx, cancel := context.WithCancel(parent) + return ctx, cancel, nil +} + +func ReadProtectedPasswordContext(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ReadProtectedPassword() +} + +func ShowResultContext(ctx context.Context, message string) { + if ctx.Err() == nil { + ShowResult(message) + } +} diff --git a/backend/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go index 93f791d..d87c088 100644 --- a/backend/services/adminreset/password_linux.go +++ b/backend/services/adminreset/password_linux.go @@ -15,11 +15,15 @@ import ( ) func ReadProtectedPassword() (string, error) { - first, err := askPassword("请输入新的超级管理员密码") + return ReadProtectedPasswordContext(context.Background()) +} + +func ReadProtectedPasswordContext(ctx context.Context) (string, error) { + first, err := askPasswordContext(ctx, "请输入新的超级管理员密码") if err != nil { return "", err } - second, err := askPassword("请再次输入新的超级管理员密码") + second, err := askPasswordContext(ctx, "请再次输入新的超级管理员密码") if err != nil { clearPassword(&first) return "", err @@ -34,7 +38,11 @@ func ReadProtectedPassword() (string, error) { } func askPassword(prompt string) (string, error) { - value, err := readSystemdInput(prompt, "no") + return askPasswordContext(context.Background(), prompt) +} + +func askPasswordContext(ctx context.Context, prompt string) (string, error) { + value, err := readSystemdInputContext(ctx, prompt, "no") if err != nil { return "", err } @@ -52,9 +60,15 @@ func ReadVisibleInputContext(ctx context.Context, prompt string) (string, error) return readSystemdInputContext(ctx, prompt, "yes") } -// ShowResult 使用当前 systemctl 的密码代理显示结果,避免提示只进入系统日志。 +// ShowResult 保留隔离密码代理的测试入口,正式流程使用绑定终端的上下文。 func ShowResult(message string) { - _, _ = readSystemdInputContext(context.Background(), message+"\n按回车结束", "yes", "--timeout=30") + ShowResultContext(context.Background(), message) +} + +func ShowResultContext(ctx context.Context, message string) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + _, _ = readSystemdInputContext(ctx, message+"\n按回车结束", "yes", "--timeout=30") } func readSystemdInput(prompt, echo string) (string, error) { @@ -62,6 +76,9 @@ func readSystemdInput(prompt, echo string) (string, error) { } func readSystemdInputContext(ctx context.Context, prompt, echo string, options ...string) (string, error) { + if terminal, ok := ctx.Value(invocationTerminalKey{}).(*invocationTerminal); ok { + return terminal.read(ctx, prompt, echo == "no") + } prompt = strings.TrimRight(prompt, ":: ") + ":" // systemd 代理把提示保存在单行配置中,用终端换行控制显示列表,避免配置被拆行。 prompt = strings.ReplaceAll(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n", "\x1bE") diff --git a/backend/services/adminreset/terminal_linux.go b/backend/services/adminreset/terminal_linux.go new file mode 100644 index 0000000..70ed82a --- /dev/null +++ b/backend/services/adminreset/terminal_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +package adminreset + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "golang.org/x/sys/unix" +) + +// 只在已授权 start 的终端输入,不向系统共享密码代理发布请求。 +func (t *invocationTerminal) read(ctx context.Context, prompt string, hidden bool) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + fd := int(t.file.Fd()) + state, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return "", err + } + // 保留终端的行编辑、信号及换行模式,避免 systemctl 提前退出时影响父 shell。 + // 只有密码输入临时关闭回显,其余输入由终端正常显示。 + if hidden { + protected := *state + protected.Lflag &^= unix.ECHO | unix.ECHONL + if err := unix.IoctlSetTermios(fd, unix.TCSETS, &protected); err != nil { + return "", err + } + defer unix.IoctlSetTermios(fd, unix.TCSETS, state) + defer fmt.Fprintln(t.file) + } + prompt = strings.TrimRight(prompt, ":: ") + ": " + prompt = strings.ReplaceAll(prompt, "\r\n", "\n") + if _, err := fmt.Fprint(t.file, prompt); err != nil { + return "", err + } + stream := &terminalStream{ctx: ctx, terminal: t} + buffer := make([]byte, 4096) + defer clearBytes(buffer) + for size := 0; size < len(buffer); { + n, err := stream.Read(buffer[size : size+1]) + if err != nil { + if errors.Is(err, io.EOF) { + t.cancel() + return "", context.Canceled + } + return "", err + } + size += n + if size > 0 && buffer[size-1] == '\n' { + return string(stripPasswordLineEnding(buffer[:size])), nil + } + } + return "", ErrInvalidRequest +} + +type terminalStream struct { + ctx context.Context + terminal *invocationTerminal +} + +func (s *terminalStream) Read(buffer []byte) (int, error) { + fd := int(s.terminal.file.Fd()) + for { + if err := s.ctx.Err(); err != nil { + return 0, err + } + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + _, err := unix.Poll(fds, 100) + if err == unix.EINTR { + continue + } + if err != nil { + return 0, err + } + if fds[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 { + return 0, io.EOF + } + if fds[0].Revents&unix.POLLIN == 0 { + continue + } + n, err := unix.Read(fd, buffer) + if err == unix.EAGAIN || err == unix.EINTR { + continue + } + if n == 0 && err == nil { + err = io.EOF + } + return n, err + } +} diff --git a/backend/services/adminreset/terminal_linux_test.go b/backend/services/adminreset/terminal_linux_test.go new file mode 100644 index 0000000..1ccd57b --- /dev/null +++ b/backend/services/adminreset/terminal_linux_test.go @@ -0,0 +1,127 @@ +//go:build linux + +package adminreset + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func testTerminal(t *testing.T) (*os.File, *invocationTerminal, context.Context) { + t.Helper() + fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + master := os.NewFile(uintptr(fd), "test-terminal") + t.Cleanup(func() { master.Close() }) + if err := unix.IoctlSetPointerInt(fd, unix.TIOCSPTLCK, 0); err != nil { + t.Fatal(err) + } + number, err := unix.IoctlGetInt(fd, unix.TIOCGPTN) + if err != nil { + t.Fatal(err) + } + slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + slave := os.NewFile(uintptr(slaveFD), "test-terminal-input") + t.Cleanup(func() { slave.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + terminal := &invocationTerminal{file: slave, cancel: cancel} + return master, terminal, context.WithValue(ctx, invocationTerminalKey{}, terminal) +} + +func readTerminalUntil(t *testing.T, master *os.File, suffix string) string { + t.Helper() + if err := master.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + var result strings.Builder + buffer := make([]byte, 1024) + for !strings.Contains(result.String(), suffix) { + n, err := master.Read(buffer) + if err != nil { + t.Fatalf("终端输出等待失败:%q %v", result.String(), err) + } + result.Write(buffer[:n]) + } + return result.String() +} + +func TestBoundTerminalInputAndPasswordEcho(t *testing.T) { + for _, hidden := range []bool{false, true} { + t.Run(fmt.Sprintf("hidden=%v", hidden), func(t *testing.T) { + master, terminal, ctx := testTerminal(t) + done := make(chan error, 1) + go func() { + value, err := terminal.read(ctx, "第一行\n选项", hidden) + if err == nil && value != "Test1234" { + err = fmt.Errorf("输入未完整传递") + } + done <- err + }() + readTerminalUntil(t, master, "选项: ") + if _, err := master.Write([]byte("Test1234\n")); err != nil { + t.Fatal(err) + } + output := readTerminalUntil(t, master, "\r\n") + if hidden && strings.Contains(output, "Test1234") { + t.Fatal("密码出现回显") + } + if !hidden && !strings.Contains(output, "Test1234") { + t.Fatal("可见输入未显示") + } + if err := <-done; err != nil { + t.Fatal(err) + } + }) + } +} + +func TestBoundTerminalCancellationRestoresTerminal(t *testing.T) { + for _, eof := range []bool{false, true} { + t.Run(fmt.Sprintf("eof=%v", eof), func(t *testing.T) { + master, terminal, parent := testTerminal(t) + before, err := unix.IoctlGetTermios(int(terminal.file.Fd()), unix.TCGETS) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(parent) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := terminal.read(ctx, "等待取消", true) + done <- err + }() + readTerminalUntil(t, master, "等待取消: ") + if eof { + // 无前台进程组的测试终端用 EOF 模拟用户中止,真实 Ctrl+C 由调用者退出触发取消。 + _, _ = master.Write([]byte{4}) + } else { + cancel() + } + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("取消结果:%v", err) + } + case <-time.After(time.Second): + t.Fatal("取消后仍然等待输入") + } + after, err := unix.IoctlGetTermios(int(terminal.file.Fd()), unix.TCGETS) + if err != nil || *before != *after { + t.Fatal("终端设置未恢复") + } + }) + } +} diff --git a/deploy/systemd/smail-admin.service b/deploy/systemd/smail-admin.service index 897838b..bd0dfa9 100644 --- a/deploy/systemd/smail-admin.service +++ b/deploy/systemd/smail-admin.service @@ -18,8 +18,7 @@ PrivateNetwork=true PrivateDevices=true ProtectHome=true ProtectSystem=strict -# 密码代理在本机临时目录收集交互输入;验证内容可见,新密码不回显,且均不写入业务配置或数据库。 -ReadWritePaths=/run/systemd/ask-password +# 交互绑定显式 start 的管理员终端,不发布系统共享密码请求;新密码不回显。 ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true @@ -32,3 +31,5 @@ LockPersonality=true MemoryDenyWriteExecute=true TimeoutStartSec=330s TimeoutStopSec=5s +# 先通知主进程取消输入并恢复终端,超时后再清理整个控制组。 +KillMode=mixed diff --git a/docs/管理员重置命令限制验收.md b/docs/管理员重置命令限制验收.md new file mode 100644 index 0000000..d2e8abf --- /dev/null +++ b/docs/管理员重置命令限制验收.md @@ -0,0 +1,50 @@ +# 管理员重置命令限制验收 + +日期:2026-09-19。 + +## 需求与任务状态 + +- [✓] 仅显式 `systemctl start smail-admin` 进入重置流程,`systemctl stop smail-admin` 终止流程。 +- [✓] `restart`、`reload` 及相关变体不能开启新的重置交互。 +- [✓] 交互绑定启动终端,取消后无系统共享密码请求残留。 +- [✓] 保留邮箱、动态码、安全码验证及受保护本机 IPC,不调整账户密码或生产数据库。 +- [✓] Windows 定向测试、Linux 测试、服务器实际命令验证和部署完成。 + +## 原因与修改 + +systemd 会将 `restart` 的启动阶段转换成 `start` Job,因此只检查 `list-jobs` 无法识别原始动作。`Restart=no` 只禁止自动重启,不限制管理员手工重启。`reload` 本身已被拒绝,但 systemctl 创建的密码代理会显示已有的系统共享输入请求。 + +管理程序现在要求唯一、本机 root、前台终端的显式 `systemctl start smail-admin` 进程,同时校验可执行文件、参数、进程启动时间及终端设备,继续保留 systemd 上下文及本机 IPC 校验。非交互启动、多服务、通配符、并发启动及重启命令均不授权。 + +输入直接绑定启动终端,验证内容可见,新密码不回显。保留终端行编辑、信号和换行设置,避免停止过程中与 shell 的终端恢复竞争。监测原启动进程退出或 PID 复用,取消输入并关闭连接。服务使用 `KillMode=mixed`,主进程先处理取消,再由 systemd 兜底清理。 + +## 实机验证 + +环境:部署服务器的 systemd 259,使用两个独立 SSH 终端;只进入菜单或输入无效选项,没有发送邮件或执行真实改密。 + +| 场景 | 实际结果 | +| --- | --- | +| `start` | 显示安全验证方式列表,仅启动终端可见 | +| `stop` | 流程终止,`inactive`、`Result=success`,终端模式正常 | +| Ctrl+C | systemctl 退出,管理程序随后结束,无遗留输入请求 | +| 未运行时 `restart` | 拒绝启动,退出码 1,无验证菜单 | +| 正在输入时 `restart` | systemd 先终止现有流程;新启动被拒绝,不重新显示菜单 | +| `reload` | systemd 报不支持;另一终端现有输入不被接管,原流程可继续 | +| `reload-or-restart` | 拒绝启动,退出码 1,无验证菜单 | +| 未运行时 `try-restart`、`try-reload-or-restart` | systemd 返回成功但不执行动作,管理进程 PID 为 0 | +| 无效选项及结果提示 | 显示失败原因,按回车结束 | +| 系统密码请求目录 | 验证期间及结束后均无本次重置产生的 `ask.*` | + +## 自动化验证 + +- 本机:`go test ./services/adminreset ./cmd/smail-admin` 通过。 +- Linux:交叉编译后的 `adminreset` 完整测试在服务器隔离目录运行通过。 +- 覆盖显式命令、危险参数变体、进程启动时间/PID 重用、root 身份、真实伪终端输入、密码无回显、输入取消和终端恢复。 +- `go vet ./services/adminreset ./cmd/smail-admin`、`git diff --check` 通过。 +- unit 校验通过;systemd 对现有其他服务的 `CPUAccounting` 弃用提示与本次修改无关。 + +## systemd 行为边界 + +systemd 没有独立禁止手工 `restart`、同时允许 `start`/`stop` 的 unit 指令。因此不能删除 systemctl 自身的命令,也不能阻止它先执行重启的停止阶段。本次保证这些命令无法启动或重新进入重置流程。只读的 `status`、`show` 和日志查询继续可用。 + +原始命令检查约束正常部署操作,不构成对主机 root 的隔离;安全凭据验证和受保护本机 IPC 必须独立保留。验证二进制保存在 `/run/smail-command-validation-20260919/`,部署前备份保存在 `/var/backups/smail-admin-command-20260919/`。 diff --git a/docs/配置系统重构技术设计.md b/docs/配置系统重构技术设计.md index d4474c5..8067168 100644 --- a/docs/配置系统重构技术设计.md +++ b/docs/配置系统重构技术设计.md @@ -1211,7 +1211,7 @@ type SuperAdminResetChannelService interface { Linux systemd 单元必须使用 `Type=oneshot`、`RemainAfterExit=no` 和 `Restart=no`,不得包含 `[Install]`、`WantedBy`、`RequiredBy`、`Alias`、`ExecReload`、启动依赖、重启传播或自动重启策略。安装和升级流程不得执行 `enable`、`preset`、`start` 或 `restart smail-admin`,并应清理旧版本遗留的启用关系。主服务未运行时使用 `Requisite=smail.service` 安全拒绝,不得为了重置自动拉起主服务。 -正式管理程序启动时必须同时验证有效 UID、systemd 进程上下文、`INVOCATION_ID`、可用时的 `SYSTEMD_EXEC_PID`,以及当前 `smail-admin.service` Job 类型。只有显式 `start` Job 可以进入重置流程;`restart`、`try-restart`、`reload`、`reload-or-restart`、`stop` 和其他 Job 类型必须拒绝。`stop` 由 systemd 负责终止正在运行的事务,不调用重置逻辑。`status`、`show`、`is-active` 和日志查询属于只读观察命令,不授予任何重置能力。 +正式管理程序启动时必须同时验证有效 UID、systemd 进程上下文、`INVOCATION_ID`、可用时的 `SYSTEMD_EXEC_PID`,以及当前 `smail-admin.service` Job 类型。systemd 会把 `restart` 的启动阶段标为 `start`,因此 Job 类型不能单独证明原始操作;还须检查唯一的、本机 root 前台 `systemctl start smail-admin` 进程,校验真实可执行文件、命令参数、进程启动时间及终端设备。并发启动、重启动作、通配符、多服务和非交互启动均拒绝。交互直接绑定此终端,密码保持无回显,不向系统共享密码代理发布请求。启动进程退出、终端断开、Ctrl+C、超时或 `stop` 均取消输入并关闭本机连接;`KillMode=mixed` 允许主进程先恢复终端。`status`、`show`、`is-active` 和日志查询仍可使用。该检查约束部署命令使用方式,不构成对主机 root 的安全隔离,受保护 IPC 身份校验和业务安全验证必须独立保留。 安全要求: