补齐Nginx辅助服务安全初始化与部署模板

This commit is contained in:
xuans 2026-09-20 02:29:36 +08:00
parent aa5d669db1
commit d8b4850916
13 changed files with 593 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import (
"os/signal"
"strconv"
"syscall"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/nginxactivation"
)
@ -57,12 +58,55 @@ func run(ctx context.Context, path string) error {
return service.Serve(ctx, listener)
}
func execute(ctx context.Context, arguments []string) error {
flags := flag.NewFlagSet("scert-nginx", flag.ContinueOnError)
flags.SetOutput(os.Stdout)
flags.Usage = func() {
fmt.Fprintln(flags.Output(), "用法:scert-nginx [参数];默认由 systemd socket 启动服务。")
flags.PrintDefaults()
}
path := flags.String("policy", "/etc/scert-nginx/policy.json", "root 管理的独立激活策略文件")
initialize := flags.Bool("initialize", false, "仅初始化专用证书材料,不重载 Nginx")
chain := flags.String("certificate", "", "root 受保护的现有完整证书链文件")
key := flags.String("private-key", "", "root 受保护的现有私钥文件")
if err := flags.Parse(arguments); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.NArg() != 0 || *path == "" {
return errors.New("不接受位置参数,策略路径不能为空")
}
if !*initialize {
if *chain != "" || *key != "" {
return errors.New("证书材料参数仅用于显式初始化")
}
return run(ctx, *path)
}
if *chain == "" || *key == "" {
return errors.New("初始化必须同时提供证书和私钥路径")
}
if os.Geteuid() != 0 {
return errors.New("Nginx 激活目录初始化必须由 root 执行")
}
policy, err := nginxactivation.LoadPolicy(*path)
if err != nil {
return fmt.Errorf("读取受信激活策略失败:%w", err)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := policy.Rotation().Initialize(ctx, *chain, *key); err != nil {
return err
}
fmt.Println("专用证书材料已初始化;尚未修改或重载 Nginx,请继续核对站点接线。")
return nil
}
func main() {
path := flag.String("policy", "/etc/scert-nginx/policy.json", "root 管理的独立激活策略文件")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, *path); err != nil {
if err := execute(ctx, os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "Nginx 本地激活服务启动或运行失败:", err)
os.Exit(1)
}

View File

@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"net"
"os"
@ -11,6 +12,18 @@ import (
"testing"
)
func TestExecuteRejectsInvalidInitializationArguments(t *testing.T) {
for _, args := range [][]string{
{"--initialize"}, {"--initialize", "--certificate", "/no-certificate"},
{"--initialize", "--private-key", "/no-key"}, {"--certificate", "/no-certificate"},
{"--private-key", "/no-key"}, {"--policy", ""}, {"unexpected"},
} {
if err := execute(context.Background(), args); err == nil {
t.Fatalf("无效初始化参数被接受:%v", args)
}
}
}
func TestInheritedListener(t *testing.T) {
if mode := os.Getenv("SCERT_LISTENER_TEST_CHILD"); mode != "" {
os.Setenv("LISTEN_PID", strconv.Itoa(os.Getpid()))

View File

@ -0,0 +1,67 @@
package admin
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNginxActivationDeploymentPolicy(t *testing.T) {
for name, required := range map[string][]string{
"scert-nginx.socket": {
"ListenStream=/run/scert-nginx/activation.sock", "Accept=no", "SocketUser=root",
"SocketGroup=Scert", "SocketMode=0660", "DirectoryMode=0755", "RemoveOnStop=no",
"Service=scert-nginx.service", "WantedBy=sockets.target",
},
"scert-nginx.service": {
"Requires=scert-nginx.socket nginx.service", "PartOf=nginx.service", "User=root", "Group=root",
"ExecStart=/usr/local/libexec/scert-nginx --policy /etc/scert-nginx/policy.json",
"NoNewPrivileges=yes", "ProtectSystem=strict", "ProtectHome=yes", "PrivateTmp=yes",
"CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_CHOWN", "AmbientCapabilities=",
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", "TimeoutStopSec=40",
},
} {
t.Run(name, func(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "systemd", name))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(activeUnitText(string(data)), "\n")
for _, expected := range required {
found := false
for _, line := range lines {
found = found || strings.TrimSpace(line) == expected
}
if !found {
t.Errorf("辅助服务模板缺少安全策略:%s", expected)
}
}
for _, line := range lines {
if strings.HasPrefix(line, "ListenStream=") && line != "ListenStream=/run/scert-nginx/activation.sock" {
t.Errorf("辅助服务不应监听其他地址:%s", line)
}
if strings.HasPrefix(line, "ExecStart=/scert/") || strings.HasPrefix(line, "ExecStartPre=") || strings.Contains(line, "--initialize") {
t.Errorf("辅助服务不应执行应用目录程序或隐式初始化:%s", line)
}
}
})
}
data, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "nginx", "policy.json.example"))
if err != nil {
t.Fatal(err)
}
var policy struct {
AllowedUID *uint32 `json:"allowed_uid"`
SocketPath string `json:"socket_path"`
Directory string `json:"directory"`
Hostname string `json:"hostname"`
}
if err := json.Unmarshal(data, &policy); err != nil {
t.Fatal(err)
}
if policy.AllowedUID != nil || policy.Hostname != "__MANAGEMENT_HOST__" || policy.SocketPath != "/run/scert-nginx/activation.sock" || policy.Directory != "/var/lib/scert-nginx" {
t.Fatal("策略示例必须显式要求填写调用 UID 和域名,并匹配专用目录")
}
}

