559 lines
19 KiB
Go
559 lines
19 KiB
Go
// Package nginxgateway installs the single-domain HTTP-01 and HTTPS gateway.
|
|
// It refuses to change hosts with unrelated enabled Nginx sites.
|
|
package nginxgateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"vpn-platform/backend/internal/nginxconfig"
|
|
"vpn-platform/backend/internal/nginxsetup"
|
|
"vpn-platform/backend/internal/tlsservice"
|
|
)
|
|
|
|
const (
|
|
siteDir = "/etc/nginx/sites-enabled"
|
|
sitePath = siteDir + "/opsx-vpn.conf"
|
|
defaultPath = siteDir + "/default"
|
|
defaultBackup = "/etc/nginx/opsx-vpn-default.disabled"
|
|
hookPath = "/etc/letsencrypt/renewal-hooks/deploy/opsx-vpn-nginx"
|
|
)
|
|
|
|
var running atomic.Bool
|
|
|
|
var managedDomain = regexp.MustCompile(`(?m)^\s*server_name ([a-z0-9.-]+);$`)
|
|
var managedHTTPPort = regexp.MustCompile(`(?m)^\s*listen ([0-9]+) default_server;$`)
|
|
var managedHTTPSPort = regexp.MustCompile(`(?m)^\s*listen ([0-9]+) ssl default_server;$`)
|
|
|
|
// ManagedEntry reads the actual public address from this application's Nginx
|
|
// site. It does not trust a persisted draft when the two differ.
|
|
func ManagedEntry() (nginxconfig.RenderOptions, error) {
|
|
data, err := os.ReadFile(sitePath)
|
|
if err != nil {
|
|
return nginxconfig.RenderOptions{}, err
|
|
}
|
|
return parseManagedEntry(string(data))
|
|
}
|
|
|
|
func parseManagedEntry(body string) (nginxconfig.RenderOptions, error) {
|
|
scheme := "http"
|
|
portPattern := managedHTTPPort
|
|
if strings.Contains(body, "# Add only after the certificate exists.") {
|
|
scheme, portPattern = "https", managedHTTPSPort
|
|
} else if !strings.Contains(body, "# Plain HTTP public entry") {
|
|
return nginxconfig.RenderOptions{}, errors.New("站点不是 opsx-vpn 托管配置")
|
|
}
|
|
domains := managedDomain.FindAllStringSubmatch(body, -1)
|
|
ports := portPattern.FindStringSubmatch(body)
|
|
if len(domains) == 0 || len(ports) != 2 || !tlsservice.ValidPublicHost(scheme, domains[0][1]) {
|
|
return nginxconfig.RenderOptions{}, errors.New("无法识别托管站点的公网地址")
|
|
}
|
|
for _, match := range domains[1:] {
|
|
if match[1] != domains[0][1] {
|
|
return nginxconfig.RenderOptions{}, errors.New("托管站点包含多个不同域名")
|
|
}
|
|
}
|
|
port, err := strconv.Atoi(ports[1])
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return nginxconfig.RenderOptions{}, errors.New("托管站点端口无效")
|
|
}
|
|
return nginxconfig.RenderOptions{Domain: domains[0][1], Scheme: scheme, Port: port}, nil
|
|
}
|
|
|
|
func Begin() bool { return running.CompareAndSwap(false, true) }
|
|
func End() { running.Store(false) }
|
|
func Running() bool { return running.Load() }
|
|
|
|
// Stage reports only the managed configuration state, not public reachability.
|
|
func Stage() string {
|
|
data, err := os.ReadFile(sitePath)
|
|
if err != nil {
|
|
return "none"
|
|
}
|
|
body := string(data)
|
|
if strings.Contains(body, "# Add only after the certificate exists.") {
|
|
return "https"
|
|
}
|
|
if strings.Contains(body, "# Plain HTTP public entry") || strings.Contains(body, "server_name ") {
|
|
return "http"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
// AuthRouteReady reflects the installed managed site, not actual Feishu reachability.
|
|
func AuthRouteReady(port int) (bool, bool) {
|
|
data, err := os.ReadFile(sitePath)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
return strings.Contains(string(data), "location = /id/.well-known/openid-configuration"),
|
|
port > 0 && strings.Contains(string(data), fmt.Sprintf("proxy_pass http://127.0.0.1:%d;", port))
|
|
}
|
|
|
|
// RefreshAuth updates only the managed site after validation; a bad config is
|
|
// rolled back before returning. It never provisions a certificate or domain.
|
|
func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error {
|
|
status := tlsservice.Status()
|
|
scheme := status.Scheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
return RefreshAuthEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: scheme, Port: status.Port}, routes, authPort)
|
|
}
|
|
|
|
func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int) error {
|
|
return refreshEntry(opts, routes, authPort, false, "", nil)
|
|
}
|
|
|
|
// UpdateEntry applies a validated public entry to an existing managed site.
|
|
// persist is called after a successful reload; failure restores the old site.
|
|
func UpdateEntry(opts nginxconfig.RenderOptions, expectedDomain string, routes gin.RoutesInfo, authPort int, persist func() error) error {
|
|
if !Begin() {
|
|
return errors.New("公网入口操作正在进行")
|
|
}
|
|
defer End()
|
|
return refreshEntry(opts, routes, authPort, true, expectedDomain, persist)
|
|
}
|
|
|
|
func refreshEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int, allowChange bool, expectedDomain string, persist func() error) error {
|
|
if Running() && !allowChange {
|
|
return errors.New("公网入口操作正在进行")
|
|
}
|
|
scheme := strings.ToLower(strings.TrimSpace(opts.Scheme))
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
opts.Scheme = scheme
|
|
if scheme == "http" {
|
|
if err := checkSitesHTTP(); err != nil {
|
|
return err
|
|
}
|
|
} else if err := checkSites(); err != nil {
|
|
return err
|
|
}
|
|
stage := Stage()
|
|
if scheme == "https" && stage != "https" && !allowChange {
|
|
return errors.New("请先启用 HTTPS 站点")
|
|
}
|
|
if scheme == "http" && stage == "none" {
|
|
return errors.New("请先启用公网入口站点")
|
|
}
|
|
previous, err := os.ReadFile(sitePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if allowChange && !strings.Contains(string(previous), "# Install this HTTP site first.") && !strings.Contains(string(previous), "# Plain HTTP public entry") {
|
|
return errors.New("现有站点不是 opsx-vpn 托管配置,拒绝覆盖")
|
|
}
|
|
if allowChange && (expectedDomain == "" || !strings.Contains(string(previous), "server_name "+expectedDomain+";")) {
|
|
return errors.New("现有站点域名与运行配置不匹配,拒绝覆盖")
|
|
}
|
|
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
|
|
if !allowChange && !strings.Contains(string(previous), "server_name "+domain+";") {
|
|
return errors.New("现有站点域名与配置不匹配")
|
|
}
|
|
if allowChange && scheme == "https" {
|
|
fullchain, privkey, _ := tlsservice.CertificateFiles(domain)
|
|
pair, err := tls.LoadX509KeyPair(fullchain, privkey)
|
|
if err != nil || len(pair.Certificate) == 0 {
|
|
return errors.New("目标域名没有可用的 HTTPS 证书,请先准备证书")
|
|
}
|
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
|
if err != nil || leaf.VerifyHostname(domain) != nil || time.Now().Before(leaf.NotBefore) || !time.Now().Before(leaf.NotAfter) {
|
|
return errors.New("目标域名证书无效或已过期")
|
|
}
|
|
}
|
|
if allowChange {
|
|
listenPort := tlsservice.EffectivePort(scheme, opts.Port)
|
|
portAlreadyManaged := strings.Contains(string(previous), fmt.Sprintf("listen %d;", listenPort)) ||
|
|
strings.Contains(string(previous), fmt.Sprintf("listen %d ssl", listenPort))
|
|
if !portAlreadyManaged {
|
|
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", listenPort))
|
|
if err != nil {
|
|
return fmt.Errorf("目标端口 %d 已占用: %w", listenPort, err)
|
|
}
|
|
listener.Close()
|
|
}
|
|
}
|
|
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(previous) == httpSite+httpsSite {
|
|
if persist != nil {
|
|
return persist()
|
|
}
|
|
return nil
|
|
}
|
|
validate := func() error { return command(func(string) {}, 15*time.Second, "nginx", "-t") }
|
|
reload := func() error { return command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx") }
|
|
return swapManagedSite(string(previous), httpSite+httpsSite, persist, writeSite, validate, reload)
|
|
}
|
|
|
|
func swapManagedSite(previous, next string, persist func() error, write func(string) error, validate, reload func() error) error {
|
|
if err := write(next); err != nil {
|
|
return err
|
|
}
|
|
rollback := func(cause error) error {
|
|
if err := write(previous); err != nil {
|
|
return fmt.Errorf("%w;恢复旧站点失败: %v", cause, err)
|
|
}
|
|
if err := validate(); err != nil {
|
|
return fmt.Errorf("%w;旧站点配置校验失败: %v", cause, err)
|
|
}
|
|
if err := reload(); err != nil {
|
|
return fmt.Errorf("%w;恢复旧站点重载失败: %v", cause, err)
|
|
}
|
|
return cause
|
|
}
|
|
if err := validate(); err != nil {
|
|
return rollback(err)
|
|
}
|
|
if err := reload(); err != nil {
|
|
return rollback(err)
|
|
}
|
|
if persist != nil {
|
|
if err := persist(); err != nil {
|
|
return rollback(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func command(emit func(string), timeout time.Duration, name string, args ...string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if len(out) > 0 {
|
|
if len(out) > 16000 {
|
|
out = out[len(out)-16000:]
|
|
}
|
|
emit(string(out))
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s 失败: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeSite(content string) error {
|
|
f, err := os.CreateTemp(siteDir, ".opsx-vpn-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
defer f.Close()
|
|
if err = f.Chmod(0o644); err != nil {
|
|
return err
|
|
}
|
|
if _, err = f.WriteString(content); err != nil {
|
|
return err
|
|
}
|
|
if err = f.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(f.Name(), sitePath)
|
|
}
|
|
|
|
func checkSitesHTTP() error {
|
|
if os.Geteuid() != 0 {
|
|
return errors.New("配置入口需要 root 权限")
|
|
}
|
|
state := nginxsetup.Inspect()
|
|
if !state.Nginx {
|
|
return errors.New("请先安装 Nginx")
|
|
}
|
|
entries, err := os.ReadDir(siteDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() != "default" && e.Name() != "opsx-vpn.conf" {
|
|
return fmt.Errorf("发现其他启用站点 %s,请先由管理员检查冲突", e.Name())
|
|
}
|
|
}
|
|
if entries, err := os.ReadDir("/etc/nginx/conf.d"); err == nil {
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".conf") {
|
|
return fmt.Errorf("发现其他 Nginx 配置 %s,请先由管理员检查冲突", e.Name())
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkSites() error {
|
|
if os.Geteuid() != 0 {
|
|
return errors.New("配置入口需要 root 权限")
|
|
}
|
|
state := nginxsetup.Inspect()
|
|
if !state.Nginx || !state.Certbot || !state.Webroot {
|
|
return errors.New("请先准备 Nginx / Certbot 和 HTTP-01 验证目录")
|
|
}
|
|
entries, err := os.ReadDir(siteDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() != "default" && e.Name() != "opsx-vpn.conf" {
|
|
return fmt.Errorf("发现其他启用站点 %s,请先由管理员检查冲突", e.Name())
|
|
}
|
|
}
|
|
if entries, err := os.ReadDir("/etc/nginx/conf.d"); err == nil {
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".conf") {
|
|
return fmt.Errorf("发现其他 Nginx 配置 %s,请先由管理员检查冲突", e.Name())
|
|
}
|
|
}
|
|
}
|
|
if info, err := os.Lstat(defaultPath); err == nil {
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return errors.New("默认站点不是发行版软链接,拒绝更改")
|
|
}
|
|
target, err := os.Readlink(defaultPath)
|
|
if err != nil || target != "/etc/nginx/sites-available/default" {
|
|
return errors.New("默认站点来源未知,拒绝更改")
|
|
}
|
|
if _, err = os.Lstat(defaultBackup); err == nil {
|
|
return errors.New("默认站点备份已存在,请人工检查")
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func certificateExists(domain string) bool {
|
|
fullchain, privkey, _ := tlsservice.CertificateFiles(domain)
|
|
pair, err := tls.LoadX509KeyPair(fullchain, privkey)
|
|
if err != nil || len(pair.Certificate) == 0 {
|
|
return false
|
|
}
|
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
|
return err == nil && leaf.VerifyHostname(domain) == nil &&
|
|
!time.Now().Before(leaf.NotBefore) && time.Now().Before(leaf.NotAfter)
|
|
}
|
|
|
|
func ipCertificateArgs(domain string) ([]string, error) {
|
|
// The scheduled systemd service on supported hosts must use the same Certbot
|
|
// binary as interactive issuance, or six-day certificates may expire.
|
|
issuer, err := exec.LookPath("certbot")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("未找到 Certbot: %w", err)
|
|
}
|
|
actual, err := filepath.EvalSymlinks(issuer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scheduled, err := filepath.EvalSymlinks("/usr/bin/certbot")
|
|
if err != nil || actual != scheduled {
|
|
return nil, errors.New("IP 短期证书要求定时任务与签发使用同一版本的 /usr/bin/certbot;请先配置续签任务")
|
|
}
|
|
if err := exec.Command("systemctl", "is-active", "--quiet", "certbot.timer").Run(); err != nil {
|
|
return nil, errors.New("IP 短期证书要求先启用 certbot.timer 定时续签")
|
|
}
|
|
if err := exec.Command("systemctl", "is-enabled", "--quiet", "certbot.timer").Run(); err != nil {
|
|
return nil, errors.New("certbot.timer 必须设为开机自动运行,才能续签六天的 IP 证书")
|
|
}
|
|
service, err := exec.Command("systemctl", "show", "-p", "ExecStart", "--value", "certbot.service").Output()
|
|
if err != nil || !strings.Contains(string(service), "/usr/bin/certbot ") {
|
|
return nil, errors.New("certbot.service 未配置为使用 /usr/bin/certbot 续签,请先核对定时任务")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, issuer, "--version").CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("检测 Certbot 版本失败: %w", err)
|
|
}
|
|
return ipCertificateArgsForVersion(domain, string(out))
|
|
}
|
|
|
|
func ipCertificateArgsForVersion(domain, output string) ([]string, error) {
|
|
version := regexp.MustCompile(`certbot ([0-9]+)\.([0-9]+)`).FindStringSubmatch(output)
|
|
if len(version) != 3 {
|
|
return nil, errors.New("无法识别 Certbot 版本;IP 证书要求 5.4 或更新版本")
|
|
}
|
|
major, _ := strconv.Atoi(version[1])
|
|
minor, _ := strconv.Atoi(version[2])
|
|
if major < 5 || (major == 5 && minor < 4) {
|
|
return nil, fmt.Errorf("当前 Certbot %s 不支持 IP 的 webroot 签发;请升级到 5.4 或更新版本", version[0])
|
|
}
|
|
return []string{"--preferred-profile", "shortlived", "--ip-address", domain}, nil
|
|
}
|
|
|
|
// Run performs a reversible HTTP bootstrap, certificate issuance, then HTTPS
|
|
// activation. On issuance errors the HTTP challenge site remains available for retry.
|
|
func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
|
|
return RunEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, emit)
|
|
}
|
|
|
|
// RunEntry activates the public entry for the given scheme/port. HTTP skips ACME.
|
|
func RunEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, emit func(string)) error {
|
|
return RunEntryWithAuth(opts, routes, 0, emit)
|
|
}
|
|
|
|
// RunEntryWithAuth preserves a running OpenVPN browser-auth upstream when
|
|
// regenerating an already managed site.
|
|
func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int, emit func(string)) error {
|
|
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
|
|
scheme := strings.ToLower(strings.TrimSpace(opts.Scheme))
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
opts.Domain, opts.Scheme = domain, scheme
|
|
if !tlsservice.ValidPublicHost(scheme, domain) {
|
|
return errors.New("请先保存有效域名;HTTP 也支持 IPv4 地址")
|
|
}
|
|
if scheme == "https" && net.ParseIP(domain) != nil && !certificateExists(domain) {
|
|
if _, err := ipCertificateArgs(domain); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !tlsservice.ValidScheme(scheme) {
|
|
return errors.New("协议仅支持 http 或 https")
|
|
}
|
|
if scheme == "http" {
|
|
if err := checkSitesHTTP(); err != nil {
|
|
return err
|
|
}
|
|
} else if err := checkSites(); err != nil {
|
|
return err
|
|
}
|
|
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
previous, readErr := os.ReadFile(sitePath)
|
|
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
|
return readErr
|
|
}
|
|
if info, err := os.Lstat(sitePath); err == nil && !info.Mode().IsRegular() {
|
|
return errors.New("入口配置不是常规文件,拒绝覆盖")
|
|
}
|
|
// A previously issued domain must never be silently replaced by a new one.
|
|
if readErr == nil && !strings.Contains(string(previous), "server_name "+domain+";") {
|
|
return errors.New("已有其他域名的入口配置,请人工迁移证书与站点")
|
|
}
|
|
if readErr != nil {
|
|
ports := []string{fmt.Sprintf("%d", tlsservice.EffectivePort(scheme, opts.Port))}
|
|
if scheme == "https" && tlsservice.EffectivePort(scheme, opts.Port) != 80 {
|
|
ports = append([]string{"80"}, ports...)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, port := range ports {
|
|
if seen[port] {
|
|
continue
|
|
}
|
|
seen[port] = true
|
|
listener, e := net.Listen("tcp", "0.0.0.0:"+port)
|
|
if e != nil {
|
|
return fmt.Errorf("端口 %s 已占用,请先检查监听服务: %w", port, e)
|
|
}
|
|
listener.Close()
|
|
}
|
|
}
|
|
movedDefault := false
|
|
if _, err := os.Lstat(defaultPath); err == nil {
|
|
if err := os.Rename(defaultPath, defaultBackup); err != nil {
|
|
return err
|
|
}
|
|
movedDefault = true
|
|
}
|
|
rollback := func() {
|
|
if readErr == nil {
|
|
_ = writeSite(string(previous))
|
|
} else {
|
|
_ = os.Remove(sitePath)
|
|
}
|
|
if movedDefault {
|
|
_ = os.Rename(defaultBackup, defaultPath)
|
|
}
|
|
_ = command(emit, 15*time.Second, "nginx", "-t")
|
|
}
|
|
if err := writeSite(httpSite); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if err := command(emit, 15*time.Second, "nginx", "-t"); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if err := command(emit, 25*time.Second, "systemctl", "start", "nginx"); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if err := command(emit, 15*time.Second, "systemctl", "reload", "nginx"); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if scheme == "http" {
|
|
emit("HTTP 公网入口已启用(未申请证书)。\n")
|
|
return nil
|
|
}
|
|
emit("HTTP-01 验证站点已启用,开始签发证书。\n")
|
|
if !certificateExists(domain) {
|
|
args := []string{"certonly", "--webroot", "-w", nginxsetup.Webroot}
|
|
if net.ParseIP(domain) != nil {
|
|
ipArgs, err := ipCertificateArgs(domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args = append(args, ipArgs...)
|
|
} else {
|
|
args = append(args, "-d", domain)
|
|
}
|
|
args = append(args, "--non-interactive", "--agree-tos", "--register-unsafely-without-email")
|
|
if err := command(emit, 4*time.Minute, "certbot", args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !certificateExists(domain) {
|
|
return errors.New("Certbot 未生成证书;HTTP 验证站点仍保留")
|
|
}
|
|
if err := writeSite(httpSite + httpsSite); err != nil {
|
|
return err
|
|
}
|
|
if err := command(emit, 15*time.Second, "nginx", "-t"); err != nil {
|
|
_ = writeSite(httpSite)
|
|
return err
|
|
}
|
|
if err := command(emit, 15*time.Second, "systemctl", "reload", "nginx"); err != nil {
|
|
_ = writeSite(httpSite)
|
|
_ = command(emit, 15*time.Second, "systemctl", "reload", "nginx")
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Certbot runs deploy hooks only when certificates are renewed.
|
|
const oldHook = "#!/bin/sh\nset -eu\n/usr/sbin/nginx -t\n/bin/systemctl reload nginx\n"
|
|
hook := renewalHook()
|
|
if data, err := os.ReadFile(hookPath); err == nil && string(data) != hook && string(data) != oldHook {
|
|
return errors.New("续签钩子已存在且内容不同,请人工检查")
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(hookPath, []byte(hook), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if _, _, source := tlsservice.CertificateFiles(domain); source == tlsservice.CertSourceHTTP01 {
|
|
_ = tlsservice.MarkCertSource(tlsservice.DataDir(), tlsservice.CertSourceHTTP01)
|
|
}
|
|
emit("HTTPS 入口已启用;Certbot 续签成功后将自动重载 Nginx。\n")
|
|
return nil
|
|
}
|