fix: apply managed Nginx public entry without service restart

This commit is contained in:
Codex committed 2026-10-26 14:33:19 +08:00
1 parent 36d653b32b
commit e4f1059880
11 files changed
+338 -33

No files matched your search

+85 -15
View File
@@ -4,6 +4,8 @@ package nginxgateway
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
@@ -72,7 +74,21 @@ func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error {
}
func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int) error {
if Running() {
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))
@@ -88,7 +104,7 @@ func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut
return err
}
stage := Stage()
if scheme == "https" && stage != "https" {
if scheme == "https" && stage != "https" && !allowChange {
return errors.New("请先启用 HTTPS 站点")
}
if scheme == "http" && stage == "none" {
@@ -98,32 +114,80 @@ func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, aut
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 !strings.Contains(string(previous), "server_name "+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
}
if err := writeSite(httpSite + httpsSite); err != 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() {
_ = writeSite(string(previous))
_ = command(func(string) {}, 15*time.Second, "nginx", "-t")
_ = command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx")
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 := command(func(string) {}, 15*time.Second, "nginx", "-t"); err != nil {
rollback()
return err
if err := validate(); err != nil {
return rollback(err)
}
if err := command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx"); err != nil {
rollback()
return err
if err := reload(); err != nil {
return rollback(err)
}
if persist != nil {
if err := persist(); err != nil {
return rollback(err)
}
}
return nil
}
@@ -246,6 +310,12 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error {
// 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 == "" {
@@ -265,7 +335,7 @@ func RunEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, emit func(s
} else if err := checkSites(); err != nil {
return err
}
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, 0)
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort)
if err != nil {
return err
}