限制管理员重置为显式启动并隔离终端输入
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-19 23:43:25 +08:00
parent 04f7deb20f
commit 97c815deb0
12 changed files with 646 additions and 18 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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 身份被接受")
}
}
}

View File

@ -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)
}
}

View File

@ -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")

View File

@ -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
}
}

View File

@ -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("终端设置未恢复")
}
})
}
}

View File

@ -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

View File

@ -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/`。

View File

@ -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 身份校验和业务安全验证必须独立保留。
安全要求: