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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user