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