使用专用证书默认目录并拒绝接管其他账户目录

This commit is contained in:
xuans 2026-09-27 00:45:29 +08:00
parent 4dfcecfd21
commit 2d84f16554
9 changed files with 89 additions and 12 deletions

View File

@ -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`,

View File

@ -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",
} {

View File

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

View File

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

View File

@ -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` 专用目录。

View File

@ -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" \

View File

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

View File

@ -31,7 +31,7 @@ systemctl daemon-reload
/scert/data 数据目录
/scert/log 日志目录
/scert/backups 备份目录
/ssl 证书根目录
/ssl/scert 默认证书根目录
```
## 安全要求

View File

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