// 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" } // AuthRouteReady reflects the installed managed site, not actual Feishu reachability. func AuthRouteReady(port int) (bool, bool) { data, err := os.ReadFile(sitePath) if err != nil { return false, false } return strings.Contains(string(data), "location = /id/.well-known/openid-configuration"), port > 0 && strings.Contains(string(data), fmt.Sprintf("proxy_pass http://127.0.0.1:%d;", port)) } // RefreshAuth updates only the managed site after validation; a bad config is // rolled back before returning. It never provisions a certificate or domain. func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error { if Running() { return errors.New("公网入口操作正在进行") } if err := checkSites(); err != nil { return err } if Stage() != "https" { return errors.New("请先启用 HTTPS 站点") } previous, err := os.ReadFile(sitePath) if err != nil { return err } if !strings.Contains(string(previous), "server_name "+domain+";") { return errors.New("现有站点域名与配置不匹配") } httpSite, httpsSite, err := nginxconfig.RenderWithAuth(domain, routes, authPort) if err != nil { return err } if err := writeSite(httpSite + httpsSite); err != nil { return err } rollback := func() { _ = writeSite(string(previous)) _ = command(func(string) {}, 15*time.Second, "nginx", "-t") _ = command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx") } if err := command(func(string) {}, 15*time.Second, "nginx", "-t"); err != nil { rollback() return err } if err := command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx"); err != nil { rollback() return err } return nil } 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 }