From 5c697afcacd32e7c0d9b7a507f46eed3ccd86f5e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 16:28:41 -0400 Subject: [PATCH 1/5] Add exception to firewall to talk to traefik --- main.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index a6c5205..1fc48f2 100644 --- a/main.go +++ b/main.go @@ -751,7 +751,7 @@ func ensureWireguardInterface(wgconfig WgConfig) error { if disableFirewall { logger.Warn("Firewall disabled: all inbound traffic on %s will be allowed", interfaceName) - } else if err := ensureWireguardFirewall(); err != nil { + } else if err := ensureWireguardFirewall(wgconfig.IpAddress); err != nil { logger.Warn("Failed to ensure WireGuard firewall rules: %v", err) } @@ -927,11 +927,18 @@ func ensureMSSClamping() error { return nil } -func ensureWireguardFirewall() error { +func ensureWireguardFirewall(localIpAddress string) error { // Rules to enforce: // 1. Allow established/related connections (responses to our outbound traffic) // 2. Allow ICMP ping packets - // 3. Drop all other inbound traffic from peers + // 3. Allow inbound traffic to ports 80/443 on the local IP only (for Traefik) + // 4. Drop all other inbound traffic from peers + + // Strip any CIDR suffix so we're left with just the host IP + localIp := localIpAddress + if ip, _, err := net.ParseCIDR(localIpAddress); err == nil { + localIp = ip.String() + } // Define the rules we want to ensure exist rules := [][]string{ @@ -951,6 +958,24 @@ func ensureWireguardFirewall() error { "--icmp-type", "8", "-j", "ACCEPT", }, + // Allow inbound HTTP to the local IP only (for Traefik) + { + "-A", "INPUT", + "-i", interfaceName, + "-p", "tcp", + "--dport", "80", + "-d", localIp, + "-j", "ACCEPT", + }, + // Allow inbound HTTPS to the local IP only (for Traefik) + { + "-A", "INPUT", + "-i", interfaceName, + "-p", "tcp", + "--dport", "443", + "-d", localIp, + "-j", "ACCEPT", + }, // Drop all other inbound traffic from WireGuard interface { "-A", "INPUT", From 74629a97f7aa13d4542e4f40f7ded0c1c9cf007e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 17:28:13 -0400 Subject: [PATCH 2/5] Add gp router endpoint for dowsteam http --- main.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/main.go b/main.go index 1fc48f2..b719e0e 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "net/http/httputil" _ "net/http/pprof" "os" "os/exec" @@ -104,6 +105,53 @@ type UpdateDestinationsRequest struct { Destinations []relay.PeerDestination `json:"destinations"` } +// pangolinDestHeader carries the downstream host:port (reachable over the +// WireGuard interface) that a /router request should be rewritten to. It is +// stripped before the request is forwarded. +const pangolinDestHeader = "p-dest-header" + +// routerProxy forwards /router/* requests from the Pangolin AI gateway to a +// destination on the WireGuard network, as named by pangolinDestHeader. +// Used for proxying AI chat completion requests (incl. streaming) to +// providers reachable only from a site. +var routerProxy = &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + dest := pr.In.Header.Get(pangolinDestHeader) + + pr.Out.URL.Scheme = "http" + pr.Out.URL.Host = dest + pr.Out.URL.Path = strings.TrimPrefix(pr.In.URL.Path, "/router") + if !strings.HasPrefix(pr.Out.URL.Path, "/") { + pr.Out.URL.Path = "/" + pr.Out.URL.Path + } + pr.Out.URL.RawPath = "" + pr.Out.Host = dest + + pr.Out.Header.Del(pangolinDestHeader) + }, + // Flush written bytes to the client immediately rather than buffering, + // which is required for SSE-based streaming chat completions. + FlushInterval: -1, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("Router proxy error for %s: %v", r.URL.Path, err) + http.Error(w, "Bad gateway", http.StatusBadGateway) + }, +} + +func handleRouter(w http.ResponseWriter, r *http.Request) { + dest := r.Header.Get(pangolinDestHeader) + if dest == "" { + http.Error(w, fmt.Sprintf("Missing %s header", pangolinDestHeader), http.StatusBadRequest) + return + } + if _, _, err := net.SplitHostPort(dest); err != nil { + http.Error(w, "Invalid destination", http.StatusBadRequest) + return + } + + routerProxy.ServeHTTP(w, r) +} + // httpMetricsMiddleware wraps HTTP handlers with metrics tracking func httpMetricsMiddleware(endpoint string, handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -568,6 +616,7 @@ func main() { http.HandleFunc("/update-destinations", httpMetricsMiddleware("update_destinations", handleUpdateDestinations)) http.HandleFunc("/update-local-snis", httpMetricsMiddleware("update_local_snis", handleUpdateLocalSNIs)) http.HandleFunc("/healthz", httpMetricsMiddleware("healthz", handleHealthz)) + http.HandleFunc("/router/", httpMetricsMiddleware("router", handleRouter)) // Register metrics endpoint only for Prometheus backend. // OTel backend pushes to a collector; no /metrics endpoint needed. From 6f1851639af5fb1f83591f1c0d9e1972097bb81f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 15:17:30 -0400 Subject: [PATCH 3/5] Process the scheme --- main.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index b719e0e..e9c0009 100644 --- a/main.go +++ b/main.go @@ -106,19 +106,29 @@ type UpdateDestinationsRequest struct { } // pangolinDestHeader carries the downstream host:port (reachable over the -// WireGuard interface) that a /router request should be rewritten to. It is +// WireGuard interface) that a /router request should be rewritten to, +// optionally prefixed with a scheme (e.g. "https://100.96.128.1:443"). It is // stripped before the request is forwarded. const pangolinDestHeader = "p-dest-header" +// splitDestHeader separates an optional "scheme://" prefix from a +// pangolinDestHeader value, defaulting to "http" when none is present. +func splitDestHeader(dest string) (scheme, host string) { + if s, rest, ok := strings.Cut(dest, "://"); ok { + return s, rest + } + return "http", dest +} + // routerProxy forwards /router/* requests from the Pangolin AI gateway to a // destination on the WireGuard network, as named by pangolinDestHeader. // Used for proxying AI chat completion requests (incl. streaming) to // providers reachable only from a site. var routerProxy = &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { - dest := pr.In.Header.Get(pangolinDestHeader) + scheme, dest := splitDestHeader(pr.In.Header.Get(pangolinDestHeader)) - pr.Out.URL.Scheme = "http" + pr.Out.URL.Scheme = scheme pr.Out.URL.Host = dest pr.Out.URL.Path = strings.TrimPrefix(pr.In.URL.Path, "/router") if !strings.HasPrefix(pr.Out.URL.Path, "/") { @@ -144,7 +154,8 @@ func handleRouter(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Missing %s header", pangolinDestHeader), http.StatusBadRequest) return } - if _, _, err := net.SplitHostPort(dest); err != nil { + _, host := splitDestHeader(dest) + if _, _, err := net.SplitHostPort(host); err != nil { http.Error(w, "Invalid destination", http.StatusBadRequest) return } From 654a3c777ac54d6cca153a8af9a5360e0af560f7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 15:48:40 -0400 Subject: [PATCH 4/5] Basic routing working to targets --- main.go | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/main.go b/main.go index e9c0009..d5508e6 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "flag" @@ -111,6 +112,14 @@ type UpdateDestinationsRequest struct { // stripped before the request is forwarded. const pangolinDestHeader = "p-dest-header" +// pangolinHostHeader optionally carries the Host header value that should be +// sent to the destination, when it differs from pangolinDestHeader (e.g. the +// target's configured IP/hostname rather than the WireGuard routing +// address). It is stripped before the request is forwarded. When absent, +// the destination from pangolinDestHeader is used as the Host header, same +// as before. +const pangolinHostHeader = "p-dest-host-header" + // splitDestHeader separates an optional "scheme://" prefix from a // pangolinDestHeader value, defaulting to "http" when none is present. func splitDestHeader(dest string) (scheme, host string) { @@ -120,6 +129,65 @@ func splitDestHeader(dest string) (scheme, host string) { return "http", dest } +// hostname strips an optional ":port" suffix from a host header value. +func hostname(hostport string) string { + if h, _, err := net.SplitHostPort(hostport); err == nil { + return h + } + return hostport +} + +// routerSNIContextKey carries the TLS ServerName (SNI) that +// routerTransport's DialTLSContext should present, since we dial the +// WireGuard destination IP but the remote end typically terminates TLS +// based on the original hostname (pangolinHostHeader), not that IP. +type routerSNIContextKey struct{} + +// routerTransport is routerProxy's RoundTripper. It mirrors +// http.DefaultTransport except for TLS dials, where it sets the SNI from +// routerSNIContextKey instead of letting it default to the dial address +// (the WireGuard IP), which the destination's TLS termination won't have a +// matching certificate/route for. +var routerTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + serverName := hostname(addr) + if sni, ok := ctx.Value(routerSNIContextKey{}).(string); ok && sni != "" { + serverName = sni + } + dialer := &tls.Dialer{Config: &tls.Config{ServerName: serverName}} + return dialer.DialContext(ctx, network, addr) + }, +} + +// logDebugRequest dumps a request's destination, headers, and body at debug +// level for troubleshooting (e.g. verifying an auth header made it through +// the proxy chain intact). It reads and restores req.Body so the request can +// still be sent afterward. Header values (including secrets like API keys) +// are logged as-is - only intended to be enabled for local troubleshooting. +func logDebugRequest(label string, req *http.Request) { + var headerLines strings.Builder + for name, values := range req.Header { + for _, v := range values { + fmt.Fprintf(&headerLines, "\n %s: %s", name, v) + } + } + + body := []byte("") + if req.Body != nil { + data, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + logger.Error("%s: failed to read body for logging: %v", label, err) + } else { + body = data + } + req.Body = io.NopCloser(bytes.NewReader(data)) + } + + logger.Debug("%s: %s %s://%s%s headers:%s\nbody: %s", label, req.Method, req.URL.Scheme, req.Host, req.URL.RequestURI(), headerLines.String(), body) +} + // routerProxy forwards /router/* requests from the Pangolin AI gateway to a // destination on the WireGuard network, as named by pangolinDestHeader. // Used for proxying AI chat completion requests (incl. streaming) to @@ -136,9 +204,19 @@ var routerProxy = &httputil.ReverseProxy{ } pr.Out.URL.RawPath = "" pr.Out.Host = dest + if hostOverride := pr.In.Header.Get(pangolinHostHeader); hostOverride != "" { + pr.Out.Host = hostOverride + ctx := context.WithValue(pr.Out.Context(), routerSNIContextKey{}, hostname(hostOverride)) + pr.Out = pr.Out.WithContext(ctx) + } pr.Out.Header.Del(pangolinDestHeader) + pr.Out.Header.Del(pangolinHostHeader) + + logger.Debug("Router proxy: %s %s -> %s (Host: %s)", pr.In.Method, pr.In.URL.Path, pr.Out.URL.String(), pr.Out.Host) + logDebugRequest("Router outbound request", pr.Out) }, + Transport: routerTransport, // Flush written bytes to the client immediately rather than buffering, // which is required for SSE-based streaming chat completions. FlushInterval: -1, @@ -150,6 +228,9 @@ var routerProxy = &httputil.ReverseProxy{ func handleRouter(w http.ResponseWriter, r *http.Request) { dest := r.Header.Get(pangolinDestHeader) + hostOverride := r.Header.Get(pangolinHostHeader) + logger.Debug("Router request received: %s %s dest=%s host=%s remote=%s", r.Method, r.URL.Path, dest, hostOverride, r.RemoteAddr) + if dest == "" { http.Error(w, fmt.Sprintf("Missing %s header", pangolinDestHeader), http.StatusBadRequest) return From e1b47b69aa4f050735729bff1216fe61583ceb31 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Aug 2026 17:14:35 -0400 Subject: [PATCH 5/5] expose unwrap to allow for streaming --- main.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/main.go b/main.go index d5508e6..d1c350d 100644 --- a/main.go +++ b/main.go @@ -273,6 +273,16 @@ func (w *responseWriterWrapper) WriteHeader(statusCode int) { w.ResponseWriter.WriteHeader(statusCode) } +// Unwrap exposes the underlying ResponseWriter so http.ResponseController +// (used by httputil.ReverseProxy's streaming Flush, and by Hijack/Push +// callers) can see through this wrapper to the real http.Flusher etc. +// Without this, ReverseProxy's flushes on /router/* silently no-op and +// streamed responses (e.g. SSE) get buffered until the response completes +// instead of being forwarded incrementally. +func (w *responseWriterWrapper) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + func parseLogLevel(level string) logger.LogLevel { switch strings.ToUpper(level) { case "DEBUG":