diff --git a/backend/cmd/opsx-gateway-setup/main.go b/backend/cmd/opsx-gateway-setup/main.go new file mode 100644 index 0000000..518aa72 --- /dev/null +++ b/backend/cmd/opsx-gateway-setup/main.go @@ -0,0 +1,17 @@ +// opsx-gateway-setup prepares Nginx and Certbot without enabling a default +// public site. The management UI invokes the same guarded installer. +package main + +import ( + "fmt" + "os" + + "vpn-platform/backend/internal/nginxsetup" +) + +func main() { + if err := nginxsetup.Run(func(message string) { fmt.Print(message) }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index aa8bf1a..8432277 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -306,6 +306,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { admin.GET("/system/tls/nginx-preview", s.nginxPreview) admin.GET("/system/tls/nginx-setup", s.nginxSetupStatus) admin.POST("/system/tls/nginx-setup", s.initializeNginx) + admin.POST("/system/tls/nginx-activate", s.activateNginx) admin.GET("/users", s.listUsers) admin.POST("/users", s.createUser) admin.PUT("/users/:id", s.updateUser) diff --git a/backend/internal/api/tls.go b/backend/internal/api/tls.go index 1068fdf..7c3906d 100644 --- a/backend/internal/api/tls.go +++ b/backend/internal/api/tls.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "vpn-platform/backend/internal/nginxconfig" + "vpn-platform/backend/internal/nginxgateway" "vpn-platform/backend/internal/nginxsetup" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/store" @@ -15,7 +16,41 @@ import ( ) func (s *Server) nginxSetupStatus(c *gin.Context) { - c.JSON(http.StatusOK, nginxsetup.Inspect()) + status := nginxsetup.Inspect() + c.JSON(http.StatusOK, gin.H{"os": status.OS, "version": status.Version, "package_manager": status.PackageManager, + "supported": status.Supported, "root": status.Root, "nginx_installed": status.Nginx, + "certbot_installed": status.Certbot, "webroot_ready": status.Webroot, "running": status.Running || nginxgateway.Running(), + "site_stage": nginxgateway.Stage()}) +} + +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 后再启用站点"}) + return + } + if !nginxgateway.Begin() { + c.JSON(http.StatusConflict, gin.H{"error": "入口配置正在运行,请等待完成"}) + return + } + defer nginxgateway.End() + c.Header("Content-Type", "application/x-ndjson; charset=utf-8") + c.Header("Cache-Control", "no-store") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + write := func(kind, message string) { + payload, _ := json.Marshal(gin.H{"type": kind, "message": message}) + fmt.Fprintf(c.Writer, "%s\n", payload) + c.Writer.Flush() + } + write("info", "检查现有站点并启用 HTTP-01 验证。\n") + if err := nginxgateway.Run(status.ConfiguredDomain, s.router.Routes(), func(message string) { write("output", message) }); err != nil { + store.Audit(c.GetString("username"), "nginx_activate", "failed", err.Error()) + write("error", err.Error()) + return + } + store.Audit(c.GetString("username"), "nginx_activate", "completed", status.ConfiguredDomain) + write("done", "站点和证书已就绪。") } func (s *Server) initializeNginx(c *gin.Context) { diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go new file mode 100644 index 0000000..833fc73 --- /dev/null +++ b/backend/internal/nginxgateway/gateway.go @@ -0,0 +1,236 @@ +// Package nginxgateway installs the single-domain HTTP-01 and HTTPS gateway. +// It refuses to change hosts with unrelated enabled Nginx sites. +package nginxgateway + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "vpn-platform/backend/internal/nginxconfig" + "vpn-platform/backend/internal/nginxsetup" + "vpn-platform/backend/internal/tlsservice" +) + +const ( + siteDir = "/etc/nginx/sites-enabled" + sitePath = siteDir + "/opsx-vpn.conf" + defaultPath = siteDir + "/default" + defaultBackup = "/etc/nginx/opsx-vpn-default.disabled" + hookPath = "/etc/letsencrypt/renewal-hooks/deploy/opsx-vpn-nginx" +) + +var running atomic.Bool + +func Begin() bool { return running.CompareAndSwap(false, true) } +func End() { running.Store(false) } +func Running() bool { return running.Load() } + +// Stage reports only the managed configuration state, not public reachability. +func Stage() string { + data, err := os.ReadFile(sitePath) + if err != nil { + return "none" + } + if strings.Contains(string(data), "# Add only after the certificate exists.") { + return "https" + } + return "http" +} + +func command(emit func(string), timeout time.Duration, name string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + if len(out) > 16000 { + out = out[len(out)-16000:] + } + emit(string(out)) + } + if err != nil { + return fmt.Errorf("%s 失败: %w", name, err) + } + return nil +} + +func writeSite(content string) error { + f, err := os.CreateTemp(siteDir, ".opsx-vpn-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err = f.Chmod(0o644); err != nil { + return err + } + if _, err = f.WriteString(content); err != nil { + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), sitePath) +} + +func checkSites() error { + if os.Geteuid() != 0 { + return errors.New("配置入口需要 root 权限") + } + state := nginxsetup.Inspect() + if !state.Nginx || !state.Certbot || !state.Webroot { + return errors.New("请先准备 Nginx / Certbot 和 HTTP-01 验证目录") + } + entries, err := os.ReadDir(siteDir) + if err != nil { + return err + } + for _, e := range entries { + if e.Name() != "default" && e.Name() != "opsx-vpn.conf" { + return fmt.Errorf("发现其他启用站点 %s,请先由管理员检查冲突", e.Name()) + } + } + if entries, err := os.ReadDir("/etc/nginx/conf.d"); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".conf") { + return fmt.Errorf("发现其他 Nginx 配置 %s,请先由管理员检查冲突", e.Name()) + } + } + } + if info, err := os.Lstat(defaultPath); err == nil { + if info.Mode()&os.ModeSymlink == 0 { + return errors.New("默认站点不是发行版软链接,拒绝更改") + } + target, err := os.Readlink(defaultPath) + if err != nil || target != "/etc/nginx/sites-available/default" { + return errors.New("默认站点来源未知,拒绝更改") + } + if _, err = os.Lstat(defaultBackup); err == nil { + return errors.New("默认站点备份已存在,请人工检查") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func certificateExists(domain string) bool { + path := filepath.Join("/etc/letsencrypt/live", domain, "fullchain.pem") + _, err := os.Stat(path) + return err == nil +} + +// Run performs a reversible HTTP bootstrap, certificate issuance, then HTTPS +// activation. On issuance errors the HTTP challenge site remains available for retry. +func Run(domain string, routes gin.RoutesInfo, emit func(string)) error { + if !tlsservice.ValidDomain(domain) { + return errors.New("请先保存有效域名") + } + if err := checkSites(); err != nil { + return err + } + httpSite, httpsSite, err := nginxconfig.Render(domain, routes) + if err != nil { + return err + } + previous, readErr := os.ReadFile(sitePath) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return readErr + } + if info, err := os.Lstat(sitePath); err == nil && !info.Mode().IsRegular() { + return errors.New("入口配置不是常规文件,拒绝覆盖") + } + // A previously issued domain must never be silently replaced by a new one. + if readErr == nil && !strings.Contains(string(previous), "server_name "+domain+";") { + return errors.New("已有其他域名的入口配置,请人工迁移证书与站点") + } + if readErr != nil { + for _, port := range []string{"80", "443"} { + listener, e := net.Listen("tcp", "0.0.0.0:"+port) + if e != nil { + return fmt.Errorf("端口 %s 已占用,请先检查监听服务: %w", port, e) + } + listener.Close() + } + } + movedDefault := false + if _, err := os.Lstat(defaultPath); err == nil { + if err := os.Rename(defaultPath, defaultBackup); err != nil { + return err + } + movedDefault = true + } + rollback := func() { + if readErr == nil { + _ = writeSite(string(previous)) + } else { + _ = os.Remove(sitePath) + } + if movedDefault { + _ = os.Rename(defaultBackup, defaultPath) + } + _ = command(emit, 15*time.Second, "nginx", "-t") + } + if err := writeSite(httpSite); err != nil { + rollback() + return err + } + if err := command(emit, 15*time.Second, "nginx", "-t"); err != nil { + rollback() + return err + } + if err := command(emit, 25*time.Second, "systemctl", "start", "nginx"); err != nil { + rollback() + return err + } + if err := command(emit, 15*time.Second, "systemctl", "reload", "nginx"); err != nil { + rollback() + return err + } + emit("HTTP-01 验证站点已启用,开始签发证书。\n") + if !certificateExists(domain) { + if err := command(emit, 4*time.Minute, "certbot", "certonly", "--webroot", "-w", nginxsetup.Webroot, + "-d", domain, "--non-interactive", "--agree-tos", "--register-unsafely-without-email"); err != nil { + return err + } + } + if !certificateExists(domain) { + return errors.New("Certbot 未生成证书;HTTP 验证站点仍保留") + } + if err := writeSite(httpSite + httpsSite); err != nil { + return err + } + if err := command(emit, 15*time.Second, "nginx", "-t"); err != nil { + _ = writeSite(httpSite) + return err + } + if err := command(emit, 15*time.Second, "systemctl", "reload", "nginx"); err != nil { + _ = writeSite(httpSite) + _ = command(emit, 15*time.Second, "systemctl", "reload", "nginx") + return err + } + if err := os.MkdirAll(filepath.Dir(hookPath), 0o755); err != nil { + return err + } + // Certbot runs deploy hooks only when certificates are renewed. + const hook = "#!/bin/sh\nset -eu\n/usr/sbin/nginx -t\n/bin/systemctl reload nginx\n" + if data, err := os.ReadFile(hookPath); err == nil && string(data) != hook { + return errors.New("续签钩子已存在且内容不同,请人工检查") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.WriteFile(hookPath, []byte(hook), 0o755); err != nil { + return err + } + emit("HTTPS 入口已启用;Certbot 续签成功后将自动重载 Nginx。\n") + return nil +} diff --git a/backend/internal/tlsservice/tlsservice.go b/backend/internal/tlsservice/tlsservice.go index 0fbba79..fde8e8c 100644 --- a/backend/internal/tlsservice/tlsservice.go +++ b/backend/internal/tlsservice/tlsservice.go @@ -228,9 +228,13 @@ func Status() CertificateStatus { status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode } if config.mode == "nginx" { - // In proxy mode, certbot owns the certificate; the Go service does not - // claim that Nginx is listening merely because a domain was saved. + // Certbot owns this certificate. Read only the public certificate and + // never mistake a configured domain for a listening HTTPS gateway. status.Domain = "" + if config.domain != "" { + readCertificate(filepath.Join("/etc/letsencrypt/live", config.domain, "fullchain.pem"), config.domain, &status) + if status.Valid { status.Domain = config.domain } + } return status } if !status.Enabled { @@ -248,6 +252,18 @@ func Status() CertificateStatus { return status } _, data = pem.Decode(data) + readCertificateBytes(data, config.domain, &status) + return status +} + +func readCertificate(path, domain string, status *CertificateStatus) { + data, err := os.ReadFile(path) + if err == nil { + readCertificateBytes(data, domain, status) + } +} + +func readCertificateBytes(data []byte, domain string, status *CertificateStatus) { for len(data) > 0 { block, rest := pem.Decode(data) if block == nil { @@ -258,7 +274,7 @@ func Status() CertificateStatus { continue } cert, err := x509.ParseCertificate(block.Bytes) - if err != nil || cert.VerifyHostname(config.domain) != nil { + if err != nil || cert.VerifyHostname(domain) != nil { break } status.Cached = true @@ -268,5 +284,4 @@ func Status() CertificateStatus { status.Valid = time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter) break } - return status } diff --git a/docs/deploy.md b/docs/deploy.md index d89e123..88f9ea0 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -65,9 +65,9 @@ Go 模式(默认)使用 `golang.org/x/crypto/acme/autocert` 监听 80/443, “预览 Nginx 配置”仅生成当前域名和后端实际注册 API 路由对应的两段站点配置,不会写入系统目录或启动服务。第一段监听 80,仅从 `/var/lib/opsx-vpn/acme` 提供 `/.well-known/acme-challenge/`;其他路径 `return 444`。第二段在证书**已签发后**监听 443:只反代已知前端页面、构建资源和已注册 API,其余路径 `return 444`;未知 TLS 主机名拒绝握手。切换模式必须按以下顺序进行(先备份现有站点,检查是否已有服务占用 80/443): -1. 可用引导按钮安装 `nginx`、`certbot`,检查云安全组放行 TCP 80/443 和域名 A/AAAA 记录;检查现有 `/etc/nginx/sites-enabled/`,若自带 default 站点占用 `default_server`,先备份并禁用该站点。若 Go 当前监听 80/443,先规划停机切换,不能同时启动 Nginx。 -2. 创建 `/var/lib/opsx-vpn/acme/.well-known/acme-challenge`,让 Nginx 工作账户能读取该目录;将页面预览的 HTTP 段保存为站点文件,先运行 `nginx -t`,再启动或重载 Nginx。先确认挑战路径从公网可读、其他 HTTP 请求断开,再执行 `certbot certonly --webroot -w /var/lib/opsx-vpn/acme -d test-link.appx365.com`。预览中的域名是页面实际配置的域名,替换示例命令中的域名。 -3. 确认证书和私钥存在于 `/etc/letsencrypt/live/<域名>/` 后,追加预览的 HTTPS 段;运行 `nginx -t` 后重载。设置 Certbot 续期定时器和 deploy hook:续期成功时先 `nginx -t`,再 `systemctl reload nginx`。运行 `certbot renew --dry-run` 并确认 hook 的实际生效方式(测试续期时可能需要 `--run-deploy-hooks`)。 +1. 在设置页安装 `nginx`、`certbot`,检查云安全组 TCP 80/443 和域名 A/AAAA 记录;保存公网域名与 Nginx 入口模式,重启 opsx-vpn 使模式生效。页面的“启用站点并申请证书”会核对已有站点和端口冲突,拒绝覆盖非默认站点;发行版默认软链接移至 `/etc/nginx/opsx-vpn-default.disabled`。Go 与 Nginx 不能同时监听 80/443。 +2. 点击启用操作后,服务生成 HTTP-01 站点并校验 `nginx -t`,使用 `certbot certonly --webroot` 申请证书;失败时页面展示过程与错误,并保留 HTTP 验证站点供重试。Certbot 当前使用无邮箱注册,续签异常不会发送邮件提醒。 +3. 证书签出后程序自动追加 HTTPS 站点、检查配置并重载,同时安装固定续签钩子(`nginx -t` + `systemctl reload nginx`)。确认 Certbot 续签定时器运行,执行 `certbot renew --dry-run` 检查续签;生产验证时再核对 `--run-deploy-hooks` 对钩子的实际效果。证书状态页读取实际的证书到期日;不要仅以站点文件存在判断公网可用。 4. 页面保存入口模式为 Nginx,启动或重启 opsx-vpn 并设置 `VPN_LISTEN_ADDR=127.0.0.1:18080`;确认 `https://<域名>/api/health`、登录及各业务页面可用,未知路径断开,80 的挑战仍可读,然后收紧 18080 公网安全组。若现有业务必须从公网直连 18080,先迁移这些客户端。 不要只开放 `/oauth2/callback`:独立的 OIDC 认证域名还要转发认证组件的完整登录流程,且应另为该域名申请证书。管理站点的路由预览不覆盖认证域名;真实认证域名和组件端口确认后单独配置。若代理和 VPN 主机分离,预览里的 `127.0.0.1` 指代理主机自身,应改成受限私网地址并保护传输链路。回退时先恢复原 Nginx 配置及 opsx-vpn 入口模式,运行 `nginx -t` 后重载,不删除 `data/` 或已有证书。 diff --git a/docs/specs/SDD-004-nginx-acme-entry.md b/docs/specs/SDD-004-nginx-acme-entry.md index 5d48d71..07095ac 100644 --- a/docs/specs/SDD-004-nginx-acme-entry.md +++ b/docs/specs/SDD-004-nginx-acme-entry.md @@ -3,7 +3,7 @@ | 字段 | 内容 | |---|---| | 级别 | S3(公网暴露面、系统服务、证书私钥) | -| 状态 | 本地实现入口模式保存、首次初始化与路由白名单预览;线上未安装 Nginx/Certbot,80/443 尚未启用 | +| 状态 | 已实现入口模式、依赖准备、管理员启用站点与申请证书;2026-10-22 线上已安装 Nginx/Certbot,80/443 待发布验证 | | 已有实现 | SDD-003 的 Go `autocert` 单域名监听已在本地完成,尚未上线 | | 目标 | 管理员从页面设置域名并查看证书;可选地由 Nginx 承担 80/443、HTTP-01 和认证反代,未知管理站点路径直接断开 | @@ -17,10 +17,10 @@ 首次引导增量:设置页将通用网络检查(root、默认出口、IPv4 转发)与可选的 Nginx 入口准备分开;只有管理员点击通用修复时才启用 IPv4 转发。各协议专属依赖仍在“创建专线”按所选协议单独检测、安装。Nginx 卡片只读检测 `/etc/os-release`、root、软件可用性;点击准备后以 POST 流接收逐条进度。服务端只执行 Ubuntu 的 `apt-get` 或 TencentOS Server 4 的 `dnf` 固定安装参数,创建 ACME webroot,不接受客户端命令、路径或包名。单次仅一项任务、各命令有超时;浏览器离开不取消已开始的系统包操作。不会写已有 Nginx 站点配置,也不会假称证书已签出。安装完成后须按下述步骤启用 HTTP 站点并签发。初始化失败保留软件包状态,重新检测后可重试,不删除已有配置。 -1. 由管理员先核对域名、云安全组 TCP 80/443、站点冲突、Nginx/Certbot 软件包和 Certbot 续期定时器;如启用 Go 监听,先规划停止 Go 80/443 后再启动 Nginx。页面只保存入口模式和预览配置,不会写入 `/etc/nginx`,也不会实际检测外部代理状态。 -2. 生成最小 HTTP 站点,仅让指定域名的 `/.well-known/acme-challenge/` 从固定 webroot 读取令牌;其他请求关闭。检查 `nginx -t` 后启动 Nginx,用 Certbot `--webroot` 首次签发。 -3. 证书签出后添加 443 站点;Nginx 配置要精确匹配管理站点路由,TLS 默认主机拒绝握手,认证入口仅以独立 Host 转发到 `127.0.0.1:19090+`。`nginx -t` 通过后 reload,再验证合法 API、SPA 深链接、未知路径、HTTP-01、证书域名和到期日。 -4. 验证续期的 `certbot renew --dry-run` 与 deploy hook;失败时恢复原站点配置与原 HTTP 服务,保留 `data/` 和证书缓存。Nginx 模式不把签发结果宣称为 Go `autocert` 的缓存证书。 +1. 由管理员核对域名、云安全组 TCP 80/443、站点冲突、Nginx/Certbot 软件包和 Certbot 续期定时器;先保存 `nginx` 模式并重启服务,避免 Go 占用 80/443。页面启用操作会检查现有启用站点及监听端口;拒绝覆盖非发行版默认站点或其他自定义站点。 +2. 生成最小 HTTP 站点,仅让指定域名的 `/.well-known/acme-challenge/` 从固定 webroot 读取令牌;其他请求关闭。检查 `nginx -t` 后启动 Nginx,用 Certbot `--webroot` 首次签发。发行版默认站点软链接移动至 `/etc/nginx/opsx-vpn-default.disabled`,失败时原路径恢复;签发失败保留 HTTP 验证站点供重试。 +3. 证书签出后添加 443 站点;Nginx 配置要精确匹配管理站点路由,TLS 默认主机拒绝握手,认证入口仅以独立 Host 转发到 `127.0.0.1:19090+`。`nginx -t` 通过后 reload,再验证合法 API、SPA 深链接、未知路径、HTTP-01、证书域名和到期日。管理员 API 的 NDJSON 输出在页面实时展示;证书公钥元数据从 Certbot 路径读取,私钥不返回。 +4. 验证续期的 `certbot renew --dry-run` 与 deploy hook;证书更新后通过固定钩子校验、重载 Nginx。HTTP 站点安装失败时恢复原配置;证书签发失败保留 HTTP 验证站点;HTTPS 配置校验失败时退回 HTTP 站点。保留 `data/` 和证书缓存。Nginx 模式不把签发结果宣称为 Go `autocert` 的缓存证书。当前 Certbot 注册不提供邮箱,续签异常须主动监控。 ## 验收 @@ -34,4 +34,4 @@ ## 边界 -`return 444` 是 Nginx 的断连响应,不是权限控制;合法接口仍必须由 Gin 鉴权。Go `autocert` 模式不会提供 `return 444`。不能用路径白名单阻断 ACME 续期、MFA 或 WebAuth。认证域名与管理域名若合并,需要重新设计路径分发及 Cookie 隔离,不能直接套用独立域名配置。安装初始化已提供页面按钮;签发、续期、Nginx 站点启用及认证域名接入尚未开发成页面一键操作,也未在生产环境执行。 +`return 444` 是 Nginx 的断连响应,不是权限控制;合法接口仍必须由 Gin 鉴权。Go `autocert` 模式不会提供 `return 444`。不能用路径白名单阻断 ACME 续期、MFA 或 WebAuth。认证域名与管理域名若合并,需要重新设计路径分发及 Cookie 隔离,不能直接套用独立域名配置。认证域名接入仍待独立域名和真实发行者信息;Certbot 续签 dry-run 与公网联调以实际执行记录为准。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 37929c4..147d917 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -753,14 +753,15 @@ }, "tls": { "title": "Domain & certificate", - "description": "Use OpsX VPN for automatic HTTP-01 certificates or preview an external Nginx / Certbot gateway.", + "description": "Configure a public domain and use Nginx / Certbot for HTTP-01 issuance and renewal.", "domain": "Configured domain", "activeDomain": "Active domain", "saveDomain": "Save domain", "restartRequired": "Domain saved; restart OpsX VPN to enable ports 80/443 and automatic issuance", "saveFailed": "Could not save domain", "dns": "DNS", "certificate": "Certificate", "valid": "Valid", "pending": "Pending first HTTPS handshake", "notConfigured": "Disabled", "saved": "Domain configuration 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.", "refresh": "Refresh status", "openHttps": "Open HTTPS health check", "loadFailed": "Could not load certificate status", "mode": "Public entry mode", "goMode": "Go automatic certificate (binds 80/443)", "nginxMode": "Nginx proxy (external 80/443)", "saveMode": "Save entry mode", - "nginxHint": "Saving this mode does not install or start Nginx. Install Nginx and Certbot, verify issuance, configuration and renewal, then restart OpsX VPN. Go will no longer bind 80/443.", - "externalRenewal": "Managed by Certbot (separate deployment required)", "previewNginx": "Preview Nginx configuration", "previewOnly": "Read-only preview; nothing has been installed or enabled. Use HTTP for issuance first, then enable HTTPS after the certificate exists.", "httpSite": "HTTP-01 site", "httpsSite": "HTTPS management site", + "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.", + "externalRenewal": "Managed by Certbot", "previewNginx": "Preview Nginx configuration", "previewOnly": "Configuration preview; HTTP handles issuance and HTTPS starts after the certificate exists.", "httpSite": "HTTP-01 site", "httpsSite": "HTTPS management site", + "siteStage": "Site stage", "stage_none": "Inactive", "stage_http": "HTTP-01 ready; certificate pending", "stage_https": "HTTPS configuration enabled", "activateSite": "Enable site and issue certificate", "activateHint": "Checks existing sites and ports first. Certbot registration uses no email address, so renewal failures cannot be reported by email.", "siteReady": "HTTPS site and certificate are ready", "siteFailed": "Gateway or certificate setup failed; see output", "setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server", "commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled" }, diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 4924411..4299941 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -753,14 +753,15 @@ }, "tls": { "title": "域名与证书", - "description": "可选择由 opsx-vpn 自动管理 HTTP-01 证书,或预览 Nginx / Certbot 外部入口配置。", + "description": "配置公网域名,使用 Nginx / Certbot 申请并自动续签 HTTP-01 证书。", "domain": "配置域名", "activeDomain": "当前监听域名", "saveDomain": "保存域名", "restartRequired": "域名已保存,重启 opsx-vpn 后才会监听 80/443 并启用自动签发", "saveFailed": "保存域名失败", "dns": "DNS 解析", "certificate": "证书", "valid": "有效", "pending": "等待首次 HTTPS 请求签发", "notConfigured": "未启用", "saved": "域名配置已保存", "issuer": "颁发者", "expires": "到期时间", "renewal": "续签", "automatic": "自动(autocert)", "ports": "HTTP / HTTPS 端口", "externalPorts": "由外部 Nginx 管理,尚未检测监听状态", "setupHint": "保存域名或入口模式后需重启生效;切换为 Nginx 前须按部署文档完成安装、证书签发和配置校验。Go 模式证书缓存于 data/acme;保存配置本身不会签发证书。", "refresh": "刷新状态", "openHttps": "打开 HTTPS 健康检查", "loadFailed": "读取证书状态失败", "mode": "公网入口模式", "goMode": "Go 自动证书(进程监听 80/443)", "nginxMode": "Nginx 代理(外部监听 80/443)", "saveMode": "保存入口模式", - "nginxHint": "仅保存模式不会安装或启动 Nginx。先安装 Nginx 和 Certbot,完成签发、配置检查与续期测试,再重启 opsx-vpn;重启后 Go 不再监听 80/443。", - "externalRenewal": "由 Certbot 管理(需单独部署)", "previewNginx": "预览 Nginx 配置", "previewOnly": "以下为只读配置预览,尚未安装或启用;HTTP 站点先用于签发,HTTPS 站点只在证书已存在后启用。", "httpSite": "HTTP-01 站点", "httpsSite": "HTTPS 管理站点", + "nginxHint": "选择 Nginx 模式并保存域名后重启服务,再点击启用站点申请证书。端口 80/443 需允许公网访问。", + "externalRenewal": "由 Certbot 管理", "previewNginx": "预览 Nginx 配置", "previewOnly": "以下为配置预览;HTTP 站点用于签发,HTTPS 站点在证书存在后启用。", "httpSite": "HTTP-01 站点", "httpsSite": "HTTPS 管理站点", + "siteStage": "站点阶段", "stage_none": "未启用", "stage_http": "HTTP-01 已准备,证书待签发", "stage_https": "HTTPS 配置已启用", "activateSite": "启用站点并申请证书", "activateHint": "启用前会检查现有站点及端口;冲突时停止并提示。证书采用 Certbot 无邮箱注册,续签失败时不会收到邮件通知。", "siteReady": "HTTPS 站点与证书已就绪", "siteFailed": "站点或证书配置失败,请查看输出", "setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续", "commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启" }, diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 80d2feb..4dac74f 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -55,7 +55,7 @@ {{ $t('tls.refresh') }} - + @@ -65,6 +65,7 @@ @@ -92,7 +93,7 @@ {{ tlsStatus.configured_domain || $t('tls.notConfigured') }} {{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }} - {{ tlsStatus.domain || $t('tls.notConfigured') }} + {{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }} {{ tlsStatus.dns?.join(', ') || '—' }} {{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }} @@ -104,7 +105,10 @@

{{ $t('tls.setupHint') }}

{{ $t('tls.refresh') }} - {{ $t('tls.openHttps') }} + {{ $t('tls.openHttps') }} + {{ $t('tls.activateSite') }} +

{{ $t('tls.activateHint') }}

+
{{ nginxActivateLog }}
{{ $t('tls.previewNginx') }}