保存本机管理命令与关闭旧重置路由检查点
This commit is contained in:
parent
55c56b518f
commit
325928ee1f
20
backend/cmd/scert-admin/entry.go
Normal file
20
backend/cmd/scert-admin/entry.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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", "", "一次性校验码文件路径")
|
||||
|
||||
@ -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",
|
||||
|
||||
138
backend/internal/adminconsole/flow.go
Normal file
138
backend/internal/adminconsole/flow.go
Normal file
@ -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
|
||||
}
|
||||
148
backend/internal/adminconsole/flow_test.go
Normal file
148
backend/internal/adminconsole/flow_test.go
Normal file
@ -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("错误消息泄露了参数中的密码")
|
||||
}
|
||||
}
|
||||
}
|
||||
203
backend/internal/adminconsole/invocation_linux.go
Normal file
203
backend/internal/adminconsole/invocation_linux.go
Normal file
@ -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
|
||||
}
|
||||
83
backend/internal/adminconsole/invocation_linux_test.go
Normal file
83
backend/internal/adminconsole/invocation_linux_test.go
Normal file
@ -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 身份被接受")
|
||||
}
|
||||
}
|
||||
}
|
||||
100
backend/internal/adminconsole/platform_linux.go
Normal file
100
backend/internal/adminconsole/platform_linux.go
Normal file
@ -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
|
||||
}
|
||||
59
backend/internal/adminconsole/platform_linux_test.go
Normal file
59
backend/internal/adminconsole/platform_linux_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
14
backend/internal/adminconsole/platform_other.go
Normal file
14
backend/internal/adminconsole/platform_other.go
Normal file
@ -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 }
|
||||
139
backend/internal/adminconsole/platform_windows.go
Normal file
139
backend/internal/adminconsole/platform_windows.go
Normal file
@ -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})))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
backend/internal/adminconsole/terminal_linux.go
Normal file
95
backend/internal/adminconsole/terminal_linux.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
127
backend/internal/adminconsole/terminal_linux_test.go
Normal file
127
backend/internal/adminconsole/terminal_linux_test.go
Normal file
@ -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("终端设置未恢复")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
65
backend/internal/httpapi/reset_boundary_test.go
Normal file
65
backend/internal/httpapi/reset_boundary_test.go
Normal file
@ -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("<html>scert-test-shell</html>"), 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("边界测试没有使用启用的真实页面回退处理器")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 所需能力。
|
||||
# 新密码和验证码只在调用终端输入,不写入文件、命令参数或服务日志。
|
||||
Loading…
Reference in New Issue
Block a user