Add managed Nginx HTTP-01 gateway activation and certificate status
This commit is contained in:
1 parent
6a1bace3e9
commit
cb28b235ef
10 files changed
+379
-23
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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/` 或已有证书。
|
||||
|
||||
@@ -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 与公网联调以实际执行记录为准。
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 转发已开启"
|
||||
},
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
<el-button :loading="hostLoading" @click="loadHostReadiness">{{ $t('tls.refresh') }}</el-button>
|
||||
</el-card>
|
||||
<el-steps :active="nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
|
||||
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
|
||||
<el-step :title="$t('tls.setupStepCheck')" />
|
||||
<el-step :title="$t('tls.setupStepInstall')" />
|
||||
<el-step :title="$t('tls.setupStepConfigure')" />
|
||||
@@ -65,6 +65,7 @@
|
||||
<template v-if="nginxSetup">
|
||||
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
|
||||
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
|
||||
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
|
||||
<el-alert v-if="!nginxSetup.supported || !nginxSetup.root" type="warning" :closable="false" :title="$t('tls.setupUnsupported')" style="margin-bottom:12px" />
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
|
||||
@@ -92,7 +93,7 @@
|
||||
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
|
||||
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.mode')">{{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.certificate')">
|
||||
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
|
||||
@@ -104,7 +105,10 @@
|
||||
</el-descriptions>
|
||||
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
|
||||
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
|
||||
<el-button v-if="tlsStatus?.enabled" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
|
||||
<el-button v-if="tlsStatus?.enabled || (tlsStatus?.certificate_valid && nginxSetup?.site_stage === 'https')" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
|
||||
<el-button type="primary" :loading="nginxActivating" :disabled="!nginxSetup?.root || !nginxSetup?.nginx_installed || !nginxSetup?.certbot_installed || !nginxSetup?.webroot_ready || nginxSetup?.running || tlsStatus?.mode !== 'nginx' || tlsStatus?.restart_required || !tlsStatus?.configured_domain" @click="activateNginx">{{ $t('tls.activateSite') }}</el-button>
|
||||
<p class="tls-hint">{{ $t('tls.activateHint') }}</p>
|
||||
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxActivateLog }}</pre>
|
||||
<el-button :loading="nginxLoading" :disabled="!tlsStatus?.configured_domain" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
|
||||
<template v-if="nginxPreview">
|
||||
<p class="tls-hint">{{ $t('tls.previewOnly') }}</p>
|
||||
@@ -188,6 +192,52 @@ const nginxSetupLoading = ref(false)
|
||||
const nginxInstalling = ref(false)
|
||||
const nginxInstallLog = ref('')
|
||||
const nginxLogView = ref(null)
|
||||
const nginxActivating = ref(false)
|
||||
const nginxActivateLog = ref('')
|
||||
const nginxActivateLogView = ref(null)
|
||||
|
||||
async function activateNginx() {
|
||||
nginxActivating.value = true
|
||||
nginxActivateLog.value = ''
|
||||
let completed = false
|
||||
try {
|
||||
const response = await fetch('/api/system/tls/nginx-activate', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(body.error || `${response.status} ${response.statusText}`)
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
const show = (line) => {
|
||||
if (!line) return
|
||||
const event = JSON.parse(line)
|
||||
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
|
||||
if (event.type === 'done') completed = true
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop()
|
||||
for (const line of lines) show(line)
|
||||
}
|
||||
if (buffer) show(buffer)
|
||||
if (!completed) throw new Error(t('tls.setupInterrupted'))
|
||||
ElMessage.success(t('tls.siteReady'))
|
||||
} catch (e) {
|
||||
nginxActivateLog.value += `\n${e.message}\n`
|
||||
ElMessage.error(t('tls.siteFailed'))
|
||||
} finally {
|
||||
nginxActivating.value = false
|
||||
await Promise.all([loadNginxSetup(), loadTLS()])
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNginxSetup() {
|
||||
nginxSetupLoading.value = true
|
||||
|
||||
Reference in new issue
Block a user