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