diff --git a/backend/internal/api/identity_gateway.go b/backend/internal/api/identity_gateway.go
index a2f8e5f..0428d57 100644
--- a/backend/internal/api/identity_gateway.go
+++ b/backend/internal/api/identity_gateway.go
@@ -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
}
diff --git a/backend/internal/api/tls.go b/backend/internal/api/tls.go
index 478fce2..d01a0f0 100644
--- a/backend/internal/api/tls.go
+++ b/backend/internal/api/tls.go
@@ -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
}
diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go
index 5f157cc..fd5d8e0 100644
--- a/backend/internal/nginxgateway/gateway.go
+++ b/backend/internal/nginxgateway/gateway.go
@@ -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() }
diff --git a/backend/internal/nginxgateway/gateway_test.go b/backend/internal/nginxgateway/gateway_test.go
index 144d3d9..90a678d 100644
--- a/backend/internal/nginxgateway/gateway_test.go
+++ b/backend/internal/nginxgateway/gateway_test.go
@@ -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) {
diff --git a/backend/internal/tlsservice/tlsservice.go b/backend/internal/tlsservice/tlsservice.go
index 03f8857..a94fb46 100644
--- a/backend/internal/tlsservice/tlsservice.go
+++ b/backend/internal/tlsservice/tlsservice.go
@@ -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 {
diff --git a/backend/internal/tlsservice/tlsservice_test.go b/backend/internal/tlsservice/tlsservice_test.go
index 02859e5..aeb60f1 100644
--- a/backend/internal/tlsservice/tlsservice_test.go
+++ b/backend/internal/tlsservice/tlsservice_test.go
@@ -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) {
diff --git a/docs/specs/SDD-005-nginx-live-public-entry.md b/docs/specs/SDD-005-nginx-live-public-entry.md
index 86deeea..1fd592d 100644
--- a/docs/specs/SDD-005-nginx-live-public-entry.md
+++ b/docs/specs/SDD-005-nginx-live-public-entry.md
@@ -22,11 +22,12 @@
- FR-02:失败时包括缺失/过期目标证书、端口冲突、`nginx -t`/reload 或配置持久化失败,恢复旧站点和旧状态并返回具体错误。
- FR-03:首次配置但尚无托管站点时,保存后提示“待启用站点”,点击启用后无须重启;切换进程入口模式明确提示重启。
- FR-04:运行中的 OpenVPN OIDC 使用旧公网回调时,禁止修改会使其回调失效的入口地址。
+- FR-05:启动时若历史保存配置与已启用的 Nginx 站点不符,保持实际站点与身份 issuer 一致,展示待应用配置,而非在启动时静默覆盖站点。
- NFR-01:只允许管理员修改;拒绝覆盖不属于本应用的站点;更新操作与证书上传/身份路由刷新互斥。
## 3. 架构与方案
-保存接口按运行模式分支:Nginx 托管站点先校验新域名证书与活跃 OIDC 引用,在锁内生成站点、备份原内容、原子替换、测试及 reload,再持久化公网配置并更新 `tlsservice` 内存;任何步骤失败回滚站点与配置。未启用站点只保存草稿;启用站点成功后更新内存。Go 模式原有需重启语义保留。前端按 `restart_required`、`site_activation_required` 和 `applied` 分别提示。
+保存接口按运行模式分支:Nginx 托管站点先校验新域名证书与活跃 OIDC 引用,在锁内生成站点、备份原内容、原子替换、测试及 reload,再持久化公网配置并更新 `tlsservice` 内存;失败恢复原站点。未启用站点只保存草稿;启用站点成功后更新内存。启动时解析现有托管站点,与持久化配置不一致则使用实际入口作为运行态并保持原站点,页面显示待应用配置。Go 模式原有需重启语义保留。前端按 `restart_required`、`site_activation_required` 和 `applied` 分别提示。
## 4. 接口、数据与配置契约
@@ -41,6 +42,7 @@
| 新入口证书不可用 | 拒绝 HTTPS 切换,旧站点不动 | 4xx 及原证书继续可用 |
| Nginx 校验/重载失败 | 恢复站点并重新 reload;返回错误 | 5xx、`nginx -t`、健康检查 |
| 已连接 OIDC 专线回调旧地址 | 拒绝修改公网地址 | 409 与原专线可用 |
+| 旧版本保存的 HTTP 草稿在升级重启后覆盖 HTTPS | 启动时先对比实际 Nginx 站点,不一致时采用站点运行态 | 公网仍提供旧 HTTPS,日志提示草稿待应用 |
| 旧站点归属不明 | 拒绝覆盖 | 错误提示;人工检查 |
| AC | 对应需求 | 前置与操作 | 预期结果 | 验证方式 | 结果 |
@@ -49,6 +51,7 @@
| AC-02 | FR-02 | 无证书、无效端口或模拟 reload 失败 | 不丢失旧入口、状态不变化 | 单测与配置检查 | 回滚单测通过;在线异常待验证 |
| AC-03 | FR-03 | 尚未启用站点保存,再启用 | 不要求重启;Go 模式仍准确提醒 | 单测、前端构建 | 本地通过;首次启用实机待验证 |
| AC-04 | FR-04 | 有 OIDC 专线运行时修改回调根地址 | 拒绝并保留原站点 | API 模块测试 | 本地拒绝与停用后放行测试通过;在线拒绝路径待验证 |
+| AC-05 | FR-05 | 已保存 HTTP 草稿,运行站点仍为 HTTPS | 重启后不重写站点,运行态 issuer 仍为 HTTPS | 解析单测、运行态单测、实机回归 | 本地测试通过;历史草稿实机待验证 |
## 6. 发布与恢复
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json
index bf351ac..639737c 100644
--- a/frontend/src/locales/en-US.json
+++ b/frontend/src/locales/en-US.json
@@ -902,6 +902,7 @@
"saveDomain": "Save domain",
"restartRequired": "Gateway mode saved; switching between Go and Nginx requires restarting the management service",
"siteActivationRequired": "Entry saved; prepare and enable the Nginx site below for first use",
+ "pendingEntry": "Nginx still serves {address}; save to validate and apply the new entry",
"applied": "Public entry updated; Nginx configuration is active",
"saveFailed": "Could not save domain",
"dns": "DNS",
diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json
index 585e552..724831b 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -902,6 +902,7 @@
"saveDomain": "保存域名",
"restartRequired": "入口模式已保存;切换 Go 与 Nginx 模式需重启管理服务",
"siteActivationRequired": "入口已保存;首次启用站点请到下方准备并启用 Nginx",
+ "pendingEntry": "当前 Nginx 仍使用 {address};点击保存将校验并更新入口",
"applied": "公网入口已更新,Nginx 配置已生效",
"saveFailed": "保存域名失败",
"dns": "DNS 解析",
diff --git a/frontend/src/views/settings/components/GatewaySettings.vue b/frontend/src/views/settings/components/GatewaySettings.vue
index eea759f..190d3e4 100644
--- a/frontend/src/views/settings/components/GatewaySettings.vue
+++ b/frontend/src/views/settings/components/GatewaySettings.vue
@@ -55,6 +55,7 @@
+