From b6f1ae490d0a70e494a2965739744655239c196d Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 31 Aug 2026 12:57:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8A=A8=E6=80=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=9C=8D=E5=8A=A1=E5=AE=89=E8=A3=85=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/service.go | 7 + backend/internal/servicehost/linux.go | 55 +++++--- backend/internal/servicehost/linux_test.go | 29 ++++- backend/internal/servicehost/service.go | 128 ++++++++++++++++--- backend/internal/servicehost/service_test.go | 36 ++++++ backend/internal/servicehost/windows.go | 19 +-- doc/Scert开发任务清单.md | 27 ++++ 7 files changed, 242 insertions(+), 59 deletions(-) diff --git a/backend/internal/cli/service.go b/backend/internal/cli/service.go index 8ff8d51..47ffd4f 100644 --- a/backend/internal/cli/service.go +++ b/backend/internal/cli/service.go @@ -18,6 +18,9 @@ func newServiceCommand() *cobra.Command { var executable string var serviceConfig string var workDir string + var user string + var group string + var readWritePaths []string command := &cobra.Command{ Use: "service", @@ -34,6 +37,7 @@ func newServiceCommand() *cobra.Command { return servicehost.Install(cmd.Context(), servicehost.Options{ Name: name, DisplayName: displayName, Description: description, Executable: executablePath, ConfigPath: configPath, WorkDir: directory, + User: user, Group: group, ReadWritePaths: readWritePaths, }) }, } @@ -67,6 +71,9 @@ func newServiceCommand() *cobra.Command { install.Flags().StringVar(&executable, "executable", "", "服务程序路径,默认使用当前 scert 程序") install.Flags().StringVar(&serviceConfig, "service-config", "", "服务配置文件路径,默认使用当前配置") install.Flags().StringVar(&workDir, "work-dir", "", "服务工作目录,默认使用程序目录") + install.Flags().StringVar(&user, "user", "", "保护运行用户,需与 --group 一起配置") + install.Flags().StringVar(&group, "group", "", "保护运行用户组,需与 --user 一起配置") + install.Flags().StringSliceVar(&readWritePaths, "read-write-path", nil, "允许服务写入的绝对路径,可重复传入") return command } diff --git a/backend/internal/servicehost/linux.go b/backend/internal/servicehost/linux.go index 7c9d3c0..3099960 100644 --- a/backend/internal/servicehost/linux.go +++ b/backend/internal/servicehost/linux.go @@ -31,7 +31,8 @@ func Install(ctx context.Context, options Options) error { } func Uninstall(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSystemctl(ctx, "disable", name); err != nil { @@ -47,7 +48,8 @@ func Uninstall(ctx context.Context, name string) error { } func Start(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSystemctl(ctx, "start", name); err != nil { @@ -57,7 +59,8 @@ func Start(ctx context.Context, name string) error { } func Stop(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSystemctl(ctx, "stop", name); err != nil { @@ -78,7 +81,8 @@ func runSystemctl(ctx context.Context, args ...string) error { const systemUnitDirectory = "/etc/systemd/system" func unitFilePath(name string) (string, error) { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return "", err } return filepath.Join(systemUnitDirectory, name+".service"), nil @@ -151,16 +155,27 @@ func renderUnitFile(options Options) (string, error) { if err != nil { return "", err } - return strings.Join([]string{ + for _, path := range normalized.ReadWritePaths { + if !filepath.IsAbs(path) { + return "", fmt.Errorf("服务可写路径必须是绝对路径:%s", path) + } + } + lines := []string{ "[Unit]", "Description=" + normalized.Description, "After=network-online.target", "Wants=network-online.target", + "ConditionPathExists=" + quoteUnitValue(normalized.ConfigPath), "", "[Service]", "Type=simple", - "WorkingDirectory=" + quoteUnitValue(normalized.WorkDir), - "ExecStart=" + quoteUnitValue(normalized.Executable) + " --config " + quoteUnitValue(normalized.ConfigPath) + " serve", + } + if normalized.User != "" { + lines = append(lines, "User="+normalized.User, "Group="+normalized.Group) + } + lines = append(lines, + "WorkingDirectory="+quoteUnitValue(normalized.WorkDir), + "ExecStart="+quoteUnitValue(normalized.Executable)+" --config "+quoteUnitValue(normalized.ConfigPath)+" serve", "KillSignal=SIGTERM", "TimeoutStopSec=30", "Restart=on-failure", @@ -168,11 +183,18 @@ func renderUnitFile(options Options) (string, error) { "NoNewPrivileges=yes", "PrivateTmp=yes", "ProtectHome=yes", - "", - "[Install]", - "WantedBy=multi-user.target", - "", - }, "\n"), nil + "ProtectSystem=full", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + ) + if len(normalized.ReadWritePaths) > 0 { + quotedPaths := make([]string, 0, len(normalized.ReadWritePaths)) + for _, path := range normalized.ReadWritePaths { + quotedPaths = append(quotedPaths, quoteUnitValue(path)) + } + lines = append(lines, "ReadWritePaths="+strings.Join(quotedPaths, " ")) + } + lines = append(lines, "", "[Install]", "WantedBy=multi-user.target", "") + return strings.Join(lines, "\n"), nil } func quoteUnitValue(value string) string { @@ -192,12 +214,3 @@ func (e *commandFailure) Error() string { } func (e *commandFailure) Unwrap() error { return e.err } - -func validateName(name string) error { - if strings.TrimSpace(name) == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) { - return ErrInvalidName - } - return nil -} - -var ErrInvalidName = &commandFailure{output: "服务名称无效"} diff --git a/backend/internal/servicehost/linux_test.go b/backend/internal/servicehost/linux_test.go index 24ec532..5c76809 100644 --- a/backend/internal/servicehost/linux_test.go +++ b/backend/internal/servicehost/linux_test.go @@ -10,11 +10,14 @@ import ( func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { content, err := renderUnitFile(Options{ - Name: "Scert", - Description: "Scert SSL 证书管理服务", - Executable: filepath.Join("/opt", "Scert", "scert"), - ConfigPath: filepath.Join("/opt", "Scert data", "scert.json"), - WorkDir: filepath.Join("/opt", "Scert data"), + Name: "Scert", + Description: "Scert SSL 证书管理服务", + Executable: filepath.Join("/opt", "Scert", "scert"), + ConfigPath: filepath.Join("/opt", "Scert data", "scert.json"), + WorkDir: filepath.Join("/opt", "Scert data"), + User: "Scert", + Group: "Scert", + ReadWritePaths: []string{"/var/lib/scert", "/var/log/scert"}, }) if err != nil { t.Fatalf("渲染 systemd unit 失败:%v", err) @@ -23,11 +26,17 @@ func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { "[Unit]", "Description=Scert SSL 证书管理服务", "After=network-online.target", + "ConditionPathExists=\"/opt/Scert data/scert.json\"", "[Service]", "Type=simple", + "User=Scert", + "Group=Scert", "WorkingDirectory=\"/opt/Scert data\"", "ExecStart=\"/opt/Scert/scert\" --config \"/opt/Scert data/scert.json\" serve", "Restart=on-failure", + "ProtectSystem=full", + "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", + "ReadWritePaths=\"/var/lib/scert\" \"/var/log/scert\"", "[Install]", "WantedBy=multi-user.target", } { @@ -44,3 +53,13 @@ func TestUnitFilePathRejectsPathTraversal(t *testing.T) { } } } + +func TestRenderUnitFileRejectsRelativeWritablePath(t *testing.T) { + _, err := renderUnitFile(Options{ + Name: "Scert", Executable: "/opt/scert", ConfigPath: "/opt/scert/scert.json", WorkDir: "/opt/scert", + ReadWritePaths: []string{"relative/path"}, + }) + if err == nil { + t.Fatal("相对可写路径未被拒绝") + } +} diff --git a/backend/internal/servicehost/service.go b/backend/internal/servicehost/service.go index b3d7480..a44f49a 100644 --- a/backend/internal/servicehost/service.go +++ b/backend/internal/servicehost/service.go @@ -5,38 +5,112 @@ import ( "fmt" "path/filepath" "strings" + "unicode" ) +var ErrInvalidName = errors.New("服务名称无效") + type Options struct { - Name string - DisplayName string - Description string - Executable string - ConfigPath string - WorkDir string + Name string + DisplayName string + Description string + Executable string + ConfigPath string + WorkDir string + User string + Group string + ReadWritePaths []string } func (o Options) Validate() error { - if strings.TrimSpace(o.Name) == "" { - return errors.New("服务名称不能为空") + if err := validateName(o.Name); err != nil { + return err } - if strings.ContainsAny(o.Name, `/\\`) { - return errors.New("服务名称不能包含路径分隔符") + if err := validatePathValue(o.Executable, "服务程序路径"); err != nil { + return err } - if strings.TrimSpace(o.Executable) == "" { - return errors.New("服务程序路径不能为空") + if err := validatePathValue(o.ConfigPath, "配置文件路径"); err != nil { + return err } - if filepath.Clean(o.Executable) == "." { - return errors.New("服务程序路径无效") + if err := validatePathValue(o.WorkDir, "服务工作目录"); err != nil { + return err } - if strings.TrimSpace(o.ConfigPath) == "" { - return errors.New("配置文件路径不能为空") + if err := validateTextValue(o.DisplayName, "服务显示名称"); err != nil { + return err } - if filepath.Clean(o.ConfigPath) == "." { - return errors.New("配置文件路径无效") + if err := validateTextValue(o.Description, "服务描述"); err != nil { + return err } - if strings.TrimSpace(o.WorkDir) == "" { - return errors.New("服务工作目录不能为空") + if (strings.TrimSpace(o.User) == "") != (strings.TrimSpace(o.Group) == "") { + return errors.New("服务用户和用户组必须同时配置") + } + if err := validateIdentity(o.User, "服务用户"); err != nil { + return err + } + if err := validateIdentity(o.Group, "服务用户组"); err != nil { + return err + } + for _, path := range o.ReadWritePaths { + if err := validatePathValue(path, "服务可写路径"); err != nil { + return err + } + } + return nil +} + +func normalizeName(name string) (string, error) { + name = strings.TrimSpace(name) + if err := validateName(name); err != nil { + return "", err + } + return name, nil +} + +func validateName(name string) error { + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return ErrInvalidName + } + for _, character := range name { + if !(character == '-' || character == '_' || character == '.' || character == '@' || + (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9')) { + return ErrInvalidName + } + } + return nil +} + +func validatePathValue(value, label string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s不能为空", label) + } + if filepath.Clean(value) == "." { + return fmt.Errorf("%s无效", label) + } + return validateTextValue(value, label) +} + +func validateTextValue(value, label string) error { + for _, character := range value { + if unicode.IsControl(character) { + return fmt.Errorf("%s不能包含控制字符", label) + } + } + return nil +} + +func validateIdentity(value, label string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + for _, character := range value { + if !(character == '-' || character == '_' || character == '.' || + (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9')) { + return fmt.Errorf("%s只能包含字母、数字、点、连字符和下划线", label) + } } return nil } @@ -57,9 +131,23 @@ func (o Options) normalized() (Options, error) { o.Executable = filepath.Clean(strings.TrimSpace(o.Executable)) o.ConfigPath = filepath.Clean(strings.TrimSpace(o.ConfigPath)) o.WorkDir = filepath.Clean(strings.TrimSpace(o.WorkDir)) + o.User = strings.TrimSpace(o.User) + o.Group = strings.TrimSpace(o.Group) + o.ReadWritePaths = normalizePaths(o.ReadWritePaths) return o, nil } +func normalizePaths(paths []string) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + path = filepath.Clean(strings.TrimSpace(path)) + if path != "." { + result = append(result, path) + } + } + return result +} + func commandError(action string, err error) error { return fmt.Errorf("%s服务失败:%w", action, err) } diff --git a/backend/internal/servicehost/service_test.go b/backend/internal/servicehost/service_test.go index d7adb1c..02fb3d7 100644 --- a/backend/internal/servicehost/service_test.go +++ b/backend/internal/servicehost/service_test.go @@ -53,3 +53,39 @@ func TestValidateNameRejectsPathLikeValues(t *testing.T) { } } } + +func TestOptionsValidateRejectsUnsafeUnitValues(t *testing.T) { + base := Options{ + Name: "Scert", Executable: "scert", ConfigPath: "scert.json", WorkDir: "scert", + } + cases := []Options{ + {Name: "Scert\nInjected", Executable: base.Executable, ConfigPath: base.ConfigPath, WorkDir: base.WorkDir}, + {Name: base.Name, DisplayName: "Scert\nInjected", Executable: base.Executable, ConfigPath: base.ConfigPath, WorkDir: base.WorkDir}, + {Name: base.Name, Description: "Scert\rInjected", Executable: base.Executable, ConfigPath: base.ConfigPath, WorkDir: base.WorkDir}, + {Name: base.Name, User: "Scert", Executable: base.Executable, ConfigPath: base.ConfigPath, WorkDir: base.WorkDir}, + {Name: base.Name, User: "Scert", Group: "Scert Group", Executable: base.Executable, ConfigPath: base.ConfigPath, WorkDir: base.WorkDir}, + } + for index, item := range cases { + if err := item.Validate(); err == nil { + t.Errorf("不安全的服务配置 %d 未被拒绝", index) + } + } +} + +func TestOptionsNormalizedCopiesServiceSecuritySettings(t *testing.T) { + firstPath := filepath.Clean(filepath.Join("C:", "var", "lib", "scert")) + secondPath := filepath.Clean(filepath.Join("C:", "var", "log", "scert")) + item, err := (Options{ + Name: " Scert ", Executable: "scert", ConfigPath: "scert.json", WorkDir: "scert", + User: " Scert ", Group: " Scert ", ReadWritePaths: []string{" " + firstPath + " ", " " + secondPath + " "}, + }).normalized() + if err != nil { + t.Fatalf("规范化服务安全配置失败:%v", err) + } + if item.User != "Scert" || item.Group != "Scert" { + t.Fatalf("服务用户配置未规范化:%+v", item) + } + if len(item.ReadWritePaths) != 2 || item.ReadWritePaths[0] != firstPath || item.ReadWritePaths[1] != secondPath { + t.Fatalf("服务可写路径未规范化:%+v", item.ReadWritePaths) + } +} diff --git a/backend/internal/servicehost/windows.go b/backend/internal/servicehost/windows.go index 98ec090..4f8db42 100644 --- a/backend/internal/servicehost/windows.go +++ b/backend/internal/servicehost/windows.go @@ -6,7 +6,6 @@ import ( "context" "os/exec" "strconv" - "strings" ) func Install(ctx context.Context, options Options) error { @@ -25,7 +24,8 @@ func Install(ctx context.Context, options Options) error { } func Uninstall(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSC(ctx, "stop", name); err != nil { @@ -39,7 +39,8 @@ func Uninstall(ctx context.Context, name string) error { } func Start(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSC(ctx, "start", name); err != nil { @@ -49,7 +50,8 @@ func Start(ctx context.Context, name string) error { } func Stop(ctx context.Context, name string) error { - if err := validateName(name); err != nil { + name, err := normalizeName(name) + if err != nil { return err } if err := runSC(ctx, "stop", name); err != nil { @@ -67,8 +69,6 @@ func runSC(ctx context.Context, args ...string) error { return nil } -var ErrInvalidName = &commandFailure{output: "服务名称无效"} - type commandFailure struct { output string err error @@ -82,10 +82,3 @@ func (e *commandFailure) Error() string { } func (e *commandFailure) Unwrap() error { return e.err } - -func validateName(name string) error { - if strings.TrimSpace(name) == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) { - return ErrInvalidName - } - return nil -} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 952c1ae..3ca3028 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3217,3 +3217,30 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` 当前验证在 Windows 开发机完成;Linux servicehost 测试仅完成 amd64 交叉编译,systemd 实际安装、启停、开机启动和权限效果仍需授权 Ubuntu 主机实机验收。 - `[!]` 真实 DNS、ACME Production、SSH/SFTP 证书部署、Nginx 反向代理、生产 HTTPS、Windows Service、Wails 安装包、Windows ACL、MySQL 实例和真实防火墙链路仍不属于本地自动化测试结论。 - `[!]` 工作区中既有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入本轮提交。 + +## 八十五、动态系统服务安装安全增强与全量复审(2026-08-31) + +### 85.1 本轮需求清单 + +- `[✓]` 动态 Linux systemd unit 增加配置文件存在条件、系统目录只读保护和网络地址族限制。 +- `[✓]` CLI 支持可选运行用户、用户组和服务可写绝对路径配置。 +- `[✓]` 服务名称、服务文本、路径和账户标识增加控制字符及危险格式校验。 +- `[✓]` Linux、Windows 服务启停和卸载入口统一规范化服务名称,避免首尾空格造成操作对象不一致。 +- `[✓]` 增加动态 unit 安全策略、相对可写路径和配置注入防护回归测试。 + +### 85.2 验证状态 + +- `[✓]` 后端 `go test -count=1 ./...` 通过。 +- `[✓]` 后端 `go test -race -count=1 ./...` 通过。 +- `[✓]` 后端 `go vet ./...`、`go mod verify`、CLI 构建通过。 +- `[✓]` Linux amd64 和 Windows amd64 servicehost 测试编译通过。 +- `[✓]` 桌面端 Go 测试、网页端生产构建、桌面端前端生产构建通过。 +- `[✓]` Wails `v2.11.0` Windows amd64 生产构建通过。 +- `[✓]` Windows 安装脚本 PowerShell 语法解析通过;当前开发机未安装 Bash,因此 Linux shell 语法检查未执行。 + +### 85.3 交付边界 + +- `[!]` Linux systemd 实际安装、启停、开机启动和权限效果仍需在授权 Ubuntu 主机实机验收。 +- `[!]` Windows Service 实际安装、ACL、开机启动和升级仍需在授权 Windows 主机实机验收。 +- `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 部署、Nginx 反向代理、生产 HTTPS、MySQL 和真实防火墙链路仍需目标环境验收。 +- `[!]` 工作区已有的 `desktop/desktop.exe`、`desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和 `backend/*.exe` 未删除、未纳入源码提交。 \ No newline at end of file