feat: configure HTTP/HTTPS public entry and managed certificates

This commit is contained in:
Codex committed 2026-10-26 14:06:41 +08:00
1 parent 35596cda9d
commit 88ea6b7854
21 files changed
+1309 -149

No files matched your search

+67 -22
View File
@@ -19,7 +19,19 @@ var spaPages = []string{
"/users", "/vpn-users", "/settings", "/identity", "/plugins", "/nodes", "/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"
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{};\\")
@@ -40,19 +52,49 @@ func routePattern(path string) string {
// 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 RenderWithAuth(domain, routes, 0)
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) {
domain = strings.ToLower(strings.TrimSpace(domain))
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.ValidDomain(domain) {
return "", "", fmt.Errorf("请输入有效的单个完整域名")
}
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;
@@ -70,24 +112,27 @@ server {
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;
httpsSite = renderProxyServer(domain, listenPort, true, domain, upstream, routes, authPort, scheme)
return httpSite, httpsSite, nil
}
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)
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)
}
@@ -98,7 +143,7 @@ server {
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 https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", authPort)
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)
@@ -124,5 +169,5 @@ server {
}
}
b.WriteString(" location / { return 444; }\n}\n")
return httpSite, b.String(), nil
return b.String()
}
@@ -91,3 +91,39 @@ func TestRenderIdentityAndSingleAuthPort(t *testing.T) {
t.Fatal("auth component exposed without a configured port")
}
}
func TestRenderHTTPCustomPort(t *testing.T) {
routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}
httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "http", Port: 8080}, routes, 0)
if err != nil {
t.Fatal(err)
}
if httpsSite != "" {
t.Fatalf("http scheme must not render https site, got %d bytes", len(httpsSite))
}
for _, fragment := range []string{"listen 8080;", "server_name vpn.example.com;", "X-Forwarded-Proto http;", "location = /api/health"} {
if !strings.Contains(httpSite, fragment) {
t.Errorf("missing HTTP rule %q in:\n%s", fragment, httpSite)
}
}
if strings.Contains(httpSite, "acme-challenge") {
t.Fatal("http entry must not include ACME challenge location")
}
}
func TestRenderHTTPSCustomPort(t *testing.T) {
routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}
httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(httpSite, "listen 80;") || !strings.Contains(httpSite, "acme-challenge") {
t.Fatalf("https custom port must keep :80 ACME http site:\n%s", httpSite)
}
if !strings.Contains(httpsSite, "listen 8443 ssl;") {
t.Fatalf("https custom port listen missing:\n%s", httpsSite)
}
if !strings.Contains(httpsSite, "X-Forwarded-Proto https;") {
t.Fatal("missing forwarded proto https")
}
}