feat(tls): add optional nginx setup and gateway preview
This commit is contained in:
1 parent
9f743e19ad
commit
d2f1f5040e
15 files changed
+753
-16
No files matched your search
@@ -0,0 +1,107 @@
|
||||
// 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", "/favicon.svg",
|
||||
}
|
||||
|
||||
const upstream = " proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n"
|
||||
|
||||
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) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if !tlsservice.ValidDomain(domain) {
|
||||
return "", "", fmt.Errorf("请输入有效的单个完整域名")
|
||||
}
|
||||
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)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `# Add only after the certificate exists. Keep the HTTP site for renewals.
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_reject_handshake on;
|
||||
return 444;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name %s;
|
||||
ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
server_tokens off;
|
||||
if ($host != %s) { return 444; }
|
||||
`, domain, domain, domain, 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")
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
for _, route := range routes {
|
||||
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 httpSite, b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package nginxconfig
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRenderAllowlistAndHTTP01(t *testing.T) {
|
||||
routes := gin.RoutesInfo{
|
||||
{Method: "GET", Path: "/api/health"},
|
||||
{Method: "GET", Path: "/api/tunnels/:id/status"},
|
||||
{Method: "POST", Path: "/api/tunnels/:id/start"},
|
||||
{Method: "PUT", Path: "/api/tunnels/:id/start"},
|
||||
}
|
||||
httpSite, httpsSite, err := Render("test-link.appx365.com", routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fragment := range []string{"server_name test-link.appx365.com;", "location ^~ /.well-known/acme-challenge/", "try_files $uri =404;", "location / { return 444; }"} {
|
||||
if !strings.Contains(httpSite, fragment) {
|
||||
t.Errorf("missing HTTP rule %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"ssl_reject_handshake on;", "location = /api/health",
|
||||
"if ($host != test-link.appx365.com) { return 444; }",
|
||||
"location ~ ^/api/tunnels/[^/]+/status$",
|
||||
"location ~ ^/api/tunnels/[^/]+/start$",
|
||||
"location = /login", "location = /settings", "location = /favicon.svg",
|
||||
"location / { return 444; }",
|
||||
} {
|
||||
if !strings.Contains(httpsSite, fragment) {
|
||||
t.Errorf("missing HTTPS rule %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Count(httpsSite, "location ~ ^/api/tunnels/[^/]+/start$") != 1 {
|
||||
t.Fatal("duplicate API paths must have one location")
|
||||
}
|
||||
pattern := regexp.MustCompile(routePattern("/api/tunnels/:id/status"))
|
||||
for _, input := range []string{"/api/tunnels/4/status", "/api/tunnels/abc/status"} {
|
||||
if !pattern.MatchString(input) {
|
||||
t.Errorf("valid route rejected: %s", input)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"/api/tunnels/4/status/evil", "/api/tunnels/4/config", "/.well-known/openid-configuration"} {
|
||||
if pattern.MatchString(input) {
|
||||
t.Errorf("unknown route accepted: %s", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) {
|
||||
if _, _, err := Render("example.com; return 200", nil); err == nil {
|
||||
t.Fatal("injected domain accepted")
|
||||
}
|
||||
_, httpsSite, err := Render("example.com", gin.RoutesInfo{{Path: "/api/health\nreturn 200;"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(httpsSite, "return 200;") {
|
||||
t.Fatal("injected route accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user