View File

@ -36,6 +36,8 @@
## 接入前要求
发布包现提供独立 socket/service、拒绝直接使用的策略模板和 `--initialize` 命令。初始化从 root 受保护副本导入当前证书,核对双层 TLS 与就绪状态,仅创建专用材料,不修改或重载站点;已有状态或失败残留拒绝覆盖。部署步骤见 `deploy/nginx/Nginx辅助服务部署说明.md`,新增初始化用例尚待 Linux 执行,模板尚未完成 systemd 实机验收。
1. 安装独立 socket/service,固定调用 UID,初始化专用版本目录并让目标站点引用 `active.conf`。
2. 应用须持久化旧状态、候选摘要、激活阶段和关联事务;完整生命周期必须串行化,不能在恢复检查上游之后并发切换上游。
3. 覆盖代理暂存、上游配置持久化与内存切换、代理提交之间的中断及应答丢失,并在启动时先恢复再允许申请下一张证书。

View File

@ -0,0 +1,121 @@
package nginxactivation
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// Initialize 仅准备首次接线材料,不修改站点、不重载 Nginx,也不覆盖已有状态。
func (r Rotation) Initialize(ctx context.Context, certificatePath, privateKeyPath string) (string, error) {
root, lock, err := r.open(ctx)
if err != nil {
return "", err
}
defer root.Close()
defer lock.Close()
if err := requireUninitialized(root); err != nil {
return "", err
}
chain, err := readInitializationMaterial(certificatePath, 1<<20, false)
if err != nil {
return "", err
}
key, err := readInitializationMaterial(privateKeyPath, 64<<10, true)
if err != nil {
return "", err
}
defer clear(key)
leaf, err := r.validateMaterial(ctx, chain, key)
if err != nil {
return "", err
}
if err := r.Proxy.Wait(ctx, leaf.Raw); err != nil {
return "", err
}
if err := r.Upstream.Wait(ctx, leaf.Raw); err != nil {
return "", err
}
if err := r.Reloader.Check(ctx); err != nil {
return "", fmt.Errorf("初始化前 Nginx 配置检查失败:%w", err)
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := requireUninitialized(root); err != nil {
return "", err
}
id, err := newID()
if err != nil {
return "", err
}
if err := root.Mkdir(id, 0o700); err != nil {
return "", err
}
if err := writeExclusive(root, filepath.Join(id, "fullchain.pem"), chain); err != nil {
return "", err
}
if err := writeExclusive(root, filepath.Join(id, "key.pem"), key); err != nil {
return "", err
}
version, err := root.Open(id)
if err != nil {
return "", err
}
if err := errors.Join(version.Sync(), version.Close()); err != nil {
return "", err
}
// 最后独占创建引用;断电或写入失败后的残留必须人工核对,不能自动覆盖重试。
if err := writeExclusive(root, includeName, renderInclude(r.Directory, id)); err != nil {
return "", err
}
directory, err := root.Open(".")
if err != nil {
return "", err
}
if err := errors.Join(directory.Sync(), directory.Close()); err != nil {
return "", err
}
return id, nil
}
func requireUninitialized(root *os.Root) error {
directory, err := root.Open(".")
if err != nil {
return err
}
defer directory.Close()
entries, err := directory.ReadDir(-1)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Name() != ".activation.lock" {
return errors.New("激活目录存在旧状态或初始化残留,拒绝覆盖,请先人工核对")
}
}
return nil
}
func readInitializationMaterial(path string, limit int64, private bool) ([]byte, error) {
if err := validateOwnedFile(path, uint32(os.Geteuid()), false); err != nil {
return nil, err
}
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return nil, err
}
defer root.Close()
if private {
info, err := root.Lstat(filepath.Base(path))
if err != nil {
return nil, err
}
if info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("初始化私钥不能允许组或其他用户读取")
}
}
return readRegular(root, filepath.Base(path), limit)
}

