完善动态系统服务安装安全策略

This commit is contained in:
xuans 2026-08-31 12:57:46 +08:00
parent 886e4da900
commit b6f1ae490d
7 changed files with 242 additions and 59 deletions

View File

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

View File

@ -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: "服务名称无效"}

View File

@ -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("相对可写路径未被拒绝")
}
}

View File

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

View File

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

View File

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

View File

@ -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` 未删除、未纳入源码提交。