feat(tls): add optional nginx setup and gateway preview
This commit is contained in:
1 parent
9f743e19ad
commit
d2f1f5040e
15 files changed
+753
-16
No files matched your search
@@ -39,6 +39,7 @@ import (
|
||||
type Server struct {
|
||||
mgr *core.Manager
|
||||
taskMgr *taskmgr.Manager
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} }
|
||||
@@ -210,6 +211,7 @@ func requirePasswordChanged() gin.HandlerFunc {
|
||||
|
||||
// RegisterRoutes 权限:viewer 只读;operator 专线运维;admin 用户/LDAP/插件/审计
|
||||
func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
s.router = r
|
||||
r.Use(securityHeaders())
|
||||
api := r.Group("/api")
|
||||
api.POST("/login", s.login)
|
||||
@@ -286,6 +288,10 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
admin.POST("/system/fix", s.systemFix)
|
||||
admin.GET("/system/tls", s.tlsStatus)
|
||||
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
||||
admin.PUT("/system/tls/mode", s.setTLSMode)
|
||||
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.GET("/users", s.listUsers)
|
||||
admin.POST("/users", s.createUser)
|
||||
admin.PUT("/users/:id", s.updateUser)
|
||||
|
||||
@@ -1,19 +1,92 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/nginxconfig"
|
||||
"vpn-platform/backend/internal/nginxsetup"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/tlsservice"
|
||||
)
|
||||
|
||||
func (s *Server) nginxSetupStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, nginxsetup.Inspect())
|
||||
}
|
||||
|
||||
func (s *Server) initializeNginx(c *gin.Context) {
|
||||
status := nginxsetup.Inspect()
|
||||
if !status.Supported || !status.Root {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "仅支持 root 运行的 Ubuntu / TencentOS Server 4"})
|
||||
return
|
||||
}
|
||||
if !nginxsetup.Begin() {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "初始化正在运行,请等待完成"})
|
||||
return
|
||||
}
|
||||
defer nginxsetup.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", "开始初始化 Nginx / Certbot;此步骤不会启用 80/443 或签发证书。\n")
|
||||
err := nginxsetup.Run(func(message string) { write("output", message) })
|
||||
if err != nil {
|
||||
store.Audit(c.GetString("username"), "nginx_setup", "failed", err.Error())
|
||||
write("error", err.Error())
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "nginx_setup", "completed", "software and webroot ready")
|
||||
write("done", "初始化完成;继续配置 HTTP-01 站点与证书。")
|
||||
}
|
||||
|
||||
func (s *Server) tlsStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, tlsservice.Status())
|
||||
}
|
||||
|
||||
func (s *Server) nginxPreview(c *gin.Context) {
|
||||
status := tlsservice.Status()
|
||||
if status.ConfiguredDomain == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在页面保存域名"})
|
||||
return
|
||||
}
|
||||
httpSite, httpsSite, err := nginxconfig.Render(status.ConfiguredDomain, s.router.Routes())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"http_site": httpSite, "https_site": httpsSite,
|
||||
"warning": "仅预览:先安装 Nginx/Certbot,停用 Go 的 80/443 监听并签出证书;认证域名须单独配置。",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setTLSMode(c *gin.Context) {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入入口模式"})
|
||||
return
|
||||
}
|
||||
if err := tlsservice.ConfigureMode(service.BaseDir(), req.Mode); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "tls_mode_configured", req.Mode, "需检查外部入口后重启")
|
||||
status := tlsservice.Status()
|
||||
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired})
|
||||
}
|
||||
|
||||
func (s *Server) setTLSDomain(c *gin.Context) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package nginxconfig renders an opt-in Nginx gateway for the existing Gin routes.
|
||||
// Rendering is read-only: it never installs packages or writes system configuration.
|
||||
package nginxconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/tlsservice"
|
||||
)
|
||||
|
||||
var spaPages = []string{
|
||||
"/", "/login", "/mfa-verify", "/dashboard", "/tunnels",
|
||||
"/tunnels/create", "/sessions", "/logs", "/ipsec-peer",
|
||||
"/users", "/vpn-users", "/settings", "/favicon.svg",
|
||||
}
|
||||
|
||||
const upstream = " proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n"
|
||||
|
||||
func validRoute(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/") && !strings.ContainsAny(path, "\n\r{};\\")
|
||||
}
|
||||
|
||||
func routePattern(path string) string {
|
||||
segments := strings.Split(path, "/")
|
||||
for i, segment := range segments {
|
||||
if strings.HasPrefix(segment, ":") {
|
||||
segments[i] = "[^/]+"
|
||||
} else {
|
||||
segments[i] = regexp.QuoteMeta(segment)
|
||||
}
|
||||
}
|
||||
return "^" + strings.Join(segments, "/") + "$"
|
||||
}
|
||||
|
||||
// Render returns the HTTP-01 bootstrap site and the HTTPS site separately.
|
||||
// Enable the second site only after Certbot has issued the certificate.
|
||||
func Render(domain string, routes gin.RoutesInfo) (httpSite, httpsSite string, err error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if !tlsservice.ValidDomain(domain) {
|
||||
return "", "", fmt.Errorf("请输入有效的单个完整域名")
|
||||
}
|
||||
httpSite = fmt.Sprintf(`# Install this HTTP site first. Certbot webroot: /var/lib/opsx-vpn/acme
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444;
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name %s;
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/lib/opsx-vpn/acme;
|
||||
default_type text/plain;
|
||||
try_files $uri =404;
|
||||
}
|
||||
location / { return 444; }
|
||||
}
|
||||
`, domain)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `# Add only after the certificate exists. Keep the HTTP site for renewals.
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_reject_handshake on;
|
||||
return 444;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name %s;
|
||||
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
server_tokens off;
|
||||
if ($host != %s) { return 444; }
|
||||
`, domain, domain, domain, domain)
|
||||
for _, path := range spaPages {
|
||||
fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream)
|
||||
}
|
||||
b.WriteString(" location ~ ^/tunnels/[0-9]+/edit$ {\n" + upstream + " }\n")
|
||||
b.WriteString(" location ~ ^/assets/[A-Za-z0-9_.-]+\\.(js|css|svg|png|woff2)$ {\n" + upstream + " }\n")
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
for _, route := range routes {
|
||||
if !validRoute(route.Path) || seen[route.Path] {
|
||||
continue
|
||||
}
|
||||
seen[route.Path] = true
|
||||
paths = append(paths, route.Path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
if strings.Contains(path, ":") {
|
||||
fmt.Fprintf(&b, " location ~ %s {\n%s }\n", routePattern(path), upstream)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream)
|
||||
}
|
||||
}
|
||||
b.WriteString(" location / { return 444; }\n}\n")
|
||||
return httpSite, b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package nginxconfig
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRenderAllowlistAndHTTP01(t *testing.T) {
|
||||
routes := gin.RoutesInfo{
|
||||
{Method: "GET", Path: "/api/health"},
|
||||
{Method: "GET", Path: "/api/tunnels/:id/status"},
|
||||
{Method: "POST", Path: "/api/tunnels/:id/start"},
|
||||
{Method: "PUT", Path: "/api/tunnels/:id/start"},
|
||||
}
|
||||
httpSite, httpsSite, err := Render("test-link.appx365.com", routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fragment := range []string{"server_name test-link.appx365.com;", "location ^~ /.well-known/acme-challenge/", "try_files $uri =404;", "location / { return 444; }"} {
|
||||
if !strings.Contains(httpSite, fragment) {
|
||||
t.Errorf("missing HTTP rule %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"ssl_reject_handshake on;", "location = /api/health",
|
||||
"if ($host != test-link.appx365.com) { return 444; }",
|
||||
"location ~ ^/api/tunnels/[^/]+/status$",
|
||||
"location ~ ^/api/tunnels/[^/]+/start$",
|
||||
"location = /login", "location = /settings", "location = /favicon.svg",
|
||||
"location / { return 444; }",
|
||||
} {
|
||||
if !strings.Contains(httpsSite, fragment) {
|
||||
t.Errorf("missing HTTPS rule %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Count(httpsSite, "location ~ ^/api/tunnels/[^/]+/start$") != 1 {
|
||||
t.Fatal("duplicate API paths must have one location")
|
||||
}
|
||||
pattern := regexp.MustCompile(routePattern("/api/tunnels/:id/status"))
|
||||
for _, input := range []string{"/api/tunnels/4/status", "/api/tunnels/abc/status"} {
|
||||
if !pattern.MatchString(input) {
|
||||
t.Errorf("valid route rejected: %s", input)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"/api/tunnels/4/status/evil", "/api/tunnels/4/config", "/.well-known/openid-configuration"} {
|
||||
if pattern.MatchString(input) {
|
||||
t.Errorf("unknown route accepted: %s", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) {
|
||||
if _, _, err := Render("example.com; return 200", nil); err == nil {
|
||||
t.Fatal("injected domain accepted")
|
||||
}
|
||||
_, httpsSite, err := Render("example.com", gin.RoutesInfo{{Path: "/api/health\nreturn 200;"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(httpsSite, "return 200;") {
|
||||
t.Fatal("injected route accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package nginxsetup prepares the host for a separately configured HTTP-01 gateway.
|
||||
package nginxsetup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Webroot = "/var/lib/opsx-vpn/acme"
|
||||
|
||||
var running atomic.Bool
|
||||
|
||||
type Status struct {
|
||||
OS string `json:"os"`
|
||||
Version string `json:"version"`
|
||||
PackageManager string `json:"package_manager"`
|
||||
Supported bool `json:"supported"`
|
||||
Root bool `json:"root"`
|
||||
Nginx bool `json:"nginx_installed"`
|
||||
Certbot bool `json:"certbot_installed"`
|
||||
Webroot bool `json:"webroot_ready"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
// ParseOSRelease never executes source content from /etc/os-release.
|
||||
func ParseOSRelease(raw string) (id, version string) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
switch key {
|
||||
case "ID":
|
||||
id = value
|
||||
case "VERSION_ID":
|
||||
version = value
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Plan(id, version string) (pm string, commands [][]string) {
|
||||
switch {
|
||||
case id == "ubuntu":
|
||||
return "apt-get", [][]string{{"apt-get", "update"}, {"apt-get", "install", "-y", "nginx", "certbot"}}
|
||||
case id == "tencentos" && strings.Split(version, ".")[0] == "4":
|
||||
return "dnf", [][]string{{"dnf", "install", "-y", "nginx", "certbot"}}
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func Inspect() Status {
|
||||
raw, _ := os.ReadFile("/etc/os-release")
|
||||
id, version := ParseOSRelease(string(raw))
|
||||
pm, _ := Plan(id, version)
|
||||
_, nginxErr := exec.LookPath("nginx")
|
||||
_, certbotErr := exec.LookPath("certbot")
|
||||
info, dirErr := os.Stat(filepath.Join(Webroot, ".well-known", "acme-challenge"))
|
||||
return Status{OS: id, Version: version, PackageManager: pm, Supported: pm != "", Root: os.Geteuid() == 0,
|
||||
Nginx: nginxErr == nil, Certbot: certbotErr == nil, Webroot: dirErr == nil && info.IsDir(), Running: running.Load()}
|
||||
}
|
||||
|
||||
func Begin() bool { return running.CompareAndSwap(false, true) }
|
||||
func End() { running.Store(false) }
|
||||
|
||||
// Run executes only the package commands selected by the server-side OS probe.
|
||||
// emit is called for command output while the process is running.
|
||||
func Run(emit func(string)) error {
|
||||
status := Inspect()
|
||||
if !status.Supported {
|
||||
return fmt.Errorf("仅支持 Ubuntu 和 TencentOS Server 4;当前为 %s %s", status.OS, status.Version)
|
||||
}
|
||||
if !status.Root {
|
||||
return fmt.Errorf("初始化需要以 root 运行的服务进程")
|
||||
}
|
||||
pm, commands := Plan(status.OS, status.Version)
|
||||
if _, err := exec.LookPath(pm); err != nil {
|
||||
return fmt.Errorf("缺少包管理器 %s: %w", pm, err)
|
||||
}
|
||||
if status.Nginx && status.Certbot {
|
||||
commands = nil
|
||||
}
|
||||
if len(commands) > 0 && pm == "apt-get" {
|
||||
// Debian maintainer scripts may start Nginx during installation. Prevent
|
||||
// that without replacing an administrator's pre-existing policy.
|
||||
policy := "/usr/sbin/policy-rc.d"
|
||||
file, err := os.OpenFile(policy, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法安全禁止安装时自动启动服务(%s):%w;请手动安装", policy, err)
|
||||
}
|
||||
if _, err := file.WriteString("#!/bin/sh\nexit 101\n"); err != nil {
|
||||
file.Close()
|
||||
os.Remove(policy)
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
os.Remove(policy)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(policy)
|
||||
emit("已临时禁止安装脚本自动启动服务;安装结束后恢复。\n")
|
||||
}
|
||||
for _, args := range commands {
|
||||
emit("$ " + strings.Join(args, " ") + "\n")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
|
||||
cmd.Stdout = &outputWriter{emit: emit}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
err := cmd.Run()
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 执行失败: %w", args[0], err)
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("nginx"); err != nil {
|
||||
return fmt.Errorf("安装后仍未找到 nginx: %w", err)
|
||||
}
|
||||
if _, err := exec.LookPath("certbot"); err != nil {
|
||||
return fmt.Errorf("安装后仍未找到 certbot: %w", err)
|
||||
}
|
||||
path := filepath.Join(Webroot, ".well-known", "acme-challenge")
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return fmt.Errorf("创建验证目录失败: %w", err)
|
||||
}
|
||||
emit("验证目录已就绪:" + path + "\n")
|
||||
emit("初始化完成。下一步配置 HTTP 站点、检查 nginx -t,再启动 Nginx 并签发证书。\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
type outputWriter struct {
|
||||
emit func(string)
|
||||
bytes int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (w *outputWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
n := len(p)
|
||||
if w.bytes < 2*1024*1024 {
|
||||
remaining := 2*1024*1024 - w.bytes
|
||||
if len(p) > remaining {
|
||||
p = p[:remaining]
|
||||
}
|
||||
w.emit(string(p))
|
||||
w.bytes += len(p)
|
||||
if w.bytes == 2*1024*1024 {
|
||||
w.emit("\n[安装日志显示达到 2MB 上限,命令继续运行]\n")
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package nginxsetup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReleaseSelectsOnlySupportedPackageCommands(t *testing.T) {
|
||||
for _, tc := range []struct{ raw, manager, install string }{
|
||||
{"NAME=Ubuntu\nID=ubuntu\nVERSION_ID=24.04\n", "apt-get", "apt-get install -y nginx certbot"},
|
||||
{"NAME=\"TencentOS Server\"\nVERSION=\"4\"\nID=tencentos\nVERSION_ID=\"4\"\n", "dnf", "dnf install -y nginx certbot"},
|
||||
{"ID=tencentos\nVERSION_ID=3\n", "", ""},
|
||||
{"ID=debian\nVERSION_ID=12\n", "", ""},
|
||||
} {
|
||||
id, version := ParseOSRelease(tc.raw)
|
||||
pm, commands := Plan(id, version)
|
||||
if pm != tc.manager {
|
||||
t.Errorf("%s: package manager %q", id, pm)
|
||||
}
|
||||
if tc.install != "" {
|
||||
got := strings.Join(commands[len(commands)-1], " ")
|
||||
if got != tc.install {
|
||||
t.Errorf("%s: install command %q", id, got)
|
||||
}
|
||||
} else if len(commands) != 0 {
|
||||
t.Errorf("%s should be unsupported", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOneInitializationAtATime(t *testing.T) {
|
||||
if !Begin() {
|
||||
t.Fatal("first initialization rejected")
|
||||
}
|
||||
defer End()
|
||||
if Begin() {
|
||||
t.Fatal("concurrent initialization accepted")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:
|
||||
|
||||
type configuration struct {
|
||||
domain string
|
||||
mode string
|
||||
dataDir string
|
||||
cacheDir string
|
||||
httpAddr string
|
||||
@@ -36,6 +37,8 @@ var state struct {
|
||||
|
||||
type CertificateStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
ConfiguredMode string `json:"configured_mode"`
|
||||
Domain string `json:"domain"`
|
||||
ConfiguredDomain string `json:"configured_domain"`
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
@@ -55,6 +58,20 @@ func ValidDomain(domain string) bool {
|
||||
}
|
||||
|
||||
func configFromEnvironment(dataDir string) (configuration, error) {
|
||||
mode := os.Getenv("VPN_TLS_MODE")
|
||||
if mode == "" {
|
||||
if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-mode")); err == nil {
|
||||
mode = strings.TrimSpace(string(raw))
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return configuration{}, fmt.Errorf("读取 TLS 模式配置: %w", err)
|
||||
}
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "autocert"
|
||||
}
|
||||
if mode != "autocert" && mode != "nginx" {
|
||||
return configuration{}, fmt.Errorf("VPN_TLS_MODE 仅支持 autocert 或 nginx")
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSpace(os.Getenv("VPN_TLS_DOMAIN")))
|
||||
if domain == "" {
|
||||
if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-domain")); err == nil {
|
||||
@@ -74,7 +91,7 @@ func configFromEnvironment(dataDir string) (configuration, error) {
|
||||
if httpsAddr == "" {
|
||||
httpsAddr = ":443"
|
||||
}
|
||||
return configuration{domain: domain, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil
|
||||
return configuration{domain: domain, mode: mode, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil
|
||||
}
|
||||
|
||||
func ConfigureDomain(dataDir, domain string) error {
|
||||
@@ -109,6 +126,37 @@ func ConfigureDomain(dataDir, domain string) error {
|
||||
return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-domain"))
|
||||
}
|
||||
|
||||
func ConfigureMode(dataDir, mode string) error {
|
||||
if os.Getenv("VPN_TLS_MODE") != "" {
|
||||
return fmt.Errorf("当前由 VPN_TLS_MODE 环境变量管理入口模式,请修改启动配置")
|
||||
}
|
||||
if mode != "autocert" && mode != "nginx" {
|
||||
return fmt.Errorf("入口模式仅支持 autocert 或 nginx")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(dataDir, ".tls-mode-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(temp.Name())
|
||||
defer temp.Close()
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.WriteString(mode + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-mode"))
|
||||
}
|
||||
|
||||
// Start keeps certificates in the persistent data directory. Issuance occurs
|
||||
// on the first TLS handshake, and autocert handles renewal.
|
||||
func Start(handler http.Handler, dataDir string) error {
|
||||
@@ -116,7 +164,7 @@ func Start(handler http.Handler, dataDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.domain == "" {
|
||||
if config.domain == "" || config.mode == "nginx" {
|
||||
state.Lock()
|
||||
state.config = config
|
||||
state.Unlock()
|
||||
@@ -173,10 +221,17 @@ func Status() CertificateStatus {
|
||||
state.RLock()
|
||||
config := state.config
|
||||
state.RUnlock()
|
||||
status := CertificateStatus{Enabled: config.domain != "", Domain: config.domain, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: "autocert"}
|
||||
status := CertificateStatus{Enabled: config.domain != "" && config.mode == "autocert", Domain: config.domain, Mode: config.mode, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: config.mode}
|
||||
if desired, err := configFromEnvironment(config.dataDir); err == nil {
|
||||
status.ConfiguredDomain = desired.domain
|
||||
status.RestartRequired = desired.domain != config.domain
|
||||
status.ConfiguredMode = desired.mode
|
||||
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.
|
||||
status.Domain = ""
|
||||
return status
|
||||
}
|
||||
if !status.Enabled {
|
||||
return status
|
||||
|
||||
@@ -70,3 +70,17 @@ func TestFailedHTTPSBindReleasesHTTPPort(t *testing.T) {
|
||||
t.Fatal("failed start reported active TLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxModeDoesNotBindGoListeners(t *testing.T) {
|
||||
t.Setenv("VPN_TLS_MODE", "nginx")
|
||||
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
|
||||
t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0")
|
||||
t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0")
|
||||
if err := Start(http.NotFoundHandler(), t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := Status()
|
||||
if status.Mode != "nginx" || status.Enabled || status.Domain != "" || status.ConfiguredDomain != "test-link.appx365.com" {
|
||||
t.Fatalf("proxy mode must not claim Go listens: %+v", status)
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -55,7 +55,22 @@ sudo ss -tlnp | grep ':18080'
|
||||
|
||||
认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。
|
||||
|
||||
该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com` 并重启服务,或通过 `VPN_TLS_DOMAIN=test-link.appx365.com` 配置(环境变量优先)。Gin 进程使用已有的 `golang.org/x/crypto/acme/autocert` 依赖监听 TCP 80/443,处理 HTTP-01、签发与自动续签;无需额外引入 `gin-gonic/autotls`。证书和 ACME 账号密钥保存在 `data/acme`(随数据目录备份)。端口空闲、公网 DNS 和云安全组 TCP 80/443 通畅是必要条件;首次访问 `https://test-link.appx365.com/api/health` 才开始签发。80/443 的进程需要绑定低端口的权限;现有以 sudo 运行的服务可直接使用。原端口 `18080` 保留用于兼容,启用 HTTPS 后应限制它的公网访问。保存域名不会自动打开云安全组,也不会为其他认证域名配置反向代理。可以选择外置 Nginx / Certbot,但不要让两者同时争用 80/443;若代理与 VPN 主机分离,代理上的 `127.0.0.1` 指代理主机自身,应改用受限的私网地址,并评估认证流量在私网链路上的保护。
|
||||
该域名须由你控制并解析到公网入口。管理员可在“设置 → 域名与证书”保存 `test-link.appx365.com`,并选择 Go 或 Nginx 入口模式;`VPN_TLS_DOMAIN` 和 `VPN_TLS_MODE` 环境变量优先于页面配置。两种模式只能选一种来占用 TCP 80/443,保存配置并不会安装 Nginx、开放安全组或签发证书。
|
||||
|
||||
Go 模式(默认)使用 `golang.org/x/crypto/acme/autocert` 监听 80/443,处理 HTTP-01、自动签发和续签;首次访问 `https://test-link.appx365.com/api/health` 才开始签发。证书和 ACME 账号密钥在 `data/acme`,须备份。此模式下 80 的非挑战请求会跳转 HTTPS,443 的管理站点沿用 Gin 路由,**不会**对未知路径执行 Nginx `return 444`。如需严格的公网路径白名单,使用下面的 Nginx 模式。端口空闲、公网 DNS 和云安全组 TCP 80/443 通畅是两种模式的共同前置条件。
|
||||
|
||||
### Nginx 模式:HTTP-01 与路径白名单
|
||||
|
||||
“设置 → 首次引导”先检查 root、默认出口、IPv4 转发;管理员可单独启用 IPv4 转发。OpenVPN、WireGuard、IPSec 等依赖仍在“创建专线”选择相应协议时单独安装,不会因进入引导页而批量安装。“可选 · HTTPS 公网入口 → 准备 Nginx / Certbot”会读取 `/etc/os-release`,仅在 Ubuntu 使用 `apt-get`、TencentOS Server 4 使用 `dnf` 安装 Nginx/Certbot,并创建 `/var/lib/opsx-vpn/acme/.well-known/acme-challenge`。页面会实时显示安装日志;需以 root 运行平台,其他系统显示不支持。Ubuntu 上如果已有 `/usr/sbin/policy-rc.d`,初始化会拒绝覆盖该文件;安装期间临时阻止软件包脚本自动启动 Nginx,正常结束后移除临时策略。若进程在安装时被强制杀死,检查并移除该临时策略后再试。TencentOS 上安装后也需核实发行版的软件包脚本没有启动默认站点;如发行版仓库没有 Certbot,页面报告失败,由管理员检查软件源。初始化不改写已有站点,不会主动启用 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`)。
|
||||
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/` 或已有证书。
|
||||
|
||||
`/.well-known/openid-configuration` 属于发行者域名,不是认证入口上的 VPN 回调。不要只放行 `/oauth2/callback`:认证组件还需要发起登录等路由。认证入口使用独立域名,只反代给认证组件;如需按路径白名单返回 Nginx `444`,须先核对实际安装版本的完整路由,并保障 HTTP-01 验证路径。认证端口默认 19090,仅监听回环;新增 OIDC 专线自动递增并跳过已配置或已监听的端口,仍以启动时冲突检测为准。
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# SDD-004:Nginx 公网认证入口与 HTTP-01
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 级别 | S3(公网暴露面、系统服务、证书私钥) |
|
||||
| 状态 | 本地实现入口模式保存、首次初始化与路由白名单预览;线上未安装 Nginx/Certbot,80/443 尚未启用 |
|
||||
| 已有实现 | SDD-003 的 Go `autocert` 单域名监听已在本地完成,尚未上线 |
|
||||
| 目标 | 管理员从页面设置域名并查看证书;可选地由 Nginx 承担 80/443、HTTP-01 和认证反代,未知管理站点路径直接断开 |
|
||||
|
||||
## 现状与决定
|
||||
|
||||
管理平台在 `:18080`,`openvpn-auth-oauth2` 按专线监听 `127.0.0.1:19090+`。2026-10-21 服务器未安装 Nginx/Certbot、80/443 未监听;公网证书尚未签发。Go `autocert` 与 Nginx 不能同时绑定 80/443,必须将入口模式明确区分。服务器的 `/.well-known/openid-configuration` 属于身份提供方的 issuer,并非本项目的 ACME 站点;不能放行所有 `/.well-known/` 子路径,只需 `/.well-known/acme-challenge/`。
|
||||
|
||||
推荐的 Nginx 模式:Certbot `--webroot` 签发,Nginx 提供 HTTP-01 和 HTTPS;Certbot 的系统定时器自动续期,并在证书更新后 reload Nginx。Go 保留 `:18080` 但不监听 80/443。管理站点域名(如 `test-link.appx365.com`)只放行现有 SPA 路由、前端静态资源和 Gin 已注册的 API 路由;其他路径 `return 444`。认证入口应使用独立域名(尚未提供真实域名),反代**所有**认证组件路由到其对应的回环端口;只放行 `/oauth2/callback` 会截断登录发起、状态及错误处理。认证组件的路由清单应在安装具体版本后核对,不能预先硬编码。
|
||||
|
||||
## 部署与回退
|
||||
|
||||
首次引导增量:设置页将通用网络检查(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` 的缓存证书。
|
||||
|
||||
## 验收
|
||||
|
||||
| 编号 | 验证 | 预期 | 状态 |
|
||||
|---|---|---|---|
|
||||
| NG-01 | 非法 Host、未知管理路径、任意 `/.well-known/` 路径 | 不转发,关闭连接;ACME 路径独立可访问 | 配置单测通过,实机待测 |
|
||||
| NG-02 | 登录、MFA、SPA 深链接、管理 API 和静态资源 | 正常访问,仍由平台做身份与权限校验 | 待测 |
|
||||
| NG-03 | 认证域名打开登录、回调和认证组件所需路径 | 全流程可达,组件只监听回环 | 待真实认证域名与组件 |
|
||||
| NG-04 | 首次签发与续期 dry-run | 证书有效、续期后 Nginx 重载 | 待服务器安装与部署 |
|
||||
| NG-05 | Ubuntu / TencentOS 4 首次引导 | 匹配各自包管理器、实时显示输出、并发重复请求拒绝;不覆盖已有站点 | 方案与并发单测通过,实机安装待测 |
|
||||
|
||||
## 边界
|
||||
|
||||
`return 444` 是 Nginx 的断连响应,不是权限控制;合法接口仍必须由 Gin 鉴权。Go `autocert` 模式不会提供 `return 444`。不能用路径白名单阻断 ACME 续期、MFA 或 WebAuth。认证域名与管理域名若合并,需要重新设计路径分发及 Cookie 隔离,不能直接套用独立域名配置。安装初始化已提供页面按钮;签发、续期、Nginx 站点启用及认证域名接入尚未开发成页面一键操作,也未在生产环境执行。
|
||||
@@ -288,6 +288,7 @@
|
||||
"depReady": "✓ Dependencies Ready",
|
||||
"notDeployed": "Not Deployed",
|
||||
"oneClickInstall": "One-Click Install",
|
||||
"onDemandHint": "Dependencies are checked without installing anything. Choose a protocol, then install only that protocol's missing dependencies from its card.",
|
||||
"installCmd": "Install Command",
|
||||
"customPlugin": "Custom Protocol Plugin",
|
||||
"customScene": "Custom Integration",
|
||||
@@ -730,11 +731,16 @@
|
||||
},
|
||||
"tls": {
|
||||
"title": "Domain & certificate",
|
||||
"description": "OpsX VPN can serve ports 80/443 with automatic ACME HTTP-01 issuance and renewal; Nginx and Certbot are optional.",
|
||||
"description": "Use OpsX VPN for automatic HTTP-01 certificates or preview an external Nginx / Certbot gateway.",
|
||||
"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": "Listening ports (HTTP / HTTPS)", "setupHint": "Save the domain and restart the server, then allow public TCP 80/443. The first HTTPS request issues a certificate and caches it in data/acme. VPN_TLS_DOMAIN may also be used and overrides the saved setting. Preserve the data directory.",
|
||||
"refresh": "Refresh status", "openHttps": "Open HTTPS health check", "loadFailed": "Could not load certificate status"
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
"depReady": "✓ 依赖就绪",
|
||||
"notDeployed": "未部署",
|
||||
"oneClickInstall": "一键安装",
|
||||
"onDemandHint": "只检测各协议的依赖,不会自动安装。选用哪个协议,再点击该协议卡片上的“一键安装”使其就绪。",
|
||||
"installCmd": "安装命令",
|
||||
"customPlugin": "自定义协议插件",
|
||||
"customScene": "自定义对接",
|
||||
@@ -730,11 +731,16 @@
|
||||
},
|
||||
"tls": {
|
||||
"title": "域名与证书",
|
||||
"description": "opsx-vpn 可直接监听 80/443,通过 ACME HTTP-01 自动签发和续签;无需 Nginx 或 Certbot。",
|
||||
"description": "可选择由 opsx-vpn 自动管理 HTTP-01 证书,或预览 Nginx / Certbot 外部入口配置。",
|
||||
"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)", "setupHint": "保存域名并重启服务,放行公网 TCP 80/443;证书将在该域名首次 HTTPS 访问时签发,缓存于 data/acme。也可用 VPN_TLS_DOMAIN 环境变量设置(优先于页面配置)。请保留数据目录。",
|
||||
"refresh": "刷新状态", "openHttps": "打开 HTTPS 健康检查", "loadFailed": "读取证书状态失败"
|
||||
"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 管理站点",
|
||||
"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 转发已开启"
|
||||
},
|
||||
"protocols": {
|
||||
"wireguard": {
|
||||
|
||||
@@ -142,7 +142,7 @@ const installDialogVisible = ref(false)
|
||||
const installDialogTitle = ref('')
|
||||
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
|
||||
|
||||
// 一键修复主机环境(IPv4 转发 / GRE 模块)
|
||||
// 通用环境修复只处理 IPv4 转发;GRE 模块属于按需使用的协议环境。
|
||||
async function fixHostEnv() {
|
||||
if (!readiness.value.root) {
|
||||
ElMessage.warning(t('dashboard.fixNeedRoot'))
|
||||
@@ -154,7 +154,6 @@ async function fixHostEnv() {
|
||||
try {
|
||||
const actions = []
|
||||
if (!readiness.value.ipv4_forwarding) actions.push(['ip_forward', t('dashboard.readinessForwarding')])
|
||||
if (readiness.value.gre_loaded === false) actions.push(['gre_module', 'GRE'])
|
||||
if (!actions.length) {
|
||||
ElMessage.info(t('dashboard.fixNothing'))
|
||||
return
|
||||
|
||||
@@ -46,15 +46,52 @@
|
||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||
<template #header><b>{{ $t('tls.title') }}</b></template>
|
||||
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header><b>{{ $t('tls.commonSetupTitle') }}</b></template>
|
||||
<el-alert type="info" :closable="false" :title="$t('tls.commonSetupHint')" style="margin-bottom:12px" />
|
||||
<template v-if="hostReadiness">
|
||||
<p class="tls-hint">{{ $t('tls.hostRoot') }}:{{ hostReadiness.root ? '✓' : '—' }} · {{ $t('tls.hostRoute') }}:{{ hostReadiness.default_route_ready ? '✓' : '—' }} · {{ $t('tls.hostForward') }}:{{ hostReadiness.ipv4_forwarding ? '✓' : '—' }}</p>
|
||||
<el-button :loading="hostFixing" :disabled="!hostReadiness.root || hostReadiness.ipv4_forwarding" @click="enableForwarding">{{ $t('tls.enableForwarding') }}</el-button>
|
||||
</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-step :title="$t('tls.setupStepCheck')" />
|
||||
<el-step :title="$t('tls.setupStepInstall')" />
|
||||
<el-step :title="$t('tls.setupStepConfigure')" />
|
||||
</el-steps>
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header><b>{{ $t('tls.setupTitle') }}</b></template>
|
||||
<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>
|
||||
<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" />
|
||||
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
|
||||
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
|
||||
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
|
||||
</el-card>
|
||||
<el-form @submit.prevent="saveTLSDomain" label-position="top">
|
||||
<el-form-item :label="$t('tls.domain')">
|
||||
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
|
||||
</el-form>
|
||||
<el-form label-position="top" style="margin-top:16px" @submit.prevent="saveTLSMode">
|
||||
<el-form-item :label="$t('tls.mode')">
|
||||
<el-select v-model="tlsMode" style="width:100%">
|
||||
<el-option value="autocert" :label="$t('tls.goMode')" />
|
||||
<el-option value="nginx" :label="$t('tls.nginxMode')" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
|
||||
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
|
||||
<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.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.certificate')">
|
||||
@@ -62,12 +99,20 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.http_address || ':80' }} / {{ tlsStatus.https_address || ':443' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : tlsStatus.mode === 'nginx' ? $t('tls.externalRenewal') : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.mode === 'nginx' ? $t('tls.externalPorts') : `${tlsStatus.http_address || ':80'} / ${tlsStatus.https_address || ':443'}` }}</el-descriptions-item>
|
||||
</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 :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>
|
||||
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
|
||||
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
|
||||
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
|
||||
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
|
||||
</template>
|
||||
</el-card>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
||||
@@ -112,6 +157,89 @@ const tlsStatus = ref(null)
|
||||
const tlsLoading = ref(false)
|
||||
const tlsSaving = ref(false)
|
||||
const tlsDomain = ref('')
|
||||
const tlsMode = ref('autocert')
|
||||
const tlsSavingMode = ref(false)
|
||||
const nginxLoading = ref(false)
|
||||
const nginxPreview = ref(null)
|
||||
const hostReadiness = ref(null)
|
||||
const hostLoading = ref(false)
|
||||
const hostFixing = ref(false)
|
||||
|
||||
async function loadHostReadiness() {
|
||||
hostLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/readiness')
|
||||
hostReadiness.value = data
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||
finally { hostLoading.value = false }
|
||||
}
|
||||
|
||||
async function enableForwarding() {
|
||||
hostFixing.value = true
|
||||
try {
|
||||
await api.post('/system/fix', { action: 'ip_forward' })
|
||||
await loadHostReadiness()
|
||||
ElMessage.success(t('tls.forwardingEnabled'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
|
||||
finally { hostFixing.value = false }
|
||||
}
|
||||
const nginxSetup = ref(null)
|
||||
const nginxSetupLoading = ref(false)
|
||||
const nginxInstalling = ref(false)
|
||||
const nginxInstallLog = ref('')
|
||||
const nginxLogView = ref(null)
|
||||
|
||||
async function loadNginxSetup() {
|
||||
nginxSetupLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/system/tls/nginx-setup')
|
||||
nginxSetup.value = data
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||
finally { nginxSetupLoading.value = false }
|
||||
}
|
||||
|
||||
async function initializeNginx() {
|
||||
nginxInstalling.value = true
|
||||
nginxInstallLog.value = ''
|
||||
let completed = false
|
||||
try {
|
||||
const response = await fetch('/api/system/tls/nginx-setup', {
|
||||
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)
|
||||
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
|
||||
if (event.type === 'done') completed = true
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.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.setupComplete'))
|
||||
} catch (e) {
|
||||
nginxInstallLog.value += `\n${e.message}\n`
|
||||
ElMessage.error(t('tls.setupFailed'))
|
||||
} finally {
|
||||
nginxInstalling.value = false
|
||||
await loadNginxSetup()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTLS() {
|
||||
tlsLoading.value = true
|
||||
@@ -119,15 +247,37 @@ async function loadTLS() {
|
||||
const { data } = await api.get('/system/tls')
|
||||
tlsStatus.value = data
|
||||
tlsDomain.value = data.configured_domain || data.domain || ''
|
||||
tlsMode.value = data.configured_mode || data.mode || 'autocert'
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||
finally { tlsLoading.value = false }
|
||||
}
|
||||
|
||||
async function saveTLSMode() {
|
||||
tlsSavingMode.value = true
|
||||
try {
|
||||
const { data } = await api.put('/system/tls/mode', { mode: tlsMode.value })
|
||||
tlsStatus.value = data.status
|
||||
nginxPreview.value = null
|
||||
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
|
||||
finally { tlsSavingMode.value = false }
|
||||
}
|
||||
|
||||
async function loadNginxPreview() {
|
||||
nginxLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/system/tls/nginx-preview')
|
||||
nginxPreview.value = data
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||||
finally { nginxLoading.value = false }
|
||||
}
|
||||
|
||||
async function saveTLSDomain() {
|
||||
tlsSaving.value = true
|
||||
try {
|
||||
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
|
||||
tlsStatus.value = data.status
|
||||
nginxPreview.value = null
|
||||
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
|
||||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
|
||||
finally { tlsSaving.value = false }
|
||||
@@ -148,7 +298,7 @@ async function changePwd() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isAdmin() && !auth.mustChange) loadTLS()
|
||||
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness() }
|
||||
try {
|
||||
const status = await api.get('/mfa/status')
|
||||
mfa.value.enabled = status.data.enabled
|
||||
@@ -200,4 +350,5 @@ function copyBackup() {
|
||||
background: var(--bg-card); border-radius: 4px;
|
||||
}
|
||||
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
|
||||
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:协议选择 -->
|
||||
<el-alert v-if="step === 0" type="info" :closable="false" :title="$t('tunnelWizard.onDemandHint')" style="margin-bottom:16px" />
|
||||
<div v-if="step === 0" class="proto-grid">
|
||||
<div v-for="(meta, key) in builtin" :key="key"
|
||||
class="proto-card"
|
||||
|
||||
Reference in new issue
Block a user