View File

@ -0,0 +1,189 @@
package nginxactivation
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/processlock"
"golang.org/x/sys/unix"
)
func initializationFixture(t *testing.T) (*rotationFixture, string, string) {
t.Helper()
f := newRotationFixture(t)
f.rotation.Directory = t.TempDir()
if err := os.Chmod(f.rotation.Directory, 0o700); err != nil {
t.Fatal(err)
}
material := t.TempDir()
chain, key := filepath.Join(material, "fullchain.pem"), filepath.Join(material, "key.pem")
writeTestFile(t, chain, f.old.chain)
writeTestFile(t, key, f.old.key)
return f, chain, key
}
func TestInitializeCreatesPrivateBaselineWithoutReload(t *testing.T) {
f, chain, key := initializationFixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
id, err := f.rotation.Initialize(ctx, chain, key)
if err != nil || !validID(id) {
t.Fatal("初始化失败或版本编号无效", err)
}
if f.reloader.reloadCount != 0 || f.reloader.checkCount != 1 {
t.Fatal("初始化只能检查既有配置,不能重载")
}
for path, mode := range map[string]os.FileMode{
id: 0o700, filepath.Join(id, "fullchain.pem"): 0o600,
filepath.Join(id, "key.pem"): 0o600, includeName: 0o600,
} {
info, err := os.Lstat(filepath.Join(f.rotation.Directory, path))
if err != nil || info.Mode().Perm() != mode {
t.Fatalf("初始化材料权限错误:%s,%v", path, err)
}
}
root, err := os.OpenRoot(f.rotation.Directory)
if err != nil {
t.Fatal(err)
}
defer root.Close()
include, err := readRegular(root, includeName, 4096)
if err != nil || !bytes.Equal(include, renderInclude(f.rotation.Directory, id)) {
t.Fatal("专用引用格式错误", err)
}
if err := verifyIncludeMaterial(root, include, f.old.pair.Certificate[0]); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(f.rotation.Directory, journalName)); !errors.Is(err, os.ErrNotExist) {
t.Fatal("初始化不应创建轮换事务", err)
}
if _, err := f.rotation.Initialize(ctx, chain, key); err == nil {
t.Fatal("不能重复初始化已有状态")
}
if err := requireInclude(root, include); err != nil {
t.Fatal("重复初始化改变了原引用", err)
}
}
func TestInitializeRejectsUnsafeMaterial(t *testing.T) {
for _, scenario := range []string{
"key-readable", "key-writable", "parent-writable", "symlink", "hardlink", "fifo",
"large", "mismatch", "different-leaf", "upstream-mismatch", "relative", "missing",
} {
t.Run(scenario, func(t *testing.T) {
f, chain, key := initializationFixture(t)
switch scenario {
case "key-readable", "key-writable", "parent-writable":
path, mode := key, os.FileMode(0o644)
if scenario == "key-writable" {
mode = 0o666
}
if scenario == "parent-writable" {
path, mode = filepath.Dir(key), 0o777
}
if err := os.Chmod(path, mode); err != nil {
t.Fatal(err)
}
case "symlink":
if err := os.Symlink(key, key+".link"); err != nil {
t.Fatal(err)
}
key += ".link"
case "hardlink":
if err := os.Link(key, key+".link"); err != nil {
t.Fatal(err)
}
case "fifo":
key += ".fifo"
if err := unix.Mkfifo(key, 0o600); err != nil {
t.Fatal(err)
}
case "large":
writeTestFile(t, chain, make([]byte, (1<<20)+1))
case "mismatch":
writeTestFile(t, key, f.candidate.key)
case "different-leaf":
writeTestFile(t, chain, f.candidate.chain)
writeTestFile(t, key, f.candidate.key)
case "upstream-mismatch":
f.upstream.active.Store(&f.candidate.pair)
case "relative":
key = "key.pem"
case "missing":
key += ".missing"
}
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
if _, err := f.rotation.Initialize(ctx, chain, key); err == nil {
t.Fatal("不应接受不可信或不一致的初始化材料")
}
if f.reloader.checkCount != 0 || f.reloader.reloadCount != 0 {
t.Fatal("材料预检失败不应执行 Nginx 命令")
}
assertInitializationEmpty(t, f.rotation.Directory)
})
}
}
func TestInitializePreservesExistingState(t *testing.T) {
for _, scenario := range []string{"include", "transaction", "partial", "external-change", "config-failure", "cancel", "locked"} {
t.Run(scenario, func(t *testing.T) {
f, chain, key := initializationFixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
name, content := "", []byte("保留原有状态\n")
switch scenario {
case "include", "transaction", "partial":
name = map[string]string{"include": includeName, "transaction": journalName, "partial": ".pending-old"}[scenario]
writeTestFile(t, filepath.Join(f.rotation.Directory, name), content)
case "external-change":
name = includeName
f.reloader.check = func(int) error {
writeTestFile(t, filepath.Join(f.rotation.Directory, name), content)
return nil
}
case "config-failure":
f.reloader.check = func(int) error { return errors.New("注入配置检查失败") }
case "cancel":
cancel()
case "locked":
lock, err := processlock.Acquire(filepath.Join(f.rotation.Directory, ".activation.lock"))
if err != nil {
t.Fatal(err)
}
defer lock.Close()
}
if _, err := f.rotation.Initialize(ctx, chain, key); err == nil {
t.Fatal("已有状态、取消或故障不应初始化成功")
}
if f.reloader.reloadCount != 0 {
t.Fatal("初始化不能重载 Nginx")
}
if name == "" {
assertInitializationEmpty(t, f.rotation.Directory)
return
}
actual, err := os.ReadFile(filepath.Join(f.rotation.Directory, name))
if err != nil || !bytes.Equal(actual, content) {
t.Fatal("初始化不能覆盖已有状态", err)
}
})
}
}
func assertInitializationEmpty(t *testing.T, directory string) {
t.Helper()
root, err := os.OpenRoot(directory)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := requireUninitialized(root); err != nil {
t.Fatal("预检失败不应留下证书或引用", err)
}
}

