完善动态系统服务安装安全策略
This commit is contained in:
parent
886e4da900
commit
b6f1ae490d
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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: "服务名称无效"}
|
||||
|
||||
@ -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("相对可写路径未被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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` 未删除、未纳入源码提交。
|
||||
Loading…
Reference in New Issue
Block a user