diff --git a/backend/internal/api/identity_gateway.go b/backend/internal/api/identity_gateway.go
index 5f01102..a2f8e5f 100644
--- a/backend/internal/api/identity_gateway.go
+++ b/backend/internal/api/identity_gateway.go
@@ -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 {
diff --git a/backend/internal/api/identity_gateway_test.go b/backend/internal/api/identity_gateway_test.go
index f42f49c..cdc546c 100644
--- a/backend/internal/api/identity_gateway_test.go
+++ b/backend/internal/api/identity_gateway_test.go
@@ -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)
+ }
}
diff --git a/backend/internal/api/tls.go b/backend/internal/api/tls.go
index d553516..478fce2 100644
--- a/backend/internal/api/tls.go
+++ b/backend/internal/api/tls.go
@@ -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) {
diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go
index c34d472..5f157cc 100644
--- a/backend/internal/nginxgateway/gateway.go
+++ b/backend/internal/nginxgateway/gateway.go
@@ -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
}
diff --git a/backend/internal/nginxgateway/gateway_test.go b/backend/internal/nginxgateway/gateway_test.go
new file mode 100644
index 0000000..144d3d9
--- /dev/null
+++ b/backend/internal/nginxgateway/gateway_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/internal/tlsservice/tlsservice.go b/backend/internal/tlsservice/tlsservice.go
index ff7c344..03f8857 100644
--- a/backend/internal/tlsservice/tlsservice.go
+++ b/backend/internal/tlsservice/tlsservice.go
@@ -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.
diff --git a/backend/internal/tlsservice/tlsservice_test.go b/backend/internal/tlsservice/tlsservice_test.go
index 09fe9dd..02859e5 100644
--- a/backend/internal/tlsservice/tlsservice_test.go
+++ b/backend/internal/tlsservice/tlsservice_test.go
@@ -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
diff --git a/docs/specs/SDD-005-nginx-live-public-entry.md b/docs/specs/SDD-005-nginx-live-public-entry.md
new file mode 100644
index 0000000..86deeea
--- /dev/null
+++ b/docs/specs/SDD-005-nginx-live-public-entry.md
@@ -0,0 +1,61 @@
+# SDD-005:Nginx 公网入口保存后即时生效
+
+| 字段 | 内容 |
+|---|---|
+| 版本 / 变更级别 | 0.1 / S3 |
+| 设计状态 | 已形成 |
+| 实现状态 | 已实现;本地 Go 测试及前端构建通过,生产热切换未演练 |
+| 负责人 / 日期 | opsx-vpn / 2026-10-26 |
+| 关联需求 | 在 Nginx 模式修改 HTTP/HTTPS 公网入口,无须重启管理进程;管理端口保持 18080 |
+| 批准状态 | 用户提出修改 |
+
+## 1. 背景、目标与范围
+
+- 现状:`api.setTLSDomain` 仅调用 `tlsservice.ConfigurePublicEntry` 写入 `data/tls-*`;`tlsservice.Status` 比较启动配置并要求重启。`nginxgateway.RefreshAuthEntry` 只能刷新同域名;页面先保存域名再另存模式,显示不准确的“重启后监听 80/443”。
+- 目标:已运行 Nginx 模式、已有托管站点时,保存协议、域名或端口会重写托管 Nginx 配置、`nginx -t` 和 reload,成功后更新内存配置及状态,返回即时生效;入口始终反代现有 18080 管理服务。
+- 边界:首次安装/启用站点依旧单独执行并提供申请证书的日志;Go autocert 模式和入口模式切换仍需重启,因为涉及 Go 监听器。生产当前 HTTPS 入口不自动切换到 HTTP。
+- 保留:原 HTTP-01 路由白名单、已有 OpenVPN/OIDC 回调、证书路径及未提交文件。
+
+## 2. 需求与约束
+
+- FR-01:已启用 Nginx 站点时保存公网入口即时更新站点及状态,不改变应用管理监听端口。
+- FR-02:失败时包括缺失/过期目标证书、端口冲突、`nginx -t`/reload 或配置持久化失败,恢复旧站点和旧状态并返回具体错误。
+- FR-03:首次配置但尚无托管站点时,保存后提示“待启用站点”,点击启用后无须重启;切换进程入口模式明确提示重启。
+- FR-04:运行中的 OpenVPN OIDC 使用旧公网回调时,禁止修改会使其回调失效的入口地址。
+- NFR-01:只允许管理员修改;拒绝覆盖不属于本应用的站点;更新操作与证书上传/身份路由刷新互斥。
+
+## 3. 架构与方案
+
+保存接口按运行模式分支:Nginx 托管站点先校验新域名证书与活跃 OIDC 引用,在锁内生成站点、备份原内容、原子替换、测试及 reload,再持久化公网配置并更新 `tlsservice` 内存;任何步骤失败回滚站点与配置。未启用站点只保存草稿;启用站点成功后更新内存。Go 模式原有需重启语义保留。前端按 `restart_required`、`site_activation_required` 和 `applied` 分别提示。
+
+## 4. 接口、数据与配置契约
+
+- `PUT /api/system/tls/domain` 请求体仍为 `{domain, scheme, port}`,响应增加 `applied` 与 `site_activation_required`;模式接口不变。
+- `GET /api/system/tls` 继续提供配置/运行状态;已完成热更新时 `restart_required=false`。
+- 没有数据库迁移或新配置文件。`VPN_TLS_*` 环境变量仍优先且不能被 UI 覆盖。
+
+## 5. 风险与验收
+
+| 风险或失败场景 | 防护 / 恢复 | 可观察信号 |
+|---|---|---|
+| 新入口证书不可用 | 拒绝 HTTPS 切换,旧站点不动 | 4xx 及原证书继续可用 |
+| Nginx 校验/重载失败 | 恢复站点并重新 reload;返回错误 | 5xx、`nginx -t`、健康检查 |
+| 已连接 OIDC 专线回调旧地址 | 拒绝修改公网地址 | 409 与原专线可用 |
+| 旧站点归属不明 | 拒绝覆盖 | 错误提示;人工检查 |
+
+| AC | 对应需求 | 前置与操作 | 预期结果 | 验证方式 | 结果 |
+|---|---|---|---|---|---|
+| AC-01 | FR-01, NFR-01 | Nginx HTTPS 已启用,保存相同域名/端口 | 不重启,健康检查可用,状态即时同步 | 单测、实机回归 | 状态更新单测通过;在线保存待验证 |
+| AC-02 | FR-02 | 无证书、无效端口或模拟 reload 失败 | 不丢失旧入口、状态不变化 | 单测与配置检查 | 回滚单测通过;在线异常待验证 |
+| AC-03 | FR-03 | 尚未启用站点保存,再启用 | 不要求重启;Go 模式仍准确提醒 | 单测、前端构建 | 本地通过;首次启用实机待验证 |
+| AC-04 | FR-04 | 有 OIDC 专线运行时修改回调根地址 | 拒绝并保留原站点 | API 模块测试 | 本地拒绝与停用后放行测试通过;在线拒绝路径待验证 |
+
+## 6. 发布与恢复
+
+本地 Go 测试、前端构建通过后提交。若部署,先备份程序/数据库和托管站点,保持 HTTPS 现状,先构建前端和后端,再切换管理进程。核验公网 HTTPS、OIDC 发现、`nginx -t`、OpenVPN 进程与 18080 健康。故障时恢复旧程序和站点并 reload;不删除证书或 `data/`。不计划在线演练切换为 HTTP。
+
+## 7. 待决问题
+
+| 问题 | 是否阻塞 | 推荐方案 |
+|---|---|---|
+| 新域名尚无证书时是否自动签发 | 不阻塞即时更新已配置站点 | 拒绝带故障风险的热切换,先通过证书流程准备证书;未来单独设计零停机多域名签发 |
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json
index b84b12c..bf351ac 100644
--- a/frontend/src/locales/en-US.json
+++ b/frontend/src/locales/en-US.json
@@ -900,21 +900,23 @@
"domain": "Configured domain",
"activeDomain": "Active domain",
"saveDomain": "Save domain",
- "restartRequired": "Domain saved; restart OpsX VPN to enable ports 80/443 and automatic issuance",
+ "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",
+ "applied": "Public entry updated; Nginx configuration is active",
"saveFailed": "Could not save domain",
"dns": "DNS",
"certificate": "Certificate",
"valid": "Valid",
"pending": "Pending first HTTPS handshake",
"notConfigured": "Disabled",
- "saved": "Domain configuration saved",
+ "saved": "Public entry saved",
"issuer": "Issuer",
"expires": "Expires",
"renewal": "Renewal",
"automatic": "Automatic (autocert)",
"ports": "HTTP / HTTPS ports",
"externalPorts": "Managed by external Nginx; listener status not checked",
- "setupHint": "Restart to apply a saved domain or mode. Before switching to Nginx, follow the deployment guide to install it, issue a certificate, and validate the configuration. Go mode caches certificates in data/acme; saving settings alone does not issue a certificate.",
+ "setupHint": "Changes to an active Nginx gateway apply on save. Prepare a certificate for initial setup. Only switching between Go and Nginx modes requires a restart.",
"refresh": "Refresh status",
"openHttps": "Open HTTPS health check",
"loadFailed": "Could not load certificate status",
@@ -922,7 +924,7 @@
"goMode": "Go automatic certificate (binds 80/443)",
"nginxMode": "Nginx proxy (external 80/443)",
"saveMode": "Save entry mode",
- "nginxHint": "Save the Nginx mode and domain, restart the service, then enable the site and request a certificate. Public ports 80/443 must be open.",
+ "nginxHint": "An active Nginx gateway reloads on save. Initial setup requires enabling the site separately; the management service remains on port 18080.",
"externalRenewal": "Managed by Certbot",
"previewNginx": "Preview Nginx configuration",
"previewOnly": "Configuration preview; HTTP handles issuance and HTTPS starts after the certificate exists.",
diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json
index 5b2cb52..585e552 100644
--- a/frontend/src/locales/zh-CN.json
+++ b/frontend/src/locales/zh-CN.json
@@ -900,21 +900,23 @@
"domain": "配置域名",
"activeDomain": "当前监听域名",
"saveDomain": "保存域名",
- "restartRequired": "域名已保存,重启 opsx-vpn 后才会监听 80/443 并启用自动签发",
+ "restartRequired": "入口模式已保存;切换 Go 与 Nginx 模式需重启管理服务",
+ "siteActivationRequired": "入口已保存;首次启用站点请到下方准备并启用 Nginx",
+ "applied": "公网入口已更新,Nginx 配置已生效",
"saveFailed": "保存域名失败",
"dns": "DNS 解析",
"certificate": "证书",
"valid": "有效",
"pending": "等待首次 HTTPS 请求签发",
"notConfigured": "未启用",
- "saved": "域名配置已保存",
+ "saved": "公网入口已保存",
"issuer": "颁发者",
"expires": "到期时间",
"renewal": "续签",
"automatic": "自动(autocert)",
"ports": "HTTP / HTTPS 端口",
"externalPorts": "由外部 Nginx 管理,尚未检测监听状态",
- "setupHint": "保存域名或入口模式后需重启生效;切换为 Nginx 前须按部署文档完成安装、证书签发和配置校验。Go 模式证书缓存于 data/acme;保存配置本身不会签发证书。",
+ "setupHint": "已启用的 Nginx 入口保存后立即生效;首次启用站点需准备证书。只有切换 Go 与 Nginx 模式才需重启。",
"refresh": "刷新状态",
"openHttps": "打开 HTTPS 健康检查",
"loadFailed": "读取证书状态失败",
@@ -922,7 +924,7 @@
"goMode": "Go 自动证书(进程监听 80/443)",
"nginxMode": "Nginx 代理(外部监听 80/443)",
"saveMode": "保存入口模式",
- "nginxHint": "选择 Nginx 模式并保存域名后重启服务,再点击启用站点申请证书。端口 80/443 需允许公网访问。",
+ "nginxHint": "已启用的 Nginx 入口保存后立即重载;首次配置需单独启用站点。管理服务继续监听 18080。",
"externalRenewal": "由 Certbot 管理",
"previewNginx": "预览 Nginx 配置",
"previewOnly": "以下为配置预览;HTTP 站点用于签发,HTTPS 站点在证书存在后启用。",
diff --git a/frontend/src/views/settings/components/GatewaySettings.vue b/frontend/src/views/settings/components/GatewaySettings.vue
index c71bad5..eea759f 100644
--- a/frontend/src/views/settings/components/GatewaySettings.vue
+++ b/frontend/src/views/settings/components/GatewaySettings.vue
@@ -54,6 +54,7 @@
+
@@ -383,16 +384,22 @@ async function savePublicEntry() {
if (!entry.host) return ElMessage.warning(t('tls.hostRequired'))
tlsSaving.value = true
try {
+ let modeRes = null
+ if (entry.mode !== tlsStatus.value?.configured_mode) {
+ const result = await api.put('/system/tls/mode', { mode: entry.mode })
+ modeRes = result.data
+ }
const { data: domainRes } = await api.put('/system/tls/domain', {
domain: entry.host,
scheme: entry.scheme,
port: entry.port,
})
tlsStatus.value = domainRes.status
- const { data: modeRes } = await api.put('/system/tls/mode', { mode: entry.mode })
- tlsStatus.value = modeRes.status || domainRes.status
nginxPreview.value = null
- ElMessage.success((domainRes.restart_required || modeRes.restart_required) ? t('tls.restartRequired') : t('tls.saved'))
+ if (domainRes.restart_required || modeRes?.restart_required) ElMessage.success(t('tls.restartRequired'))
+ else if (domainRes.site_activation_required) ElMessage.success(t('tls.siteActivationRequired'))
+ else ElMessage.success(t(domainRes.applied ? 'tls.applied' : 'tls.saved'))
+ await refreshAll()
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}