View File

@ -67,6 +67,6 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -OutputDirect
powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -Target linux-amd64 -SkipDesktop -OutputDirectory .\build\release-candidate-20260915
```
此命令不构建 Windows 或 Wails 桌面端,不代表跨平台发布验收。归档包含 `scert`、`scert-admin`、`scert-nginx`、Web 静态资源、安装器、systemd 模板和部署说明。解压后先运行 `sha256sum -c SHA256SUMS`,再按部署说明操作。`scert-nginx` 仅随包提供,普通安装器不会安装或启用它;启用前仍需单独完成 root 策略、本地 socket、Nginx 接线和实机验收。
此命令不构建 Windows 或 Wails 桌面端,不代表跨平台发布验收。归档包含 `scert`、`scert-admin`、`scert-nginx`、Web 静态资源、安装器、systemd 模板、`nginx/` 策略模板和部署说明。解压后先运行 `sha256sum -c SHA256SUMS`,再按部署说明操作。`scert-nginx` 仅随包提供,普通安装器不会安装或启用它;启用前按 `nginx/Nginx辅助服务部署说明.md` 单独完成 root 策略、安全初始化、本地 socket、Nginx 接线和实机验收。
构建通过不等于生产验收通过;真实 DNS、ACME、SSH、Windows Service、systemd、WebView2 和 HTTPS 仍需在授权环境复核。

View File

@ -284,6 +284,10 @@ foreach ($requiredDeploymentFile in @(
"README.md",
"systemd\Scert.service",
"systemd\Scert-admin.service",
"systemd\scert-nginx.socket",
"systemd\scert-nginx.service",
"nginx\policy.json.example",
"nginx\Nginx辅助服务部署说明.md",
"systemd\Scert服务管理说明.md",
"systemd\Scert-admin服务管理说明.md"
)) {
@ -394,6 +398,7 @@ try {
if ($goos -eq "linux") {
Copy-Item -LiteralPath (Join-Path $deploy "install-linux.sh") -Destination (Join-Path $targetDirectory "install-linux.sh")
Copy-Item -LiteralPath (Join-Path $deploy "systemd") -Destination (Join-Path $targetDirectory "systemd") -Recurse
Copy-Item -LiteralPath (Join-Path $deploy "nginx") -Destination (Join-Path $targetDirectory "nginx") -Recurse
}
else {
Copy-Item -LiteralPath (Join-Path $deploy "install-windows.ps1") -Destination (Join-Path $targetDirectory "install-windows.ps1")

View File

@ -21,6 +21,8 @@ deploy/
二进制文件应针对目标平台构建为 Linux amd64,并且具有可执行权限。
Linux 发布包还包含 `scert-nginx`、`systemd/scert-nginx.socket`、`systemd/scert-nginx.service` 和 `nginx/` 下的策略模板与中文说明。它们是独立、显式安装的管理证书激活组件,普通安装器不安装、不启用、不修改共享 Nginx。接线流程见 [Nginx 辅助服务部署说明](nginx/Nginx辅助服务部署说明.md),需要单独完成实机验证。
## 安装命令
```bash

