fix: preserve live Nginx entry when saved draft differs
This commit is contained in:
1 parent
e4f1059880
commit
97da0a57b6
10 files changed
+115
-3
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -71,7 +72,7 @@ func (s *Server) RefreshManagedGatewayOnStart() error {
|
||||
s.gatewayMu.Lock()
|
||||
defer s.gatewayMu.Unlock()
|
||||
status := tlsservice.Status()
|
||||
if status.Mode != "nginx" || status.RestartRequired || status.Domain == "" || status.PublicBase == "" {
|
||||
if status.Mode != "nginx" || status.RestartRequired {
|
||||
return nil
|
||||
}
|
||||
scheme := status.Scheme
|
||||
@@ -79,6 +80,22 @@ func (s *Server) RefreshManagedGatewayOnStart() error {
|
||||
scheme = "https"
|
||||
}
|
||||
stage := nginxgateway.Stage()
|
||||
if stage != "none" {
|
||||
actual, err := nginxgateway.ManagedEntry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualBase := tlsservice.PublicBaseURL(actual.Scheme, actual.Domain, actual.Port)
|
||||
if actualBase != status.ConfiguredPublicBase {
|
||||
if err := tlsservice.UseManagedNginxEntry(service.BaseDir(), actual.Scheme, actual.Domain, actual.Port); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("已保存的公网入口 %s 与运行中的 Nginx %s 不同;保留当前站点,请在设置页确认后保存", status.ConfiguredPublicBase, actualBase)
|
||||
}
|
||||
}
|
||||
if status.Domain == "" || status.PublicBase == "" {
|
||||
return nil
|
||||
}
|
||||
if scheme == "https" && (!status.Valid || stage != "https") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -176,6 +176,11 @@ func (s *Server) setTLSDomain(c *gin.Context) {
|
||||
if status.Mode == "nginx" && status.ConfiguredMode == "nginx" && nginxgateway.Stage() != "none" {
|
||||
s.gatewayMu.Lock()
|
||||
defer s.gatewayMu.Unlock()
|
||||
actual, err := nginxgateway.ManagedEntry()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := checkAuthGatewayChange(status.PublicBase, newBase); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -186,7 +191,7 @@ func (s *Server) setTLSDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
opts := nginxconfig.RenderOptions{Domain: req.Domain, Scheme: scheme, Port: port}
|
||||
err = nginxgateway.UpdateEntry(opts, status.ConfiguredDomain, s.router.Routes(), authPort, func() error {
|
||||
err = nginxgateway.UpdateEntry(opts, actual.Domain, s.router.Routes(), authPort, func() error {
|
||||
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -32,6 +34,45 @@ const (
|
||||
|
||||
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.ValidDomain(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() }
|
||||
|
||||
@@ -3,8 +3,30 @@ package nginxgateway
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/nginxconfig"
|
||||
)
|
||||
|
||||
func TestParseManagedEntry(t *testing.T) {
|
||||
for _, want := range []nginxconfig.RenderOptions{
|
||||
{Domain: "vpn.example.com", Scheme: "https", Port: 443},
|
||||
{Domain: "vpn.example.com", Scheme: "https", Port: 8443},
|
||||
{Domain: "vpn.example.com", Scheme: "http", Port: 28080},
|
||||
} {
|
||||
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(want, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseManagedEntry(httpSite + httpsSite)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("parse managed entry = %+v, %v; want %+v", got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := parseManagedEntry("server { listen 443 ssl; }"); err == nil {
|
||||
t.Fatal("unmanaged site must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapManagedSiteRestoresPreviousOnFailure(t *testing.T) {
|
||||
for _, failAt := range []string{"validate", "reload", "persist"} {
|
||||
t.Run(failAt, func(t *testing.T) {
|
||||
|
||||
@@ -301,6 +301,21 @@ func AdoptNginxConfiguration(dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseManagedNginxEntry keeps the in-process identity issuer aligned with the
|
||||
// live Nginx site if an older version left an unapplied draft on disk.
|
||||
func UseManagedNginxEntry(dataDir, scheme, domain string, port int) error {
|
||||
if !ValidScheme(scheme) || !ValidDomain(domain) || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("运行中的 Nginx 公网地址无效")
|
||||
}
|
||||
state.Lock()
|
||||
defer state.Unlock()
|
||||
if state.config.mode != "nginx" || state.config.dataDir != dataDir {
|
||||
return fmt.Errorf("当前运行模式不是 Nginx")
|
||||
}
|
||||
state.config.scheme, state.config.domain, state.config.port = scheme, domain, port
|
||||
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 {
|
||||
|
||||
@@ -116,6 +116,12 @@ func TestNginxEntryCanAdoptWithoutRestart(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if err := UseManagedNginxEntry(dir, "https", "first.example.com", 443); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := Status(); s.PublicBase == s.ConfiguredPublicBase || s.ConfiguredPublicBase != "http://second.example.com:8080" {
|
||||
t.Fatalf("live Nginx address must remain distinct from saved draft: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBaseURL(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user