// 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" "crypto/tls" "crypto/x509" "errors" "fmt" "net" "os" "os/exec" "path/filepath" "regexp" "strconv" "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 var managedDomain = regexp.MustCompile(`(?m)^\s*server_name ([a-z0-9.-]+);$`) var managedHTTPPort = regexp.MustCompile(`(?m)^\s*listen ([0-9]+) default_server;$`) var managedHTTPSPort = regexp.MustCompile(`(?m)^\s*listen ([0-9]+) ssl default_server;$`) // ManagedEntry reads the actual public address from this application's Nginx // site. It does not trust a persisted draft when the two differ. func ManagedEntry() (nginxconfig.RenderOptions, error) { data, err := os.ReadFile(sitePath) if err != nil { return nginxconfig.RenderOptions{}, err } return parseManagedEntry(string(data)) } func parseManagedEntry(body string) (nginxconfig.RenderOptions, error) { scheme := "http" portPattern := managedHTTPPort if strings.Contains(body, "# Add only after the certificate exists.") { scheme, portPattern = "https", managedHTTPSPort } else if !strings.Contains(body, "# Plain HTTP public entry") { return nginxconfig.RenderOptions{}, errors.New("站点不是 opsx-vpn 托管配置") } domains := managedDomain.FindAllStringSubmatch(body, -1) ports := portPattern.FindStringSubmatch(body) if len(domains) == 0 || len(ports) != 2 || !tlsservice.ValidPublicHost(scheme, domains[0][1]) { return nginxconfig.RenderOptions{}, errors.New("无法识别托管站点的公网地址") } for _, match := range domains[1:] { if match[1] != domains[0][1] { return nginxconfig.RenderOptions{}, errors.New("托管站点包含多个不同域名") } } port, err := strconv.Atoi(ports[1]) if err != nil || port < 1 || port > 65535 { return nginxconfig.RenderOptions{}, errors.New("托管站点端口无效") } return nginxconfig.RenderOptions{Domain: domains[0][1], Scheme: scheme, Port: port}, nil } 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" } body := string(data) if strings.Contains(body, "# Add only after the certificate exists.") { return "https" } if strings.Contains(body, "# Plain HTTP public entry") || strings.Contains(body, "server_name ") { return "http" } 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 { status := tlsservice.Status() scheme := status.Scheme if scheme == "" { scheme = "https" } return RefreshAuthEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: scheme, Port: status.Port}, routes, authPort) } func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int) error { return refreshEntry(opts, routes, authPort, false, "", nil) } // UpdateEntry applies a validated public entry to an existing managed site. // persist is called after a successful reload; failure restores the old site. func UpdateEntry(opts nginxconfig.RenderOptions, expectedDomain string, routes gin.RoutesInfo, authPort int, persist func() error) error { if !Begin() { return errors.New("公网入口操作正在进行") } defer End() return refreshEntry(opts, routes, authPort, true, expectedDomain, persist) } func refreshEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int, allowChange bool, expectedDomain string, persist func() error) error { if Running() && !allowChange { return errors.New("公网入口操作正在进行") } scheme := strings.ToLower(strings.TrimSpace(opts.Scheme)) if scheme == "" { scheme = "https" } opts.Scheme = scheme if scheme == "http" { if err := checkSitesHTTP(); err != nil { return err } } else if err := checkSites(); err != nil { return err } stage := Stage() if scheme == "https" && stage != "https" && !allowChange { return errors.New("请先启用 HTTPS 站点") } if scheme == "http" && stage == "none" { return errors.New("请先启用公网入口站点") } previous, err := os.ReadFile(sitePath) if err != nil { return err } if allowChange && !strings.Contains(string(previous), "# Install this HTTP site first.") && !strings.Contains(string(previous), "# Plain HTTP public entry") { return errors.New("现有站点不是 opsx-vpn 托管配置,拒绝覆盖") } if allowChange && (expectedDomain == "" || !strings.Contains(string(previous), "server_name "+expectedDomain+";")) { return errors.New("现有站点域名与运行配置不匹配,拒绝覆盖") } domain := strings.ToLower(strings.TrimSpace(opts.Domain)) if !allowChange && !strings.Contains(string(previous), "server_name "+domain+";") { return errors.New("现有站点域名与配置不匹配") } if allowChange && scheme == "https" { fullchain, privkey, _ := tlsservice.CertificateFiles(domain) pair, err := tls.LoadX509KeyPair(fullchain, privkey) if err != nil || len(pair.Certificate) == 0 { return errors.New("目标域名没有可用的 HTTPS 证书,请先准备证书") } leaf, err := x509.ParseCertificate(pair.Certificate[0]) if err != nil || leaf.VerifyHostname(domain) != nil || time.Now().Before(leaf.NotBefore) || !time.Now().Before(leaf.NotAfter) { return errors.New("目标域名证书无效或已过期") } } if allowChange { listenPort := tlsservice.EffectivePort(scheme, opts.Port) portAlreadyManaged := strings.Contains(string(previous), fmt.Sprintf("listen %d;", listenPort)) || strings.Contains(string(previous), fmt.Sprintf("listen %d ssl", listenPort)) if !portAlreadyManaged { listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", listenPort)) if err != nil { return fmt.Errorf("目标端口 %d 已占用: %w", listenPort, err) } listener.Close() } } httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort) if err != nil { return err } if string(previous) == httpSite+httpsSite { if persist != nil { return persist() } return nil } validate := func() error { return command(func(string) {}, 15*time.Second, "nginx", "-t") } reload := func() error { return command(func(string) {}, 15*time.Second, "systemctl", "reload", "nginx") } return swapManagedSite(string(previous), httpSite+httpsSite, persist, writeSite, validate, reload) } func swapManagedSite(previous, next string, persist func() error, write func(string) error, validate, reload func() error) error { if err := write(next); err != nil { return err } rollback := func(cause error) error { if err := write(previous); err != nil { return fmt.Errorf("%w;恢复旧站点失败: %v", cause, err) } if err := validate(); err != nil { return fmt.Errorf("%w;旧站点配置校验失败: %v", cause, err) } if err := reload(); err != nil { return fmt.Errorf("%w;恢复旧站点重载失败: %v", cause, err) } return cause } if err := validate(); err != nil { return rollback(err) } if err := reload(); err != nil { return rollback(err) } if persist != nil { if err := persist(); err != nil { return rollback(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 checkSitesHTTP() error { if os.Geteuid() != 0 { return errors.New("配置入口需要 root 权限") } state := nginxsetup.Inspect() if !state.Nginx { return errors.New("请先安装 Nginx") } 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()) } } } return nil } 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 { fullchain, _, _ := tlsservice.CertificateFiles(domain) _, err := os.Stat(fullchain) 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 { return RunEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, emit) } // RunEntry activates the public entry for the given scheme/port. HTTP skips ACME. func RunEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, emit func(string)) error { return RunEntryWithAuth(opts, routes, 0, emit) } // RunEntryWithAuth preserves a running OpenVPN browser-auth upstream when // regenerating an already managed site. func RunEntryWithAuth(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int, emit func(string)) error { domain := strings.ToLower(strings.TrimSpace(opts.Domain)) scheme := strings.ToLower(strings.TrimSpace(opts.Scheme)) if scheme == "" { scheme = "https" } opts.Domain, opts.Scheme = domain, scheme if !tlsservice.ValidPublicHost(scheme, domain) { return errors.New("请先保存有效域名;HTTP 也支持 IPv4 地址") } if !tlsservice.ValidScheme(scheme) { return errors.New("协议仅支持 http 或 https") } if scheme == "http" { if err := checkSitesHTTP(); err != nil { return err } } else if err := checkSites(); err != nil { return err } httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort) 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 { ports := []string{fmt.Sprintf("%d", tlsservice.EffectivePort(scheme, opts.Port))} if scheme == "https" && tlsservice.EffectivePort(scheme, opts.Port) != 80 { ports = append([]string{"80"}, ports...) } seen := map[string]bool{} for _, port := range ports { if seen[port] { continue } seen[port] = true 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 } if scheme == "http" { emit("HTTP 公网入口已启用(未申请证书)。\n") return nil } 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 } if _, _, source := tlsservice.CertificateFiles(domain); source == tlsservice.CertSourceHTTP01 { _ = tlsservice.MarkCertSource(tlsservice.DataDir(), tlsservice.CertSourceHTTP01) } emit("HTTPS 入口已启用;Certbot 续签成功后将自动重载 Nginx。\n") return nil }