Files
opsx-vpn/backend/internal/nginxgateway/gateway.go
T

380 lines
11 KiB
Go

// 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"
}
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 {
if Running() {
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" {
return errors.New("请先启用 HTTPS 站点")
}
if scheme == "http" && stage == "none" {
return errors.New("请先启用公网入口站点")
}
previous, err := os.ReadFile(sitePath)
if err != nil {
return err
}
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
if !strings.Contains(string(previous), "server_name "+domain+";") {
return errors.New("现有站点域名与配置不匹配")
}
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort)
if err != nil {
return err
}
if string(previous) == httpSite+httpsSite {
return nil
}
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 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 {
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.ValidDomain(domain) {
return errors.New("请先保存有效域名")
}
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, 0)
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
}