fix: apply managed Nginx public entry without service restart
This commit is contained in:
1 parent
36d653b32b
commit
e4f1059880
11 files changed
+338
-33
No files matched your search
@@ -38,6 +38,26 @@ func authGatewayPort(publicBase string) (int, error) {
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func checkAuthGatewayChange(oldBase, newBase string) error {
|
||||
if oldBase == "" || oldBase == newBase {
|
||||
return nil
|
||||
}
|
||||
port, err := authGatewayPort(oldBase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if port != 0 {
|
||||
return &activeAuthEntry{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type activeAuthEntry struct{}
|
||||
|
||||
func (*activeAuthEntry) Error() string {
|
||||
return "运行中的 OIDC 专线仍使用旧公网地址,请先停止或迁移专线"
|
||||
}
|
||||
|
||||
type gatewayConflict struct{}
|
||||
|
||||
func (*gatewayConflict) Error() string {
|
||||
|
||||
@@ -29,6 +29,12 @@ func TestAuthGatewayPortFollowsRunningOIDCTunnel(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(19090)
|
||||
if err := checkAuthGatewayChange("https://vpn.example.com", "http://vpn.example.com"); err == nil {
|
||||
t.Fatal("active OIDC callback must block public entry change")
|
||||
}
|
||||
if err := checkAuthGatewayChange("https://vpn.example.com", "https://vpn.example.com"); err != nil {
|
||||
t.Fatalf("unchanged public entry should be allowed: %v", err)
|
||||
}
|
||||
if port, err := authGatewayPort("https://other.example.com"); err != nil || port != 0 {
|
||||
t.Fatalf("wrong domain routed: %d, %v", port, err)
|
||||
}
|
||||
@@ -36,4 +42,7 @@ func TestAuthGatewayPortFollowsRunningOIDCTunnel(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(0)
|
||||
if err := checkAuthGatewayChange("https://vpn.example.com", "http://vpn.example.com"); err != nil {
|
||||
t.Fatalf("stopped OIDC tunnel should allow change: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,19 @@ func (s *Server) nginxSetupStatus(c *gin.Context) {
|
||||
|
||||
func (s *Server) activateNginx(c *gin.Context) {
|
||||
status := tlsservice.Status()
|
||||
if status.Mode != "nginx" || status.ConfiguredMode != "nginx" || status.RestartRequired || status.ConfiguredDomain == "" {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "请先保存 Nginx 模式和公网入口,重启 opsx-vpn 后再启用站点"})
|
||||
if status.Mode != "nginx" || status.ConfiguredMode != "nginx" || status.ConfiguredDomain == "" {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "请先启用 Nginx 模式并保存公网入口;切换入口模式需重启 opsx-vpn"})
|
||||
return
|
||||
}
|
||||
scheme := status.ConfiguredScheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
authPort, err := authGatewayPort(status.ConfiguredPublicBase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !nginxgateway.Begin() {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "入口配置正在运行,请等待完成"})
|
||||
return
|
||||
@@ -54,11 +59,15 @@ func (s *Server) activateNginx(c *gin.Context) {
|
||||
write("info", "检查现有站点并启用 HTTP-01 验证。\n")
|
||||
}
|
||||
opts := nginxconfig.RenderOptions{Domain: status.ConfiguredDomain, Scheme: scheme, Port: status.ConfiguredPort}
|
||||
if err := nginxgateway.RunEntry(opts, s.router.Routes(), func(message string) { write("output", message) }); err != nil {
|
||||
if err := nginxgateway.RunEntryWithAuth(opts, s.router.Routes(), authPort, func(message string) { write("output", message) }); err != nil {
|
||||
store.Audit(c.GetString("username"), "nginx_activate", "failed", err.Error())
|
||||
write("error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := tlsservice.AdoptNginxConfiguration(service.BaseDir()); err != nil {
|
||||
write("error", "站点已更新,但状态同步失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "nginx_activate", "completed", status.ConfiguredPublicBase)
|
||||
write("done", "站点和入口已就绪。")
|
||||
}
|
||||
@@ -157,13 +166,48 @@ func (s *Server) setTLSDomain(c *gin.Context) {
|
||||
if req.Port != nil {
|
||||
port = *req.Port
|
||||
}
|
||||
status := tlsservice.Status()
|
||||
newBase := tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port)
|
||||
if newBase == "" || port < 0 || port > 65535 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "公网域名、协议或端口无效"})
|
||||
return
|
||||
}
|
||||
// The Go listener cannot be rebound in place; Nginx owns its own sockets.
|
||||
if status.Mode == "nginx" && status.ConfiguredMode == "nginx" && nginxgateway.Stage() != "none" {
|
||||
s.gatewayMu.Lock()
|
||||
defer s.gatewayMu.Unlock()
|
||||
if err := checkAuthGatewayChange(status.PublicBase, newBase); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authPort, err := authGatewayPort(newBase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
opts := nginxconfig.RenderOptions{Domain: req.Domain, Scheme: scheme, Port: port}
|
||||
err = nginxgateway.UpdateEntry(opts, status.ConfiguredDomain, s.router.Routes(), authPort, func() error {
|
||||
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
|
||||
return err
|
||||
}
|
||||
return tlsservice.AdoptNginxConfiguration(service.BaseDir())
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "tls_domain_applied", newBase, "Nginx 已重载")
|
||||
c.JSON(http.StatusOK, gin.H{"status": tlsservice.Status(), "restart_required": false, "applied": true})
|
||||
return
|
||||
}
|
||||
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "tls_domain_configured", tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port), "需重启后生效")
|
||||
status := tlsservice.Status()
|
||||
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired})
|
||||
status = tlsservice.Status()
|
||||
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired,
|
||||
"site_activation_required": status.Mode == "nginx" && status.ConfiguredMode == "nginx" && nginxgateway.Stage() == "none"})
|
||||
}
|
||||
|
||||
func (s *Server) uploadTLSCertificate(c *gin.Context) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package nginxgateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSwapManagedSiteRestoresPreviousOnFailure(t *testing.T) {
|
||||
for _, failAt := range []string{"validate", "reload", "persist"} {
|
||||
t.Run(failAt, func(t *testing.T) {
|
||||
site := "old"
|
||||
validations, reloads := 0, 0
|
||||
write := func(content string) error { site = content; return nil }
|
||||
validate := func() error {
|
||||
validations++
|
||||
if failAt == "validate" && site == "new" {
|
||||
return errors.New("invalid nginx config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
reload := func() error {
|
||||
reloads++
|
||||
if failAt == "reload" && site == "new" {
|
||||
return errors.New("reload failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
persist := func() error {
|
||||
if failAt == "persist" {
|
||||
return errors.New("save failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := swapManagedSite("old", "new", persist, write, validate, reload); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if site != "old" || validations < 2 || reloads < 1 {
|
||||
t.Fatalf("old site not restored: site=%s validations=%d reloads=%d", site, validations, reloads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,25 @@ func ConfigureMode(dataDir, mode string) error {
|
||||
return writePersisted(dataDir, "tls-mode", mode)
|
||||
}
|
||||
|
||||
// AdoptNginxConfiguration updates the in-process public address after the
|
||||
// managed Nginx site has passed validation and reloaded successfully.
|
||||
func AdoptNginxConfiguration(dataDir string) error {
|
||||
config, err := configFromEnvironment(dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.mode != "nginx" {
|
||||
return fmt.Errorf("当前入口不是 Nginx 模式")
|
||||
}
|
||||
state.Lock()
|
||||
defer state.Unlock()
|
||||
if state.config.mode != "nginx" || state.config.dataDir != dataDir {
|
||||
return fmt.Errorf("运行模式不支持热更新 Nginx 入口")
|
||||
}
|
||||
state.config = config
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start keeps certificates in the persistent data directory. Issuance occurs
|
||||
// on the first TLS handshake, and autocert handles renewal.
|
||||
func Start(handler http.Handler, dataDir string) error {
|
||||
@@ -377,8 +396,8 @@ func Status() CertificateStatus {
|
||||
status.ConfiguredScheme = normalizeScheme(desired.scheme)
|
||||
status.ConfiguredPort = desired.port
|
||||
status.ConfiguredPublicBase = PublicBaseURL(status.ConfiguredScheme, desired.domain, desired.port)
|
||||
status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode ||
|
||||
normalizeScheme(desired.scheme) != scheme || desired.port != config.port
|
||||
status.RestartRequired = desired.mode != config.mode || (config.mode != "nginx" &&
|
||||
(desired.domain != config.domain || normalizeScheme(desired.scheme) != scheme || desired.port != config.port))
|
||||
}
|
||||
if config.mode == "nginx" {
|
||||
// Certbot or uploaded PEMs own HTTPS certificates. HTTP needs no leaf.
|
||||
|
||||
@@ -89,6 +89,35 @@ func TestNginxModeDoesNotBindGoListeners(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxEntryCanAdoptWithoutRestart(t *testing.T) {
|
||||
t.Setenv("VPN_TLS_MODE", "")
|
||||
t.Setenv("VPN_TLS_DOMAIN", "")
|
||||
t.Setenv("VPN_TLS_SCHEME", "")
|
||||
t.Setenv("VPN_TLS_PORT", "")
|
||||
dir := t.TempDir()
|
||||
if err := ConfigureMode(dir, "nginx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConfigurePublicEntry(dir, "https", "first.example.com", 443); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Start(http.NotFoundHandler(), dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConfigurePublicEntry(dir, "http", "second.example.com", 8080); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := Status(); s.PublicBase == "http://second.example.com:8080" || s.RestartRequired {
|
||||
t.Fatalf("unapplied nginx config must not claim the new entry or require process restart: %+v", s)
|
||||
}
|
||||
if err := AdoptNginxConfiguration(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := Status(); s.RestartRequired || s.ConfiguredPublicBase != "http://second.example.com:8080" || s.PublicBase != s.ConfiguredPublicBase {
|
||||
t.Fatalf("nginx config was not adopted: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
scheme, host string
|
||||
|
||||
Reference in new issue
Block a user