diff --git a/backend/internal/nginxactivation/command_linux.go b/backend/internal/nginxactivation/command_linux.go new file mode 100644 index 0000000..fcc06e8 --- /dev/null +++ b/backend/internal/nginxactivation/command_linux.go @@ -0,0 +1,40 @@ +package nginxactivation + +import ( + "context" + "errors" + "os/exec" + "path/filepath" + "time" +) + +// CommandReloader 的参数来自本地受信策略,证书材料不能指定命令或参数。 +type CommandReloader struct { + Binary string + Config string + Prefix string +} + +func (r CommandReloader) Check(ctx context.Context) error { + return r.run(ctx, "-t") +} + +func (r CommandReloader) Reload(ctx context.Context) error { + return r.run(ctx, "-s", "reload") +} + +func (r CommandReloader) run(ctx context.Context, operation ...string) error { + if !filepath.IsAbs(r.Binary) || !filepath.IsAbs(r.Config) || !filepath.IsAbs(r.Prefix) { + return errors.New("Nginx 命令策略必须使用绝对路径") + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + arguments := append([]string{"-p", r.Prefix, "-c", r.Config}, operation...) + command := exec.CommandContext(ctx, r.Binary, arguments...) + command.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C"} + // 不向应用返回可能包含站点配置和文件内容的子进程输出。 + if err := command.Run(); err != nil { + return errors.Join(errors.New("Nginx 受限命令执行失败"), err, ctx.Err()) + } + return nil +} diff --git a/backend/internal/nginxactivation/files.go b/backend/internal/nginxactivation/files.go new file mode 100644 index 0000000..52c430e --- /dev/null +++ b/backend/internal/nginxactivation/files.go @@ -0,0 +1,103 @@ +//go:build linux + +package nginxactivation + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +func readRegular(root *os.Root, name string, limit int64) ([]byte, error) { + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > limit { + return nil, errors.New("激活状态必须是大小受限的普通文件") + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(data)) > limit { + return nil, errors.Join(errors.New("读取激活状态失败"), err) + } + return data, nil +} + +func writeExclusive(root *os.Root, name string, data []byte) error { + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, writeErr := file.Write(data) + syncErr := file.Sync() + return errors.Join(writeErr, syncErr, file.Close()) +} + +func writeAtomic(root *os.Root, name string, data []byte) error { + if info, err := root.Lstat(name); err == nil { + if !info.Mode().IsRegular() { + return errors.New("拒绝覆盖非常规激活状态文件") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + id, err := newID() + if err != nil { + return err + } + // 失败的临时文件保留供诊断,不覆盖已有快照。 + temporary := ".pending-" + id + if err := writeExclusive(root, temporary, data); err != nil { + return err + } + if err := root.Rename(temporary, name); err != nil { + return err + } + directory, err := root.Open(".") + if err != nil { + return err + } + return errors.Join(directory.Sync(), directory.Close()) +} + +func newID() (string, error) { + var data [16]byte + if _, err := rand.Read(data[:]); err != nil { + return "", err + } + return hex.EncodeToString(data[:]), nil +} + +func validateDirectory(directory string) error { + if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory || directory == string(filepath.Separator) { + return errors.New("Nginx 激活目录必须是独立的绝对目录") + } + // 目录来自本地部署策略,仍拒绝可改变 Nginx 指令语义的字符。 + for _, char := range directory { + if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '/' || char == '-' || char == '_' || char == '.') { + return errors.New("Nginx 激活目录包含不允许的字符") + } + } + for path := directory; ; path = filepath.Dir(path) { + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("激活目录及其父路径必须是真实目录:%w", err) + } + if path == directory && info.Mode().Perm()&0o077 != 0 { + return errors.New("激活目录只能由激活器所属用户访问") + } + if filepath.Dir(path) == path { + break + } + } + return nil +} diff --git a/backend/internal/nginxactivation/probe.go b/backend/internal/nginxactivation/probe.go new file mode 100644 index 0000000..7f01116 --- /dev/null +++ b/backend/internal/nginxactivation/probe.go @@ -0,0 +1,114 @@ +package nginxactivation + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// Endpoint 只允许探测本机指定端口,域名仍用于 SNI、Host 和可信链校验。 +type Endpoint struct { + Address string + Hostname string + Roots *x509.CertPool +} + +func (e Endpoint) validate() error { + host, port, err := net.SplitHostPort(e.Address) + number, portErr := strconv.Atoi(port) + ip := net.ParseIP(host) + if err != nil || portErr != nil || number < 1 || number > 65535 || ip == nil || !ip.IsLoopback() { + return errors.New("管理证书探测地址必须是本机回环 IP 和有效端口") + } + if !domain.IsValidName(e.Hostname) || net.ParseIP(e.Hostname) != nil { + return errors.New("管理证书探测域名无效") + } + return nil +} + +// Check 每次建立新 TLS 连接,不复用连接、不使用系统代理、不跟随跳转。 +// expected 为空时读取已验证的当前证书,否则同时校验实际叶证书字节。 +func (e Endpoint) Check(ctx context.Context, expected []byte) ([]byte, error) { + if err := e.validate(); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + _, port, _ := net.SplitHostPort(e.Address) + transport := &http.Transport{ + Proxy: nil, DisableKeepAlives: true, DisableCompression: true, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: e.Hostname, RootCAs: e.Roots}, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", e.Address) + }, + } + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + address := url.URL{Scheme: "https", Host: net.JoinHostPort(e.Hostname, port), Path: "/readyz"} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.String(), nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("管理入口 TLS 探测失败:%w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.TLS == nil || len(response.TLS.VerifiedChains) == 0 || len(response.TLS.PeerCertificates) == 0 { + return nil, errors.New("管理入口未通过 HTTPS 就绪检查") + } + actual := response.TLS.PeerCertificates[0].Raw + if len(expected) != 0 && !bytes.Equal(expected, actual) { + return nil, errors.New("管理入口仍未提供预期的新证书") + } + body, err := io.ReadAll(io.LimitReader(response.Body, 4097)) + if err != nil || len(body) > 4096 { + return nil, errors.New("管理入口就绪响应读取失败或超过限制") + } + var readiness struct { + Service string `json:"service"` + Status string `json:"status"` + } + if err := json.Unmarshal(body, &readiness); err != nil || readiness.Service != "scert" || readiness.Status != "ready" { + return nil, errors.New("管理入口未返回 Scert 就绪状态") + } + return bytes.Clone(actual), nil +} + +func (e Endpoint) Wait(ctx context.Context, expected []byte) error { + if len(expected) == 0 { + return errors.New("等待管理入口切换时必须指定证书") + } + if err := e.validate(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + for { + _, err := e.Check(ctx, expected) + if err == nil { + return nil + } + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return errors.Join(ctx.Err(), err) + case <-timer.C: + } + } +} diff --git a/backend/internal/nginxactivation/rotation_linux.go b/backend/internal/nginxactivation/rotation_linux.go new file mode 100644 index 0000000..4fc40d5 --- /dev/null +++ b/backend/internal/nginxactivation/rotation_linux.go @@ -0,0 +1,291 @@ +package nginxactivation + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/processlock" +) + +const ( + includeName = "active.conf" + journalName = "transaction.json" +) + +type Reloader interface { + Check(context.Context) error + Reload(context.Context) error +} + +// Rotation 的目录与命令必须由本地受信部署策略提供,不能来自 HTTP 或证书请求。 +// 调用方须先安装引用 active.conf 的专用站点,并将目录交给独立激活器用户。 +type Rotation struct { + Directory string + Proxy Endpoint + Upstream Endpoint + Reloader Reloader +} + +type transaction struct { + ID string `json:"id"` + Status string `json:"status"` + PreviousInclude []byte `json:"previous_include"` + CandidateInclude []byte `json:"candidate_include"` + PreviousLeaf []byte `json:"previous_leaf"` + CandidateLeaf []byte `json:"candidate_leaf"` +} + +// Stage 只切换 Nginx 并验证入口;上游切换后必须调用 Commit 才算双层激活完成。 +// 任意中断均保留持久化记录,下一次启动应先调用 Recover。 +func (r Rotation) Stage(ctx context.Context, chain, key []byte) (id string, resultErr error) { + root, lock, err := r.open(ctx) + if err != nil { + return "", err + } + defer root.Close() + defer lock.Close() + previous, err := readTransaction(root) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err == nil && (previous.Status == "pending" || previous.Status == "staged") { + return "", errors.New("仍有未完成的管理证书激活,请先恢复") + } + leaf, err := r.validateMaterial(ctx, chain, key) + if err != nil { + return "", err + } + oldLeaf, err := r.Proxy.Check(ctx, nil) + if err != nil { + return "", err + } + oldCertificate, err := x509.ParseCertificate(oldLeaf) + if err != nil || !leaf.NotAfter.After(oldCertificate.NotAfter) { + return "", errors.New("候选管理证书没有延长入口实际有效期") + } + if _, err := r.Upstream.Check(ctx, nil); err != nil { + return "", err + } + oldInclude, err := readRegular(root, includeName, 4096) + if err != nil { + return "", err + } + if err := r.Reloader.Check(ctx); err != nil { + return "", fmt.Errorf("Nginx 原配置检查失败:%w", 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 + } + // 先同步新版本目录,再持久化引用,避免断电后只留下不存在的证书路径。 + versionDir, err := root.Open(id) + if err != nil { + return "", err + } + if err := errors.Join(versionDir.Sync(), versionDir.Close()); err != nil { + return "", err + } + current := transaction{ + ID: id, Status: "pending", PreviousInclude: oldInclude, PreviousLeaf: oldLeaf, + CandidateLeaf: leaf.Raw, + CandidateInclude: []byte(fmt.Sprintf("ssl_certificate %s/%s/fullchain.pem;\nssl_certificate_key %s/%s/key.pem;\n", r.Directory, id, r.Directory, id)), + } + if err := saveTransaction(root, current); err != nil { + return "", err + } + defer func() { + if resultErr != nil { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 25*time.Second) + defer cancel() + resultErr = errors.Join(resultErr, r.rollback(rollbackCtx, root, current)) + } + }() + if err := writeAtomic(root, includeName, current.CandidateInclude); err != nil { + return id, err + } + if err := r.Reloader.Check(ctx); err != nil { + return id, fmt.Errorf("Nginx 候选配置检查失败:%w", err) + } + if err := r.Reloader.Reload(ctx); err != nil { + return id, fmt.Errorf("Nginx 重载失败:%w", err) + } + if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil { + return id, err + } + current.Status = "staged" + return id, saveTransaction(root, current) +} + +// Commit 验证代理和上游均提供同一候选证书且就绪;失败时保留事务供协调者回退。 +func (r Rotation) Commit(ctx context.Context, id string) error { + root, lock, err := r.open(ctx) + if err != nil { + return err + } + defer root.Close() + defer lock.Close() + current, err := readTransaction(root) + if err != nil { + return err + } + if id == "" || current.ID != id || current.Status != "staged" { + return errors.New("管理证书激活事务不匹配或尚未完成暂存") + } + include, err := readRegular(root, includeName, 4096) + if err != nil || !bytes.Equal(include, current.CandidateInclude) { + return errors.New("管理证书专用引用已被修改,拒绝提交") + } + if err := r.Upstream.Wait(ctx, current.CandidateLeaf); err != nil { + return err + } + if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil { + return err + } + current.Status = "committed" + return saveTransaction(root, current) +} + +func (r Rotation) Rollback(ctx context.Context, id string) error { + return r.restore(ctx, id, false) +} + +// Recover 处理进程退出时残留的事务,不回退已经提交的双层证书。 +func (r Rotation) Recover(ctx context.Context) error { + return r.restore(ctx, "", true) +} + +func (r Rotation) restore(ctx context.Context, id string, recoverOnly bool) error { + root, lock, err := r.open(ctx) + if err != nil { + return err + } + defer root.Close() + defer lock.Close() + current, err := readTransaction(root) + if errors.Is(err, os.ErrNotExist) && recoverOnly { + return nil + } + if err != nil { + return err + } + if recoverOnly && (current.Status == "committed" || current.Status == "rolled_back") { + return nil + } + if !recoverOnly && (id == "" || current.ID != id) { + return errors.New("管理证书回退事务不匹配") + } + if current.Status == "committed" { + return errors.New("已提交事务不能通过临时激活回退接口恢复") + } + return r.rollback(ctx, root, current) +} + +func (r Rotation) rollback(ctx context.Context, root *os.Root, current transaction) error { + active, err := readRegular(root, includeName, 4096) + if err != nil { + return err + } + if !bytes.Equal(active, current.CandidateInclude) && !bytes.Equal(active, current.PreviousInclude) { + return errors.New("Nginx 证书引用发生外部变化,拒绝回退覆盖") + } + if err := writeAtomic(root, includeName, current.PreviousInclude); err != nil { + return fmt.Errorf("恢复 Nginx 证书引用失败:%w", err) + } + if err := r.Reloader.Check(ctx); err != nil { + return fmt.Errorf("Nginx 回退配置检查失败:%w", err) + } + if err := r.Reloader.Reload(ctx); err != nil { + return fmt.Errorf("Nginx 回退重载失败:%w", err) + } + if err := r.Proxy.Wait(ctx, current.PreviousLeaf); err != nil { + return fmt.Errorf("Nginx 回退就绪检查失败:%w", err) + } + current.Status = "rolled_back" + return saveTransaction(root, current) +} + +func (r Rotation) validateMaterial(ctx context.Context, chain, key []byte) (*x509.Certificate, error) { + if len(chain) == 0 || len(chain) > 1<<20 || len(key) == 0 || len(key) > 64<<10 { + return nil, errors.New("候选证书或私钥大小无效") + } + pair, err := tls.X509KeyPair(chain, key) + if err != nil { + return nil, errors.New("候选证书和私钥不匹配") + } + validator := certificate.LocalValidator{VerifyChain: true, Roots: r.Proxy.Roots} + if _, err := validator.ValidatePEM(ctx, chain, []string{r.Proxy.Hostname}); err != nil { + return nil, fmt.Errorf("候选证书未通过可信校验:%w", err) + } + if len(pair.Certificate[0]) > 16<<10 { + return nil, errors.New("候选叶证书超过大小限制") + } + return x509.ParseCertificate(pair.Certificate[0]) +} + +func (r Rotation) open(ctx context.Context) (*os.Root, *processlock.Lock, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if r.Reloader == nil || r.Proxy.Address == r.Upstream.Address || r.Proxy.Hostname != r.Upstream.Hostname { + return nil, nil, errors.New("Nginx 激活策略必须配置独立代理、上游和重载器") + } + if err := errors.Join(r.Proxy.validate(), r.Upstream.validate(), validateDirectory(r.Directory)); err != nil { + return nil, nil, err + } + lock, err := processlock.Acquire(filepath.Join(r.Directory, ".activation.lock")) + if err != nil { + return nil, nil, err + } + root, err := os.OpenRoot(r.Directory) + if err != nil { + lock.Close() + return nil, nil, err + } + return root, lock, nil +} + +func readTransaction(root *os.Root) (transaction, error) { + var current transaction + data, err := readRegular(root, journalName, 64<<10) + if err != nil { + return current, err + } + if err := json.Unmarshal(data, ¤t); err != nil { + return current, err + } + if current.ID == "" || len(current.PreviousInclude) == 0 || len(current.CandidateInclude) == 0 || len(current.PreviousLeaf) == 0 || len(current.CandidateLeaf) == 0 { + return current, errors.New("管理证书激活记录不完整") + } + switch current.Status { + case "pending", "staged", "committed", "rolled_back": + return current, nil + default: + return current, errors.New("管理证书激活记录状态无效") + } +} + +func saveTransaction(root *os.Root, current transaction) error { + data, err := json.Marshal(current) + if err != nil { + return err + } + return writeAtomic(root, journalName, data) +}