174 lines
6.7 KiB
Go
174 lines
6.7 KiB
Go
// 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", "/identity", "/plugins", "/nodes", "/favicon.svg",
|
|
}
|
|
|
|
type RenderOptions struct {
|
|
Domain string
|
|
Scheme string
|
|
Port int
|
|
}
|
|
|
|
func upstreamBlock(scheme string) string {
|
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
return fmt.Sprintf(" proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto %s;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n", scheme)
|
|
}
|
|
|
|
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) {
|
|
return RenderWithOptions(RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, 0)
|
|
}
|
|
|
|
// RenderWithAuth optionally exposes a single local OpenVPN browser-auth service.
|
|
// All other unknown paths retain the existing 444 catch-all.
|
|
func RenderWithAuth(domain string, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) {
|
|
return RenderWithOptions(RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, authPort)
|
|
}
|
|
|
|
// RenderWithOptions renders gateway sites for the configured public scheme/port.
|
|
func RenderWithOptions(opts RenderOptions, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) {
|
|
domain := strings.ToLower(strings.TrimSpace(opts.Domain))
|
|
scheme := strings.ToLower(strings.TrimSpace(opts.Scheme))
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
if !tlsservice.ValidPublicHost(scheme, domain) {
|
|
return "", "", fmt.Errorf("请输入有效域名;HTTP 也支持 IPv4 地址")
|
|
}
|
|
if !tlsservice.ValidScheme(scheme) {
|
|
return "", "", fmt.Errorf("协议仅支持 http 或 https")
|
|
}
|
|
if opts.Port < 0 || opts.Port > 65535 {
|
|
return "", "", fmt.Errorf("端口无效")
|
|
}
|
|
if authPort != 0 && (authPort < 1024 || authPort > 65535) {
|
|
return "", "", fmt.Errorf("认证端口无效")
|
|
}
|
|
listenPort := tlsservice.EffectivePort(scheme, opts.Port)
|
|
if scheme == "https" && listenPort == 80 {
|
|
return "", "", fmt.Errorf("HTTPS 不能占用 HTTP-01 验证端口 80")
|
|
}
|
|
if listenPort == 18080 || listenPort == authPort {
|
|
return "", "", fmt.Errorf("公网端口与本机服务端口冲突")
|
|
}
|
|
upstream := upstreamBlock(scheme)
|
|
|
|
if scheme == "http" {
|
|
httpSite = renderProxyServer(domain, listenPort, false, "", upstream, routes, authPort, scheme)
|
|
return httpSite, "", nil
|
|
}
|
|
|
|
// HTTPS: keep :80 for HTTP-01 / renewals; public app listens on listenPort.
|
|
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)
|
|
httpsSite = renderProxyServer(domain, listenPort, true, domain, upstream, routes, authPort, scheme)
|
|
return httpSite, httpsSite, nil
|
|
}
|
|
|
|
func renderProxyServer(domain string, listenPort int, ssl bool, certDomain, upstream string, routes gin.RoutesInfo, authPort int, scheme string) string {
|
|
var b strings.Builder
|
|
if ssl {
|
|
b.WriteString("# Add only after the certificate exists. Keep the HTTP site for renewals.\n")
|
|
fmt.Fprintf(&b, "server {\n listen %d ssl default_server;\n server_name _;\n ssl_reject_handshake on;\n return 444;\n}\n", listenPort)
|
|
fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", listenPort, domain)
|
|
fullchain, privkey, _ := tlsservice.CertificateFiles(certDomain)
|
|
fmt.Fprintf(&b, " ssl_certificate %s;\n", fullchain)
|
|
fmt.Fprintf(&b, " ssl_certificate_key %s;\n", privkey)
|
|
b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n server_tokens off;\n")
|
|
fmt.Fprintf(&b, " if ($host != %s) { return 444; }\n", domain)
|
|
} else {
|
|
b.WriteString("# Plain HTTP public entry (no ACME required).\n")
|
|
fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 444;\n}\n", listenPort)
|
|
fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n server_tokens off;\n", listenPort, domain)
|
|
fmt.Fprintf(&b, " if ($host != %s) { return 444; }\n", 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")
|
|
// Public issuer endpoints are fixed: unknown /id/* requests still get 444.
|
|
for _, path := range []string{"/id/.well-known/openid-configuration", "/id/jwks", "/id/authorize", "/id/feishu/callback", "/id/token", "/id/userinfo"} {
|
|
fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream)
|
|
}
|
|
if authPort != 0 {
|
|
fmt.Fprintf(&b, " location ^~ /oauth2/ {\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto %s;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", scheme, authPort)
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var paths []string
|
|
for _, route := range routes {
|
|
// The issuer locations above are deliberately enumerated. Gin also
|
|
// reports them as routes; emitting them twice makes nginx -t fail.
|
|
if strings.HasPrefix(route.Path, "/id/") {
|
|
continue
|
|
}
|
|
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 b.String()
|
|
}
|