diff --git a/backend/cmd/scert-nginx/main_linux.go b/backend/cmd/scert-nginx/main_linux.go index a394a47..2ae0170 100644 --- a/backend/cmd/scert-nginx/main_linux.go +++ b/backend/cmd/scert-nginx/main_linux.go @@ -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) } diff --git a/backend/cmd/scert-nginx/main_linux_test.go b/backend/cmd/scert-nginx/main_linux_test.go index a5c707b..fb7c791 100644 --- a/backend/cmd/scert-nginx/main_linux_test.go +++ b/backend/cmd/scert-nginx/main_linux_test.go @@ -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())) diff --git a/backend/internal/admin/nginx_units_test.go b/backend/internal/admin/nginx_units_test.go new file mode 100644 index 0000000..20c98e5 --- /dev/null +++ b/backend/internal/admin/nginx_units_test.go @@ -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 和域名,并匹配专用目录") + } +} diff --git a/backend/internal/nginxactivation/README.md b/backend/internal/nginxactivation/README.md index aca7f1b..06d474a 100644 --- a/backend/internal/nginxactivation/README.md +++ b/backend/internal/nginxactivation/README.md @@ -36,6 +36,8 @@ ## 接入前要求 +发布包现提供独立 socket/service、拒绝直接使用的策略模板和 `--initialize` 命令。初始化从 root 受保护副本导入当前证书,核对双层 TLS 与就绪状态,仅创建专用材料,不修改或重载站点;已有状态或失败残留拒绝覆盖。部署步骤见 `deploy/nginx/Nginx辅助服务部署说明.md`,新增初始化用例尚待 Linux 执行,模板尚未完成 systemd 实机验收。 + 1. 安装独立 socket/service,固定调用 UID,初始化专用版本目录并让目标站点引用 `active.conf`。 2. 应用须持久化旧状态、候选摘要、激活阶段和关联事务;完整生命周期必须串行化,不能在恢复检查上游之后并发切换上游。 3. 覆盖代理暂存、上游配置持久化与内存切换、代理提交之间的中断及应答丢失,并在启动时先恢复再允许申请下一张证书。 diff --git a/backend/internal/nginxactivation/initialize_linux.go b/backend/internal/nginxactivation/initialize_linux.go new file mode 100644 index 0000000..ca83093 --- /dev/null +++ b/backend/internal/nginxactivation/initialize_linux.go @@ -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) +} diff --git a/backend/internal/nginxactivation/initialize_linux_test.go b/backend/internal/nginxactivation/initialize_linux_test.go new file mode 100644 index 0000000..577b883 --- /dev/null +++ b/backend/internal/nginxactivation/initialize_linux_test.go @@ -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) + } +} diff --git a/build/README.md b/build/README.md index a5c8223..c4032aa 100644 --- a/build/README.md +++ b/build/README.md @@ -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 仍需在授权环境复核。 diff --git a/build/build-release.ps1 b/build/build-release.ps1 index d40af37..6e516aa 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -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") diff --git a/deploy/README.md b/deploy/README.md index 0e90c96..33c581e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/deploy/nginx/Nginx辅助服务部署说明.md b/deploy/nginx/Nginx辅助服务部署说明.md new file mode 100644 index 0000000..4223035 --- /dev/null +++ b/deploy/nginx/Nginx辅助服务部署说明.md @@ -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、策略、应用证书路径、两层实际证书和事务记录一致。任何未决或损坏记录均保持保护,交由维护人员检查,不通过删除记录解除保护。 diff --git a/deploy/nginx/policy.json.example b/deploy/nginx/policy.json.example new file mode 100644 index 0000000..f4eebdf --- /dev/null +++ b/deploy/nginx/policy.json.example @@ -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" +} diff --git a/deploy/systemd/scert-nginx.service b/deploy/systemd/scert-nginx.service new file mode 100644 index 0000000..fcc0111 --- /dev/null +++ b/deploy/systemd/scert-nginx.service @@ -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 diff --git a/deploy/systemd/scert-nginx.socket b/deploy/systemd/scert-nginx.socket new file mode 100644 index 0000000..fc88769 --- /dev/null +++ b/deploy/systemd/scert-nginx.socket @@ -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