View File

@ -0,0 +1,81 @@
# Nginx 辅助服务部署说明
本流程面向 Linux 的 root 维护人员,使用独立 Unix socket 完成管理证书双层切换。普通安装器不会安装或启用辅助服务。本模板尚待目标主机 systemd 与实际 Nginx 配置验证,不代表生产验收已通过。
## 部署前核对
- 先确认 SSH 主机身份、备份及维护窗口。检查目标站点、已有服务、端口、Nginx 主进程用户、二进制、主配置、PID、日志和缓存目录,不修改其他站点。
- 管理域名和公开端口由部署环境指定。策略只接受具体域名与两个不同的回环地址。Scert 应用的域名、代理公开端口、HTTPS 上游端口必须与策略一致。
- 代理和上游必须已提供同一张受系统 CA 信任、覆盖管理域名且未过期的证书,两个 `/readyz` 均成功。初始化不签发证书,不接受自签名证书,不绕过验证。
- 程序、策略、Nginx 主配置及全部 include、模块、PID 和可执行依赖须由 root 维护。不要让应用用户修改这些路径或任何父目录;不能把任意应用用户可写配置交给 root 的 `nginx -t` 或重载命令。
- 使用与核心程序配套的辅助程序版本,先核验发布包 `SHA256SUMS`。辅助程序必须安装在 `/usr/local/libexec/scert-nginx`,不能直接以 root 运行应用可写目录中的程序。
- 停止并禁止管理证书变更任务;首次接线期间两个服务仍须能响应就绪探测。辅助服务尚未接通时,应用的代理续期保护会拒绝新的签发。
## 首次文件安装
先核对以下路径均无符号链接、硬链接、非 root 写权限或既有安装。本节命令仅用于首次安装,不作为升级覆盖命令。目标已存在时停止,检查来源和内容,不删除旧状态。
```sh
install -d -o root -g root -m 0755 /usr/local/libexec
install -d -o root -g root -m 0700 /etc/scert-nginx
install -d -o root -g root -m 0700 /var/lib/scert-nginx
install -d -o root -g root -m 0755 /run/scert-nginx
test ! -e /usr/local/libexec/scert-nginx && test ! -L /usr/local/libexec/scert-nginx &&
install -o root -g root -m 0755 ./scert-nginx /usr/local/libexec/scert-nginx
test ! -e /etc/scert-nginx/policy.json && test ! -L /etc/scert-nginx/policy.json &&
install -o root -g root -m 0600 ./nginx/policy.json.example /etc/scert-nginx/policy.json
id -u Scert
```
使用编辑器填写 `policy.json` 的 `allowed_uid` 为实际非 root 数字 UID,填写域名与现场地址,并核实 Nginx 路径。默认 `allowed_uid=null` 和域名占位符故意不可用,不能原样加载。Nginx 前缀使用规范绝对目录,不带结尾斜杠。应用 socket 固定为 `/run/scert-nginx/activation.sock`,不得改为 TCP。
独立复制当前证书链和私钥到 root 控制的暂存目录,例如 `/etc/scert-nginx/initial/`,目录 0700、文件 0600。先核对实际来源是普通文件、内容属于当前管理证书,再复制;初始化器不直接读取应用用户可写路径或符号链接。不要把密钥内容放入命令行、日志或文档。
```sh
/usr/local/libexec/scert-nginx \
--policy /etc/scert-nginx/policy.json \
--initialize \
--certificate /etc/scert-nginx/initial/fullchain.pem \
--private-key /etc/scert-nginx/initial/key.pem
```
初始化会核对输入权限、可信证书链、代理与上游实际叶证书、就绪响应及当前 `nginx -t`。通过后,在专用目录内生成随机版本子目录及 `active.conf`,私钥和引用为 0600。它不修改站点、不重载 Nginx、不创建轮换事务。目标目录只允许已有锁文件;任何旧版本、事务或失败残留均拒绝覆盖,失败后先检查,不盲目重试或清空目录。
## 站点接线
保留原站点备份,只把当前管理站点的 `ssl_certificate` 和 `ssl_certificate_key` 两行替换为下面的引用,不在同一层保留重复指令:
```nginx
include /var/lib/scert-nginx/active.conf;
```
继续保留 `Scert.conf.example` 的域名、回环 HTTPS 上游、SNI 和 `proxy_ssl_verify on`。root Nginx 主进程可以读取 0700 专用目录,不给工作进程或应用用户开放私钥权限。非 root 主进程不适用本模板,不能靠放宽权限解决。
先执行策略指定的 Nginx 二进制、前缀和主配置的 `-t`,成功后由维护人员单次平滑重载,再严格验证代理和上游证书及 `/readyz`。对公网域名另行验证正常 DNS 与未强制 IP 的浏览器访问;回环探测不代表公网通过。接线失败时按备份恢复原站点并核验,保留专用目录供排查。
## 安装并启用通道
确认 `/etc/systemd/system/scert-nginx.socket`、`.service` 不存在,且 `/run/scert-nginx` 为 root 所有且不可由其他用户写入。安装发布包中的模板,不覆盖既有服务:
```sh
test ! -e /etc/systemd/system/scert-nginx.socket && test ! -L /etc/systemd/system/scert-nginx.socket &&
install -o root -g root -m 0644 ./systemd/scert-nginx.socket /etc/systemd/system/scert-nginx.socket
test ! -e /etc/systemd/system/scert-nginx.service && test ! -L /etc/systemd/system/scert-nginx.service &&
install -o root -g root -m 0644 ./systemd/scert-nginx.service /etc/systemd/system/scert-nginx.service
systemd-analyze verify /etc/systemd/system/scert-nginx.socket /etc/systemd/system/scert-nginx.service
systemctl daemon-reload
systemctl enable --now scert-nginx.socket
systemctl status scert-nginx.socket --no-pager
```
服务由 socket 激活,不设置应用的 sudo、root 用户或额外能力。socket 为 root 所有、Scert 组、0660;服务还以内核对端 UID 核对策略中的唯一调用者。运行目录由 socket 创建,服务停止不移除它,不借用应用所有的 `RuntimeDirectory`。
模板对文件系统只读,仅开放专用状态目录及常见 Nginx 日志、缓存和已有 PID 文件。`CAP_DAC_OVERRIDE` 用于读取可信配置引用的受限材料以及打开工作用户所有的 Nginx 日志,`CAP_CHOWN` 用于 Nginx 配置检查中的路径所有者检查;写入仍受只读挂载及上述有限可写路径约束。辅助服务依赖 `nginx.service`,随其停止或重启,以便重建 PID 文件的隔离挂载;现场 Nginx 单元名不同时须同步调整 `Requires`、`After` 和 `PartOf`。非标准日志、缓存或 PID 路径须按现场最小范围评审 drop-in,不能直接放开 `/etc`、`/run` 或整个文件系统。不要使用私有网络命名空间,否则无法探测正式回环上游。
先完成隔离 systemd 测试,再验证真实调用 UID、非授权 UID 拒绝、辅助服务重启、主机重启、证书轮换、故障恢复和其他站点不受影响。仅有 socket 活跃、`nginx -t` 或核心 `/readyz` 成功都不等于双层协调验收。当前应用尚无独立管理协调就绪命令,完整流程仍须通过实际续期任务和两层证书证据核对。
## 升级与恢复
升级前等待应用协调与辅助事务均结束,停止相关变更任务,再停止 socket 和辅助服务。检查它们均已停止后才替换 root 所有的辅助程序,保留旧程序及校验摘要;不重复初始化、不清空版本或请求记录,重新启动 socket 后复验。
辅助程序状态与应用协调记录必须成套备份。现有应用完整备份不包含 `/etc/scert-nginx`、`/var/lib/scert-nginx` 和 Nginx 站点配置,不能声称只恢复应用备份就完成双层灾难恢复。停止变更并核对终态后,另行备份这些 root 文件、服务模板与 include 依赖;恢复时核对 UID、策略、应用证书路径、两层实际证书和事务记录一致。任何未决或损坏记录均保持保护,交由维护人员检查,不通过删除记录解除保护。

