diff --git a/backend/internal/admin/installer_test.go b/backend/internal/admin/installer_test.go index 235b7cf..3714190 100644 --- a/backend/internal/admin/installer_test.go +++ b/backend/internal/admin/installer_test.go @@ -73,6 +73,66 @@ systemctl() { } } +func TestLinuxInstallerCertificateOwnership(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip(err) + } + data, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "install-linux.sh")) + if err != nil { + t.Fatal(err) + } + content := string(data) + start := strings.Index(content, "validate_existing_certificate_dir() {") + if start < 0 { + t.Fatal("validate_existing_certificate_dir") + } + end := strings.Index(content[start:], "\n}\n") + if end < 0 { + t.Fatal("validate_existing_certificate_dir") + } + guard := content[start : start+end+3] + base := t.TempDir() + file := filepath.Join(base, "keep.pem") + if err := os.WriteFile(file, []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + path string + owner string + allowed bool + }{ + {"new-directory", filepath.Join(base, "new"), "root:root", true}, + {"owned-directory", base, "Scert:Scert", true}, + {"shared-directory", base, "root:root", false}, + {"other-service", base, "nginx:nginx", false}, + {"wrong-group", base, "Scert:root", false}, + {"missing-owner", base, "", false}, + {"existing-file", file, "Scert:Scert", false}, + } { + t.Run(tc.name, func(t *testing.T) { + script := `set -Eeuo pipefail +SERVICE_USER=Scert +SERVICE_GROUP=Scert +certificate_dir="$TEST_PATH" +fail() { printf '%s\n' "$1" >&2; exit 1; } +stat() { printf '%s\n' "$TEST_OWNER"; } +` + guard + "\nvalidate_existing_certificate_dir\n" + command := exec.Command(bash, "--noprofile", "--norc", "-c", script) + command.Env = append(os.Environ(), "TEST_PATH="+filepath.ToSlash(tc.path), "TEST_OWNER="+tc.owner) + output, err := command.CombinedOutput() + if (err == nil) != tc.allowed { + t.Fatalf("allowed=%t, err=%v, output=%s", tc.allowed, err, output) + } + preserved, err := os.ReadFile(file) + if err != nil || string(preserved) != "preserve" { + t.Fatalf("preserved=%q, err=%v", preserved, err) + } + }) + } +} + func TestLinuxInstallerPolicy(t *testing.T) { installerPath := filepath.Join("..", "..", "..", "deploy", "install-linux.sh") data, err := os.ReadFile(installerPath) @@ -83,7 +143,8 @@ func TestLinuxInstallerPolicy(t *testing.T) { required := []string{ `readonly INSTALL_DIR="/scert"`, - `readonly DEFAULT_CERTIFICATE_DIR="/ssl"`, + `readonly DEFAULT_CERTIFICATE_DIR="/ssl/scert"`, + `validate_existing_certificate_dir`, `readonly SERVICE_USER="Scert"`, `readonly INSTALL_METADATA_FILE="$INSTALL_DIR/conf/installation.json"`, `useradd --system`, diff --git a/backend/internal/admin/service_units_test.go b/backend/internal/admin/service_units_test.go index f8740e6..711b3b5 100644 --- a/backend/internal/admin/service_units_test.go +++ b/backend/internal/admin/service_units_test.go @@ -38,7 +38,7 @@ func TestScertSystemdUnitPolicy(t *testing.T) { "RestrictSUIDSGID=yes", "RemoveIPC=yes", "SystemCallArchitectures=native", - "ReadWritePaths=/ssl /scert/conf /scert/data /scert/log /scert/backups", + "ReadWritePaths=/ssl/scert /scert/conf /scert/data /scert/log /scert/backups", "[Install]", "WantedBy=multi-user.target", } { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index aa199db..1fdf8d0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -168,7 +168,7 @@ func Default(baseDir string) Config { func DefaultPaths(baseDir string) PathsConfig { if runtime.GOOS == "linux" { - certificateRoot := "/ssl" + certificateRoot := "/ssl/scert" if strings.TrimSpace(baseDir) == "" { if installedRoot, err := readInstalledCertificateRoot(LinuxInstallationMetadataPath); err == nil { certificateRoot = installedRoot diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d556e33..ab83501 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -11,7 +11,7 @@ import ( func TestDefaultPaths(t *testing.T) { paths := DefaultPaths(t.TempDir()) if runtime.GOOS == "linux" { - if paths.Install != "/scert" || paths.Certificate != "/ssl" { + if paths.Install != "/scert" || paths.Certificate != "/ssl/scert" { t.Fatalf("Linux 默认路径不正确:%+v", paths) } return diff --git a/deploy/README.md b/deploy/README.md index 369ac5f..71c3d92 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -158,7 +158,7 @@ SCERT_WEB_ENABLED=true ## 参数说明 - `--source-dir`:包含两个 Linux 可执行文件和 `web/index.html` 的目录,默认是安装器所在目录。 -- `--certificate-dir`:证书根目录参数,默认 `/ssl`。安装器会创建该目录,写入 `/scert/conf/installation.json`,并生成对应的 `Scert.service.d/10-certificate-path.conf`。首次初始化会读取该记录作为证书目录默认值。 +- `--certificate-dir`:证书根目录参数,默认 `/ssl/scert`,不接管共用的 `/ssl`。安装器会创建专用目录,写入 `/scert/conf/installation.json`,并生成对应的 `Scert.service.d/10-certificate-path.conf`。首次初始化会读取该记录作为证书目录默认值;既有安装仍沿用安装记录,不自动迁移证书。已存在且不属于 Scert 的目录会被拒绝,不修改其所有者或内容,请选择尚不存在的专用子目录。 Linux 安装目录当前固定为 `/scert`。自定义证书根目录必须是规范化的安全绝对路径,只允许字母、数字、点、下划线、连字符和斜杠,不允许空格、`..` 路径片段、符号链接、受保护系统目录或 `/scert` 下的路径;`/var` 下仅允许 `/var/lib/scert` 专用目录。 diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh index 85adc96..ba6204a 100644 --- a/deploy/install-linux.sh +++ b/deploy/install-linux.sh @@ -3,7 +3,7 @@ set -Eeuo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly INSTALL_DIR="/scert" -readonly DEFAULT_CERTIFICATE_DIR="/ssl" +readonly DEFAULT_CERTIFICATE_DIR="/ssl/scert" readonly SERVICE_USER="Scert" readonly SERVICE_GROUP="Scert" readonly SERVICE_DIR="/etc/systemd/system" @@ -27,7 +27,7 @@ Scert Linux 安装器 选项: --source-dir DIR 安装产物目录,必须包含 scert 和 scert-admin - --certificate-dir DIR 证书根目录,默认 /ssl + --certificate-dir DIR 证书根目录,默认 /ssl/scert -h, --help 显示帮助 Linux 安装目录固定为 /scert,证书业务目录可在首次初始化时继续配置。 @@ -112,6 +112,15 @@ validate_certificate_dir() { } +validate_existing_certificate_dir() { + if [[ ! -e "$certificate_dir" ]]; then + return + fi + [[ -d "$certificate_dir" ]] || fail "证书路径不是目录:$certificate_dir" + [[ "$(stat -c '%U:%G' -- "$certificate_dir")" == "$SERVICE_USER:$SERVICE_GROUP" ]] || \ + fail "现有证书目录不属于 Scert,拒绝接管;请填写尚不存在的专用子目录:$certificate_dir" +} + reject_symlink_path() { local target_path="$1" local description="$2" @@ -173,13 +182,14 @@ validate_environment() { [[ "$(uname -s)" == "Linux" ]] || fail "此安装器只支持 Linux" [[ "$(id -u)" -eq 0 ]] || fail "请使用 root 或 sudo 运行安装器" local command_name - for command_name in install systemctl getent groupadd useradd id chmod chown cut grep readlink find file mv cp mktemp; do + for command_name in install systemctl getent groupadd useradd id chmod chown cut grep readlink find file mv cp mktemp stat; do command -v "$command_name" >/dev/null 2>&1 || fail "找不到系统命令:$command_name" done nologin_shell="$(command -v nologin || true)" [[ -n "$nologin_shell" ]] || fail "找不到 nologin,无法创建禁止登录的服务用户" load_existing_certificate_dir validate_certificate_dir + validate_existing_certificate_dir validate_managed_paths } @@ -337,7 +347,7 @@ create_directories() { "$INSTALL_DIR/data" \ "$INSTALL_DIR/log" \ "$INSTALL_DIR/backups" - install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$certificate_dir" + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$certificate_dir" chown -R -h "$SERVICE_USER:$SERVICE_GROUP" \ "$INSTALL_DIR/conf" \ "$INSTALL_DIR/data" \ diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service index 53ffcc6..b602ee1 100644 --- a/deploy/systemd/Scert.service +++ b/deploy/systemd/Scert.service @@ -39,7 +39,7 @@ RestrictRealtime=yes RestrictSUIDSGID=yes RemoveIPC=yes SystemCallArchitectures=native -ReadWritePaths=/ssl /scert/conf /scert/data /scert/log /scert/backups +ReadWritePaths=/ssl/scert /scert/conf /scert/data /scert/log /scert/backups # 初始化临时证书读取本机网卡地址需要 NETLINK_ROUTE,不授予网络管理能力。 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK diff --git a/deploy/systemd/Scert服务管理说明.md b/deploy/systemd/Scert服务管理说明.md index bf40999..44937c7 100644 --- a/deploy/systemd/Scert服务管理说明.md +++ b/deploy/systemd/Scert服务管理说明.md @@ -31,7 +31,7 @@ systemctl daemon-reload /scert/data 数据目录 /scert/log 日志目录 /scert/backups 备份目录 -/ssl 证书根目录 +/ssl/scert 默认证书根目录 ``` ## 安全要求 diff --git a/deploy/tests/install-linux-test.py b/deploy/tests/install-linux-test.py index 9517e92..2a63cab 100644 --- a/deploy/tests/install-linux-test.py +++ b/deploy/tests/install-linux-test.py @@ -4,6 +4,7 @@ import argparse import hashlib import json import os +import pwd from pathlib import Path import subprocess import sys @@ -92,6 +93,9 @@ def prepare(base, scenario, certificate_root): for name in ["conf/scert.json", "conf/master.key", "conf/secrets.json", "data/scert.db", "backups/keep"]: (base / "install" / name).write_text("preserve:" + name) (base / "certificates/keep.pem").write_text("preserve:certificate") + account = pwd.getpwnam("Scert") + os.chown(base / "certificates", account.pw_uid, account.pw_gid) + (base / "certificates").chmod(0o700) for name in ["Scert.service", "Scert-admin.service", "Scert.service.d/10-certificate-path.conf"]: (base / "units" / name).write_text("old:" + name) command_log = str(base / "systemctl.log") @@ -117,6 +121,8 @@ def prepare(base, scenario, certificate_root): + ('exit 0; fi\n' if scenario == "service-active" else 'exit 3; fi\n') + 'if [[ "$1" == "show" ]]; then\n' + f'if [[ "$2" == "{service_name}" ]]; then\n' + state_command + 'fi\n' + + 'if [[ "$2" == "Scert-storage.socket" ]]; then\n' + + "printf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive' 'SubState=dead' 'ControlPID=0' 'Job='; exit 0\nfi\n" + "printf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive' 'SubState=dead' 'MainPID=0' 'ControlPID=0' 'Job='; exit 0\nfi\n" + '[[ "$*" == "daemon-reload" ]] || exit 97\n' + (f'if [[ ! -e "{counter}" ]]; then touch "{counter}"; exit 42; fi\n' if scenario == "reload-failure" else "") @@ -213,7 +219,7 @@ def parent(args): command_log = base / "systemctl.log" calls = command_log.read_text().splitlines() if command_log.exists() else [] allowed = {"is-active --quiet Scert.service", "is-active --quiet Scert-admin.service", "daemon-reload"} - allowed.update("show " + name + " -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job" for name in ["Scert.service", "Scert-admin.service"]) + allowed.update("show " + name + " -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job" for name in ["Scert.service", "Scert-admin.service", "Scert-storage.service", "Scert-storage.socket"]) if any(call not in allowed for call in calls): errors.append("出现不允许的服务操作") if scenario in SERVICE_GUARDS and "daemon-reload" in calls: