From 325928ee1f7e0b6b916a9e352f07bde1790a9380 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 21 Sep 2026 01:12:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=9C=AC=E6=9C=BA=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=BD=E4=BB=A4=E4=B8=8E=E5=85=B3=E9=97=AD=E6=97=A7?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E8=B7=AF=E7=94=B1=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/scert-admin/entry.go | 20 ++ backend/cmd/scert-admin/main.go | 2 +- backend/internal/admin/server_test.go | 12 +- backend/internal/adminconsole/flow.go | 138 ++++++++++++ backend/internal/adminconsole/flow_test.go | 148 +++++++++++++ .../internal/adminconsole/invocation_linux.go | 203 ++++++++++++++++++ .../adminconsole/invocation_linux_test.go | 83 +++++++ .../internal/adminconsole/platform_linux.go | 100 +++++++++ .../adminconsole/platform_linux_test.go | 59 +++++ .../internal/adminconsole/platform_other.go | 14 ++ .../internal/adminconsole/platform_windows.go | 139 ++++++++++++ .../internal/adminconsole/terminal_linux.go | 95 ++++++++ .../adminconsole/terminal_linux_test.go | 127 +++++++++++ .../internal/httpapi/reset_boundary_test.go | 65 ++++++ backend/internal/httpapi/server.go | 5 +- backend/internal/httpapi/server_test.go | 26 +-- .../internal/localreset/transport_windows.go | 10 + deploy/systemd/Scert-admin.service | 28 ++- 18 files changed, 1242 insertions(+), 32 deletions(-) create mode 100644 backend/cmd/scert-admin/entry.go create mode 100644 backend/internal/adminconsole/flow.go create mode 100644 backend/internal/adminconsole/flow_test.go create mode 100644 backend/internal/adminconsole/invocation_linux.go create mode 100644 backend/internal/adminconsole/invocation_linux_test.go create mode 100644 backend/internal/adminconsole/platform_linux.go create mode 100644 backend/internal/adminconsole/platform_linux_test.go create mode 100644 backend/internal/adminconsole/platform_other.go create mode 100644 backend/internal/adminconsole/platform_windows.go create mode 100644 backend/internal/adminconsole/terminal_linux.go create mode 100644 backend/internal/adminconsole/terminal_linux_test.go create mode 100644 backend/internal/httpapi/reset_boundary_test.go diff --git a/backend/cmd/scert-admin/entry.go b/backend/cmd/scert-admin/entry.go new file mode 100644 index 0000000..5f25d82 --- /dev/null +++ b/backend/cmd/scert-admin/entry.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "git.pcux.cn/xuans/Scert/backend/internal/adminconsole" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := adminconsole.Run(ctx, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "Scert-admin:", err) + os.Exit(1) + } +} diff --git a/backend/cmd/scert-admin/main.go b/backend/cmd/scert-admin/main.go index a294110..c993c9d 100644 --- a/backend/cmd/scert-admin/main.go +++ b/backend/cmd/scert-admin/main.go @@ -22,7 +22,7 @@ func writeSessionInfo(writer io.Writer, session admin.Session) { fmt.Fprintln(writer, "请使用 root 或 Scert 用户读取该文件;密码重置完成后文件会自动销毁。") fmt.Fprintln(writer, "停止 Scert-admin 后入口将立即关闭;异常退出时入口会在租约过期后自动关闭。") } -func main() { +func legacyMain() { baseDir := flag.String("base-dir", "", "Windows 默认安装根目录") configPath := flag.String("config", "", "配置文件路径") codeOutput := flag.String("code-output", "", "一次性校验码文件路径") diff --git a/backend/internal/admin/server_test.go b/backend/internal/admin/server_test.go index 4a0062f..8480e10 100644 --- a/backend/internal/admin/server_test.go +++ b/backend/internal/admin/server_test.go @@ -21,13 +21,17 @@ func TestScertAdminSystemdUnitKeepsManualOnlyPolicy(t *testing.T) { for _, required := range []string{ "Documentation=file:///scert/docs/Scert-admin-service-management.md", - "User=Scert", - "Group=Scert", + "User=root", + "Group=root", "RuntimeDirectory=scert-admin", "RuntimeDirectoryMode=0700", - "ExecStart=/scert/scert-admin --config /scert/conf/scert.json --code-output /run/scert-admin/reset-code", + "ExecStart=/scert/scert-admin", "Restart=no", - "RuntimeMaxSec=30min", + "RuntimeMaxSec=130s", + "Type=oneshot", + "Requisite=Scert.service", + "KillMode=mixed", + "CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_OVERRIDE", "UMask=0077", "ProtectHome=yes", "NoNewPrivileges=yes", diff --git a/backend/internal/adminconsole/flow.go b/backend/internal/adminconsole/flow.go new file mode 100644 index 0000000..721a37f --- /dev/null +++ b/backend/internal/adminconsole/flow.go @@ -0,0 +1,138 @@ +package adminconsole + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy" +) + +var ( + ErrUnauthorizedPeer = localreset.ErrUnauthorized + ErrInvalidRequest = localreset.ErrProtocol +) + +type terminal interface { + Read(context.Context, string, bool) (string, error) + Show(string) error +} + +type resetClient interface { + Methods() []localreset.Method + Select(string) error + Verify(string) error + Reset(string) error + Close() error +} + +// Run 只接受无参数的本机交互,不从参数、环境变量、管道或文件读取密码。 +func Run(parent context.Context, args []string) error { + if len(args) != 0 { + return errors.New("Scert-admin 不接受参数;Linux 请在管理员终端执行 systemctl start Scert-admin") + } + ctx, cancel := context.WithTimeout(parent, localreset.SessionTimeout) + defer cancel() + ctx, screen, closeScreen, err := openTerminal(ctx) + if err != nil { + return errors.New("本机管理员终端校验失败;Linux 请执行 systemctl start Scert-admin,Windows 请使用提升权限的控制台") + } + defer closeScreen() + conn, err := dialControl(ctx) + if err != nil { + _ = screen.Show("本机重置通道不可用,请确认 Scert 主服务已完成初始化并正常运行。") + return localreset.ErrUnavailable + } + return runConnected(ctx, conn, screen) +} + +func runConnected(ctx context.Context, conn net.Conn, screen terminal) error { + client, err := localreset.Begin(ctx, conn) + if err != nil { + _ = screen.Show("主服务拒绝了本机重置会话,请检查初始化状态及绑定邮箱。") + return localreset.ErrUnavailable + } + defer client.Close() + if err := resetFlow(ctx, client, screen); err != nil { + _ = screen.Show("密码重置未完成:操作已取消、验证失败、会话过期或保存失败。请检查服务日志后重新启动。") + return localreset.ErrProtocol + } + if err := screen.Show("超级管理员密码重置成功,旧登录会话已失效,请使用新密码登录。"); err != nil { + return errors.New("密码已重置,但无法向调用终端显示结果,请使用新密码登录") + } + return nil +} + +func resetFlow(ctx context.Context, client resetClient, screen terminal) error { + methods := client.Methods() + if len(methods) != 1 || methods[0].ID != "email" { + return localreset.ErrUnavailable + } + choice, err := screen.Read(ctx, "验证方式:绑定邮箱验证码。输入 1 发送验证码,其他输入取消", false) + if err != nil || strings.TrimSpace(choice) != "1" { + return localreset.ErrProtocol + } + if err := ctx.Err(); err != nil { + return err + } + if err := client.Select("email"); err != nil { + return err + } + code, err := screen.Read(ctx, "请输入绑定邮箱收到的八位验证码(不回显)", true) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + err = client.Verify(code) + code = "" + if err != nil { + return err + } + first, err := screen.Read(ctx, "请输入新密码(8 到 12 个字符,包含字母和数字,不回显)", true) + if err != nil { + return err + } + if err := passwordpolicy.Validate(first, ""); err != nil { + return err + } + second, err := screen.Read(ctx, "请再次输入新密码(不回显)", true) + if err != nil { + first = "" + return err + } + if first != second { + first, second = "", "" + return localreset.ErrProtocol + } + second = "" + if err := ctx.Err(); err != nil { + first = "" + return err + } + err = client.Reset(first) + first = "" + return err +} + +func clearBytes(value []byte) { clear(value) } + +func stripPasswordLineEnding(value []byte) []byte { + if len(value) > 0 && value[len(value)-1] == '\n' { + value = value[:len(value)-1] + } + if len(value) > 0 && value[len(value)-1] == '\r' { + value = value[:len(value)-1] + } + return value +} + +func showLine(writer io.Writer, message string) error { + _, err := fmt.Fprintln(writer, message) + return err +} diff --git a/backend/internal/adminconsole/flow_test.go b/backend/internal/adminconsole/flow_test.go new file mode 100644 index 0000000..084367f --- /dev/null +++ b/backend/internal/adminconsole/flow_test.go @@ -0,0 +1,148 @@ +package adminconsole + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type testTerminalInput struct { + values []string + hidden []bool + prompts []string + failAt int + readHook func(int) +} + +func (s *testTerminalInput) Read(ctx context.Context, prompt string, hidden bool) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + s.hidden = append(s.hidden, hidden) + s.prompts = append(s.prompts, prompt) + index := len(s.hidden) + if s.readHook != nil { + s.readHook(index) + } + if s.failAt == index { + return "", errors.New("test-input-failure") + } + if index > len(s.values) { + return "", errors.New("test-input-exhausted") + } + return s.values[index-1], nil +} +func (*testTerminalInput) Show(string) error { return nil } + +type testResetClient struct { + methods []localreset.Method + calls []string + fail string + password string +} + +func (c *testResetClient) Methods() []localreset.Method { return c.methods } +func (c *testResetClient) invoke(action string) error { + c.calls = append(c.calls, action) + if c.fail == action { + return localreset.ErrProtocol + } + return nil +} +func (c *testResetClient) Select(value string) error { + if value != "email" { + return localreset.ErrProtocol + } + return c.invoke("select") +} +func (c *testResetClient) Verify(value string) error { + if value != "01234567" { + return localreset.ErrProtocol + } + return c.invoke("verify") +} +func (c *testResetClient) Reset(value string) error { + c.password = value + return c.invoke("reset") +} +func (*testResetClient) Close() error { return nil } + +func TestAdminConsoleFlowRequiresVerificationAndHiddenInput(t *testing.T) { + client := &testResetClient{methods: []localreset.Method{{ID: "email"}}} + screen := &testTerminalInput{values: []string{"1", "01234567", "NewPass123!", "NewPass123!"}} + if err := resetFlow(t.Context(), client, screen); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(client.calls, []string{"select", "verify", "reset"}) || client.password != "NewPass123!" { + t.Fatal("重置未按验证后提交的顺序执行") + } + if !reflect.DeepEqual(screen.hidden, []bool{false, true, true, true}) { + t.Fatal("验证码或密码出现回显") + } + for _, prompt := range screen.prompts { + if strings.Contains(prompt, "01234567") || strings.Contains(prompt, "NewPass123!") { + t.Fatal("提示文字泄露秘密") + } + } +} + +func TestAdminConsoleFailureNeverSubmitsPassword(t *testing.T) { + for _, scenario := range []string{"unknown-method", "cancel-choice", "select", "verify", "input-code", "input-password", "input-confirm", "weak-password", "mismatch", "cancel-after-verify"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &testResetClient{methods: []localreset.Method{{ID: "email"}}} + screen := &testTerminalInput{values: []string{"1", "01234567", "NewPass123!", "NewPass123!"}} + switch scenario { + case "unknown-method": + client.methods[0].ID = "unknown" + case "cancel-choice": + screen.values[0] = "0" + case "select", "verify": + client.fail = scenario + case "input-code": + screen.failAt = 2 + case "input-password": + screen.failAt = 3 + case "input-confirm": + screen.failAt = 4 + case "weak-password": + screen.values[2] = "short" + case "mismatch": + screen.values[3] = "WrongPass12!" + case "cancel-after-verify": + screen.readHook = func(index int) { + if index == 4 { + cancel() + } + } + } + if err := resetFlow(ctx, client, screen); err == nil { + t.Fatal("失败条件未被拒绝") + } + for _, call := range client.calls { + if call == "reset" { + t.Fatal("验证或交互失败仍然提交了密码") + } + } + }) + } +} + +func TestAdminConsoleRejectsAllArgumentBasedInput(t *testing.T) { + for _, args := range [][]string{ + {"--password", "TestPass123!"}, {"--config", "scert.json"}, {"--code-output", "reset-code"}, {"TestPass123!"}, {"--help"}, + } { + err := Run(t.Context(), args) + if err == nil { + t.Fatal("接受了命令行参数") + } + if strings.Contains(err.Error(), "TestPass123!") { + t.Fatal("错误消息泄露了参数中的密码") + } + } +} diff --git a/backend/internal/adminconsole/invocation_linux.go b/backend/internal/adminconsole/invocation_linux.go new file mode 100644 index 0000000..c96857c --- /dev/null +++ b/backend/internal/adminconsole/invocation_linux.go @@ -0,0 +1,203 @@ +//go:build linux + +package adminconsole + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +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), "Scert-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 || !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{"Scert-admin", "Scert-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] == "Scert-admin" || positional[1] == "Scert-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/internal/adminconsole/invocation_linux_test.go b/backend/internal/adminconsole/invocation_linux_test.go new file mode 100644 index 0000000..9788a2b --- /dev/null +++ b/backend/internal/adminconsole/invocation_linux_test.go @@ -0,0 +1,83 @@ +//go:build linux + +package adminconsole + +import ( + "os" + "strconv" + "strings" + "testing" +) + +func TestAdminCommandRequiresExplicitInteractiveStart(t *testing.T) { + tests := []struct { + command string + targets bool + allowed bool + }{ + {"systemctl start Scert-admin", true, true}, + {"/usr/bin/systemctl --system start Scert-admin.service --no-pager", true, true}, + {"systemctl --wait --quiet start Scert-admin", true, true}, + {"systemctl restart Scert-admin", true, false}, + {"systemctl reload Scert-admin", true, false}, + {"systemctl stop Scert-admin", true, false}, + {"systemctl try-restart Scert-admin", true, false}, + {"systemctl reload-or-restart Scert-admin", true, false}, + {"systemctl try-reload-or-restart Scert-admin", true, false}, + {"systemctl force-reload Scert-admin", true, false}, + {"systemctl --no-block start Scert-admin", true, false}, + {"systemctl --no-ask-password start Scert-admin", true, false}, + {"systemctl --user start Scert-admin", true, false}, + {"systemctl --host=other start Scert-admin", true, false}, + {"systemctl start Scert-admin smail", true, false}, + {"systemctl start Scert-*", true, false}, + {"systemctl restart Scert-*", true, false}, + {"systemctl status Scert-admin", false, false}, + {"systemctl show Scert-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/internal/adminconsole/platform_linux.go b/backend/internal/adminconsole/platform_linux.go new file mode 100644 index 0000000..5c54942 --- /dev/null +++ b/backend/internal/adminconsole/platform_linux.go @@ -0,0 +1,100 @@ +//go:build linux + +package adminconsole + +import ( + "context" + "net" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "golang.org/x/sys/unix" +) + +func openTerminal(ctx context.Context) (context.Context, terminal, func(), error) { + child, closeTerminal, err := OpenInvocation(ctx) + if err != nil { + return nil, nil, nil, err + } + screen, ok := child.Value(invocationTerminalKey{}).(*invocationTerminal) + if !ok { + closeTerminal() + return nil, nil, nil, ErrUnauthorizedPeer + } + return child, screen, closeTerminal, nil +} + +func (t *invocationTerminal) Read(ctx context.Context, prompt string, hidden bool) (string, error) { + return t.read(ctx, prompt, hidden) +} + +func (t *invocationTerminal) Show(message string) error { return showLine(t.file, message) } + +func isTerminal(fd int) bool { + _, err := unix.IoctlGetTermios(fd, unix.TCGETS) + return err == nil +} + +func dialControl(ctx context.Context) (net.Conn, error) { + serviceUser, err := user.Lookup("Scert") + if err != nil { + return nil, localreset.ErrUnavailable + } + uid, err := strconv.ParseUint(serviceUser.Uid, 10, 32) + if err != nil { + return nil, localreset.ErrUnavailable + } + return localreset.Dial(ctx, "", uint32(uid)) +} + +// AuthorizeCurrentProcess 要求 root 系统服务和显式启动任务,不能通过直接执行或伪造环境放行。 +func AuthorizeCurrentProcess() error { + if !authorizeLinuxProcess(os.Geteuid(), os.Getpid(), os.Getppid(), os.Getenv("INVOCATION_ID"), os.Getenv("SYSTEMD_EXEC_PID")) { + return ErrUnauthorizedPeer + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "/usr/bin/systemctl", "list-jobs", "--no-legend", "--plain", "--no-pager").Output() + if err != nil || !hasAuthorizedStartJob(string(output)) { + return ErrUnauthorizedPeer + } + return nil +} + +func hasAuthorizedStartJob(output string) bool { + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) >= 4 && fields[1] == "Scert-admin.service" { + return fields[2] == "start" && (fields[3] == "waiting" || fields[3] == "running") + } + } + return false +} + +func authorizeLinuxProcess(euid, pid, parentPID int, invocationID, systemdExecPID string) bool { + if euid != 0 || parentPID != 1 || !validInvocationID(invocationID) { + return false + } + if systemdExecPID == "" { + return true + } + execPID, err := strconv.Atoi(systemdExecPID) + return err == nil && execPID == pid +} + +func validInvocationID(value string) bool { + if len(value) != 32 { + return false + } + for _, character := range strings.ToLower(value) { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return false + } + } + return true +} diff --git a/backend/internal/adminconsole/platform_linux_test.go b/backend/internal/adminconsole/platform_linux_test.go new file mode 100644 index 0000000..06959fe --- /dev/null +++ b/backend/internal/adminconsole/platform_linux_test.go @@ -0,0 +1,59 @@ +//go:build linux + +package adminconsole + +import "testing" + +func TestAuthorizeLinuxProcessRequiresSystemdInvocation(t *testing.T) { + validID := "0123456789abcdef0123456789abcdef" + tests := []struct { + name string + euid int + pid int + parentPID int + invocation string + execPID string + want bool + }{ + {name: "systemd service", euid: 0, pid: 120, parentPID: 1, invocation: validID, execPID: "120", want: true}, + {name: "systemd without optional exec pid", euid: 0, pid: 120, parentPID: 1, invocation: validID, want: true}, + {name: "non root", euid: 1000, pid: 120, parentPID: 1, invocation: validID, execPID: "120"}, + {name: "direct execution", euid: 0, pid: 120, parentPID: 99, invocation: validID, execPID: "120"}, + {name: "missing invocation", euid: 0, pid: 120, parentPID: 1, execPID: "120"}, + {name: "forged exec pid", euid: 0, pid: 120, parentPID: 1, invocation: validID, execPID: "121"}, + {name: "invalid invocation", euid: 0, pid: 120, parentPID: 1, invocation: "not-an-invocation-id", execPID: "120"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := authorizeLinuxProcess(test.euid, test.pid, test.parentPID, test.invocation, test.execPID); got != test.want { + t.Fatalf("authorizeLinuxProcess() = %v, want %v", got, test.want) + } + }) + } +} + +func TestAuthorizedSystemdJobOnlyAllowsExplicitStart(t *testing.T) { + tests := []struct { + name string + output string + want bool + }{ + {name: "start running", output: "42 Scert-admin.service start running\n", want: true}, + {name: "start waiting", output: "42 Scert-admin.service start waiting\n", want: true}, + {name: "restart", output: "42 Scert-admin.service restart running\n"}, + {name: "try restart", output: "42 Scert-admin.service try-restart running\n"}, + {name: "reload or restart", output: "42 Scert-admin.service reload-or-restart running\n"}, + {name: "try reload or restart", output: "42 Scert-admin.service try-reload-or-restart running\n"}, + {name: "reload", output: "42 Scert-admin.service reload running\n"}, + {name: "stop", output: "42 Scert-admin.service stop running\n"}, + {name: "other unit", output: "42 smail.service start running\n"}, + {name: "malformed", output: "Scert-admin.service start\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := hasAuthorizedStartJob(test.output); got != test.want { + t.Fatalf("hasAuthorizedStartJob() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/backend/internal/adminconsole/platform_other.go b/backend/internal/adminconsole/platform_other.go new file mode 100644 index 0000000..1f12881 --- /dev/null +++ b/backend/internal/adminconsole/platform_other.go @@ -0,0 +1,14 @@ +//go:build !linux && !windows + +package adminconsole + +import ( + "context" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "net" +) + +func openTerminal(context.Context) (context.Context, terminal, func(), error) { + return nil, nil, nil, localreset.ErrUnavailable +} +func dialControl(context.Context) (net.Conn, error) { return nil, localreset.ErrUnavailable } diff --git a/backend/internal/adminconsole/platform_windows.go b/backend/internal/adminconsole/platform_windows.go new file mode 100644 index 0000000..9ff6745 --- /dev/null +++ b/backend/internal/adminconsole/platform_windows.go @@ -0,0 +1,139 @@ +//go:build windows + +package adminconsole + +import ( + "context" + "fmt" + "net" + "os" + "time" + "unicode/utf16" + "unsafe" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "golang.org/x/sys/windows" +) + +var readConsoleInput = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputExW") + +type consoleRecord struct { + Type uint16 + Padding uint16 + Down int32 + Repeat uint16 + Key uint16 + Scan uint16 + Character uint16 + Control uint32 +} + +type windowsTerminal struct { + input windows.Handle + output *os.File +} + +func openTerminal(ctx context.Context) (context.Context, terminal, func(), error) { + if err := localreset.AuthorizeCurrentProcess(); err != nil { + return nil, nil, nil, err + } + input := windows.Handle(os.Stdin.Fd()) + var mode uint32 + if windows.GetConsoleMode(input, &mode) != nil || windows.GetConsoleMode(windows.Handle(os.Stderr.Fd()), &mode) != nil { + return nil, nil, nil, ErrUnauthorizedPeer + } + if readConsoleInput.Find() != nil { + return nil, nil, nil, localreset.ErrUnavailable + } + child, cancel := context.WithCancel(ctx) + return child, &windowsTerminal{input: input, output: os.Stderr}, cancel, nil +} + +func dialControl(ctx context.Context) (net.Conn, error) { return localreset.Dial(ctx, "", 0) } +func (t *windowsTerminal) Show(message string) error { return showLine(t.output, message) } + +// Read 使用非阻塞控制台事件,取消或超时后恢复原始模式,不遗留后台输入线程。 +func (t *windowsTerminal) Read(ctx context.Context, prompt string, hidden bool) (value string, resultErr error) { + if err := ctx.Err(); err != nil { + return "", err + } + var mode uint32 + if err := windows.GetConsoleMode(t.input, &mode); err != nil { + return "", err + } + next := mode &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_QUICK_EDIT_MODE) + next |= windows.ENABLE_EXTENDED_FLAGS + if err := windows.SetConsoleMode(t.input, next); err != nil { + return "", err + } + defer func() { + if err := windows.SetConsoleMode(t.input, mode); err != nil { + value, resultErr = "", localreset.ErrUnavailable + } + }() + if _, err := fmt.Fprint(t.output, prompt+": "); err != nil { + return "", err + } + defer fmt.Fprintln(t.output) + buffer := make([]uint16, 0, 4096) + defer func() { clear(buffer[:cap(buffer)]) }() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + if err := ctx.Err(); err != nil { + return "", err + } + var record consoleRecord + var count uint32 + ok, _, err := readConsoleInput.Call(uintptr(t.input), uintptr(unsafe.Pointer(&record)), 1, uintptr(unsafe.Pointer(&count)), 2) + if ok == 0 { + return "", err + } + if count == 0 { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + continue + } + } + if record.Type != 1 || record.Down == 0 { + continue + } + switch record.Character { + case 3, 27: + return "", context.Canceled + case '\r', '\n': + return string(utf16.Decode(buffer)), nil + case '\b': + if len(buffer) > 0 { + size := len(buffer) - 1 + if size > 0 && utf16.IsSurrogate(rune(buffer[size])) && utf16.IsSurrogate(rune(buffer[size-1])) { + size-- + } + clear(buffer[size:]) + buffer = buffer[:size] + if !hidden { + _, _ = fmt.Fprint(t.output, "\b \b") + } + } + default: + if record.Character < 32 || record.Character == 127 { + continue + } + repeat := int(record.Repeat) + if repeat < 1 { + repeat = 1 + } + if len(buffer)+repeat > cap(buffer) { + return "", ErrInvalidRequest + } + for range repeat { + buffer = append(buffer, record.Character) + } + if !hidden { + _, _ = fmt.Fprint(t.output, string(utf16.Decode([]uint16{record.Character}))) + } + } + } +} diff --git a/backend/internal/adminconsole/terminal_linux.go b/backend/internal/adminconsole/terminal_linux.go new file mode 100644 index 0000000..6b9c158 --- /dev/null +++ b/backend/internal/adminconsole/terminal_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +package adminconsole + +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/internal/adminconsole/terminal_linux_test.go b/backend/internal/adminconsole/terminal_linux_test.go new file mode 100644 index 0000000..773260f --- /dev/null +++ b/backend/internal/adminconsole/terminal_linux_test.go @@ -0,0 +1,127 @@ +//go:build linux + +package adminconsole + +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/backend/internal/httpapi/reset_boundary_test.go b/backend/internal/httpapi/reset_boundary_test.go new file mode 100644 index 0000000..a3e5f0c --- /dev/null +++ b/backend/internal/httpapi/reset_boundary_test.go @@ -0,0 +1,65 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/admin" +) + +// newLegacyResetServer 只保留旧处理函数的独立回归,不代表正式产品路由或生产验收。 +func newLegacyResetServer(dependencies Dependencies) *Server { + server := New(dependencies) + server.router.GET(admin.ResetPath, server.resetPage) + server.router.POST(admin.ResetPath, server.resetSubmit) + return server +} + +func TestProductionResetPathClosedEvenWithLegacyActivation(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Web.Hostname = "scert.example.test" + staticDir := t.TempDir() + if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("scert-test-shell"), 0600); err != nil { + t.Fatal(err) + } + controller := admin.New(cfg) + if _, err := controller.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = controller.Stop() }) + server := New(Dependencies{Config: cfg, FrontendDir: staticDir, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) + for _, route := range server.router.Routes() { + if route.Path == "/reset" || strings.HasPrefix(route.Path, "/reset/") { + t.Fatal("正式路由注册了旧密码重置处理器") + } + } + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} { + for _, path := range []string{"/reset", "/reset/", "/reset/anything", "/%72eset"} { + for _, authenticated := range []bool{false, true} { + request := httptest.NewRequest(method, "http://scert.example.test"+path, strings.NewReader("reset_code=test&new_password=TestPass123")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + if authenticated { + serveAdminHTTP(server, response, request) + } else { + server.Handler().ServeHTTP(response, request) + } + if response.Code != http.StatusNotFound { + t.Fatalf("正式重置路径必须关闭:%s %s authenticated=%v status=%d", method, path, authenticated, response.Code) + } + if strings.Contains(response.Body.String(), "scert-test-shell") || len(response.Result().Cookies()) != 0 { + t.Fatal("旧重置路径回退到页面或写入了重置 Cookie") + } + } + } + } + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://scert.example.test/login", nil)) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "scert-test-shell") { + t.Fatal("边界测试没有使用启用的真实页面回退处理器") + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 5f5678e..ee074a8 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -19,7 +19,6 @@ import ( "github.com/gin-gonic/gin" - "git.pcux.cn/xuans/Scert/backend/internal/admin" "git.pcux.cn/xuans/Scert/backend/internal/buildinfo" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" @@ -171,8 +170,6 @@ func (s *Server) registerRoutes() { // 主路由保持独立,初始化路由由专用模块注册。 s.router.GET("/healthz", s.health) s.router.GET("/readyz", s.ready) - s.router.GET(admin.ResetPath, s.resetPage) - s.router.POST(admin.ResetPath, s.resetSubmit) s.router.GET("/api/v1/status", s.status) s.router.GET("/api/v1/auth/session", s.authSession) s.router.POST("/api/v1/auth/login", s.requireSameOrigin, s.authLogin) @@ -500,7 +497,7 @@ func (s *Server) status(c *gin.Context) { if _, authenticated := s.currentAuthSession(c); authenticated { payload["https_mode"] = cfg.Server.HTTPSMode payload["database_driver"] = cfg.Database.Driver - payload["reset_url"] = cfg.ResetURL() + payload["reset_method"] = "local_admin_command" } c.JSON(http.StatusOK, payload) } diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index 501c199..9741d26 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -66,14 +66,14 @@ func TestHealthAndDomainAPI(t *testing.T) { } } -func TestResetPathRequiresScertAdminActivation(t *testing.T) { +func TestLegacyResetPathRequiresScertAdminActivation(t *testing.T) { dir := t.TempDir() cfg := readyTestConfig(dir) cfg.SetupReady = true cfg.Web.Hostname = "scert.example.test" cfg.Server.Port = 8443 statePath := admin.StatePath(cfg.Paths.Data) - server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, ResetStatePath: statePath}) + server := newLegacyResetServer(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, ResetStatePath: statePath}) inactive := httptest.NewRecorder() serveAdminHTTP(server, inactive, httptest.NewRequest(http.MethodGet, "/reset", nil)) @@ -103,7 +103,7 @@ func TestResetPathRequiresScertAdminActivation(t *testing.T) { } } -func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) { +func TestLegacyResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) { dir := t.TempDir() cfg := readyTestConfig(dir) cfg.SetupReady = true @@ -125,7 +125,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) { if err != nil { t.Fatalf("启动测试重置会话失败:%v", err) } - server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) + server := newLegacyResetServer(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) get := httptest.NewRecorder() serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil)) if get.Code != http.StatusOK { @@ -174,7 +174,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) { } } -func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) { +func TestLegacyResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) { dir := t.TempDir() cfg := readyTestConfig(dir) cfg.SetupReady = true @@ -211,7 +211,7 @@ func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) { if err != nil { t.Fatalf("启动测试重置会话失败:%v", err) } - server := New(Dependencies{ + server := newLegacyResetServer(Dependencies{ Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, Users: users, ResetStatePath: admin.StatePath(cfg.Paths.Data), }) @@ -243,7 +243,7 @@ func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) { } } -func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) { +func TestLegacyResetRejectsCrossSiteAndLimitsFailures(t *testing.T) { dir := t.TempDir() cfg := readyTestConfig(dir) cfg.SetupReady = true @@ -257,7 +257,7 @@ func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) { if err != nil { t.Fatalf("启动测试重置会话失败:%v", err) } - server := New(Dependencies{Config: cfg, ConfigPath: filepath.Join(dir, "config", "scert.json"), Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) + server := newLegacyResetServer(Dependencies{Config: cfg, ConfigPath: filepath.Join(dir, "config", "scert.json"), Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) get := httptest.NewRecorder() serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil)) cookie := get.Result().Cookies()[0] @@ -376,7 +376,7 @@ func TestServerAndDomainAssociationAPI(t *testing.T) { t.Fatalf("服务器删除后域名关联未清理:%d %s", listResponse.Code, listResponse.Body.String()) } } -func TestResetConfigSaveFailureKeepsSessionAndPassword(t *testing.T) { +func TestLegacyResetConfigSaveFailureKeepsSessionAndPassword(t *testing.T) { fixture := newResetTestFixture(t) blockedPath := filepath.Join(t.TempDir(), "config-target") if err := os.MkdirAll(blockedPath, 0o700); err != nil { @@ -415,7 +415,7 @@ func TestResetConfigSaveFailureKeepsSessionAndPassword(t *testing.T) { } } -func TestResetRejectsOversizedRequestBody(t *testing.T) { +func TestLegacyResetRejectsOversizedRequestBody(t *testing.T) { fixture := newResetTestFixture(t) form := resetForm(fixture.session.Code, fixture.cookie.Value, "NewPass1234") form.Set("padding", strings.Repeat("x", resetMaxBodyBytes)) @@ -437,7 +437,7 @@ func TestResetRejectsOversizedRequestBody(t *testing.T) { } } -func TestResetCSRFCookieHasRestrictedAttributes(t *testing.T) { +func TestLegacyResetCSRFCookieHasRestrictedAttributes(t *testing.T) { fixture := newResetTestFixture(t) cookie := fixture.cookie if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { @@ -451,7 +451,7 @@ func TestResetCSRFCookieHasRestrictedAttributes(t *testing.T) { } } -func TestResetConcurrentSubmissionsAllowOnlyOneSuccess(t *testing.T) { +func TestLegacyResetConcurrentSubmissionsAllowOnlyOneSuccess(t *testing.T) { fixture := newResetTestFixture(t) form := resetForm(fixture.session.Code, fixture.cookie.Value, "NewPass1234") start := make(chan struct{}) @@ -536,7 +536,7 @@ func newResetTestFixture(t *testing.T) resetTestFixture { if err != nil { t.Fatalf("启动测试重置会话失败:%v", err) } - server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) + server := newLegacyResetServer(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)}) get := httptest.NewRecorder() serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "https://scert.example.test/reset", nil)) if get.Code != http.StatusOK { diff --git a/backend/internal/localreset/transport_windows.go b/backend/internal/localreset/transport_windows.go index 3b6d76d..5fca412 100644 --- a/backend/internal/localreset/transport_windows.go +++ b/backend/internal/localreset/transport_windows.go @@ -130,3 +130,13 @@ func Dial(ctx context.Context, path string, expectedUID uint32) (net.Conn, error } return conn, nil } + +func AuthorizeCurrentProcess() error { + token, err := windows.OpenCurrentProcessToken() + if err != nil { + return ErrUnauthorized + } + defer token.Close() + _, err = tokenPeer(token) + return err +} diff --git a/deploy/systemd/Scert-admin.service b/deploy/systemd/Scert-admin.service index 6fcfba9..3bd7f3a 100644 --- a/deploy/systemd/Scert-admin.service +++ b/deploy/systemd/Scert-admin.service @@ -1,25 +1,28 @@ [Unit] Description=Scert-admin 临时密码重置入口激活器 Documentation=file:///scert/docs/Scert-admin-service-management.md -After=network-online.target -Wants=network-online.target +After=Scert.service +Requisite=Scert.service ConditionPathExists=/scert/conf/scert.json [Service] -Type=simple -User=Scert -Group=Scert +Type=oneshot +RemainAfterExit=no +User=root +Group=root WorkingDirectory=/scert RuntimeDirectory=scert-admin RuntimeDirectoryMode=0700 -ExecStart=/scert/scert-admin --config /scert/conf/scert.json --code-output /run/scert-admin/reset-code +ExecStart=/scert/scert-admin KillSignal=SIGTERM -TimeoutStopSec=15 +TimeoutStartSec=130s +TimeoutStopSec=5s +KillMode=mixed Restart=no -RuntimeMaxSec=30min +RuntimeMaxSec=130s UMask=0077 NoNewPrivileges=yes -CapabilityBoundingSet= +CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_OVERRIDE AmbientCapabilities= PrivateTmp=yes PrivateDevices=yes @@ -39,9 +42,14 @@ RestrictRealtime=yes RestrictSUIDSGID=yes RemoveIPC=yes SystemCallArchitectures=native -ReadWritePaths=/scert/data /scert/log /run/scert-admin +ReadWritePaths=/run/scert-admin +DeviceAllow=char-pts rw RestrictAddressFamilies=AF_UNIX # Scert-admin 不监听任何 TCP/UDP 端口,只维护主服务 /reset 路径的短时激活租约。 # 刻意不提供 [Install] 段,禁止通过 systemctl enable 纳入开机启动。 # 不配置 ExecReload,reload 不属于受支持的控制操作。 + +# 当前实现通过本机 Socket 交互,不再维护旧注释中描述的 Web 重置入口。 +# 管理程序使用 root 读取显式启动者的终端;只保留终端访问及本机 Socket 所需能力。 +# 新密码和验证码只在调用终端输入,不写入文件、命令参数或服务日志。 \ No newline at end of file