View File

@ -0,0 +1,12 @@
{
"version": 1,
"allowed_uid": null,
"socket_path": "/run/scert-nginx/activation.sock",
"directory": "/var/lib/scert-nginx",
"hostname": "__MANAGEMENT_HOST__",
"proxy_address": "127.0.0.1:443",
"upstream_address": "127.0.0.1:9004",
"nginx_binary": "/usr/sbin/nginx",
"nginx_config": "/etc/nginx/nginx.conf",
"nginx_prefix": "/etc/nginx"
}

View File

@ -0,0 +1,38 @@
[Unit]
Description=Scert Nginx 受限证书激活服务
Requires=scert-nginx.socket nginx.service
PartOf=nginx.service
After=scert-nginx.socket nginx.service
ConditionPathExists=/etc/scert-nginx/policy.json
[Service]
Type=simple
User=root
Group=root
ExecStart=/usr/local/libexec/scert-nginx --policy /etc/scert-nginx/policy.json
UMask=0077
TimeoutStopSec=40
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_CHOWN
AmbientCapabilities=
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectHostname=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# Nginx 配置检查会打开日志和 PID;非标准部署须按实际路径单独评审。
ReadWritePaths=/var/lib/scert-nginx -/var/log/nginx -/var/lib/nginx -/var/cache/nginx -/run/nginx.pid

View File

@ -0,0 +1,15 @@
[Unit]
Description=Scert Nginx 本地激活通道
[Socket]
ListenStream=/run/scert-nginx/activation.sock
Accept=no
SocketUser=root
SocketGroup=Scert
SocketMode=0660
DirectoryMode=0755
RemoveOnStop=no
Service=scert-nginx.service
[Install]
WantedBy=sockets.target