From 6aa94c0c2a4861378a468e0280d6e6cdc2db6ab3 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 8 May 2026 13:45:50 +0100 Subject: [PATCH 1/4] fix(http): Set host header based on in fix https://github.com/fosrl/pangolin/issues/2952 issue by setting the incoming host header to the outgoing one by the reverse proxy, this was the default behaviour when using single proxy but now since we use more features it now rewrites the host header --- netstack2/http_handler.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 7ba2f63..ed39d1e 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -291,6 +291,9 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { proxy := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(targetURL) + if host := pr.In.Host; host != "" { + pr.Out.Host = host + } // SetXForwarded sets X-Forwarded-For from the inbound request's // RemoteAddr (the WireGuard/netstack client address), along with // X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of From 146e7835eb541c7f0dc6fba2a22b4ccfc37fdde8 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 8 May 2026 15:17:31 +0100 Subject: [PATCH 2/4] fix(http): populate Request.TLS for private HTTPS via httpConnCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net/http only sets Request.TLS for *tls.Conn or conns implementing ConnectionState(). Our listener wrapped tls.Server in httpConnCtx with an embedded net.Conn, so TLS was never surfaced and r.TLS stayed nil. That triggered the HTTP→HTTPS permanent redirect on every request for HTTPS rules. Add ConnectionState() on httpConnCtx delegating to the underlying TLS conn. Add tests for TLS forwarding and plain TCP. --- netstack2/http_handler.go | 15 ++++++++++ netstack2/http_handler_tls_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 netstack2/http_handler_tls_test.go diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 7ba2f63..354781e 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -139,6 +139,21 @@ type httpConnCtx struct { rule *SubnetRule } +// ConnectionState allows net/http.Server to populate Request.TLS when the +// underlying connection is TLS (e.g. *tls.Conn from tls.Server). Without this, +// the connection is not *tls.Conn and does not expose ConnectionState through +// the net.Conn interface field, so tlsState stays nil and the HTTPS redirect +// in handleRequest runs on every request. +func (c *httpConnCtx) ConnectionState() tls.ConnectionState { + type tlsConn interface { + ConnectionState() tls.ConnectionState + } + if tc, ok := c.Conn.(tlsConn); ok { + return tc.ConnectionState() + } + return tls.ConnectionState{} +} + // connCtxKey is the unexported context key used to store a *SubnetRule on the // per-connection context created by http.Server.ConnContext. type connCtxKey struct{} diff --git a/netstack2/http_handler_tls_test.go b/netstack2/http_handler_tls_test.go new file mode 100644 index 0000000..0f2ffdc --- /dev/null +++ b/netstack2/http_handler_tls_test.go @@ -0,0 +1,48 @@ +package netstack2 + +import ( + "crypto/tls" + "net" + "testing" +) + +// tlsConnStub is a minimal net.Conn that also exposes TLS state, matching +// *tls.Conn's ConnectionState used by net/http.Server. +type tlsConnStub struct { + net.Conn + state tls.ConnectionState +} + +func (t *tlsConnStub) ConnectionState() tls.ConnectionState { + return t.state +} + +func TestHTTPConnCtxForwardsConnectionState(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + inner := &tlsConnStub{ + Conn: c1, + state: tls.ConnectionState{Version: tls.VersionTLS12, HandshakeComplete: true}, + } + wrapped := &httpConnCtx{Conn: inner, rule: nil} + + got := wrapped.ConnectionState() + if got.Version != tls.VersionTLS12 || !got.HandshakeComplete { + t.Fatalf("ConnectionState = %+v, want TLS 1.2 and HandshakeComplete", got) + } +} + +func TestHTTPConnCtxConnectionStatePlainTCP(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + + wrapped := &httpConnCtx{Conn: c1, rule: nil} + got := wrapped.ConnectionState() + if got.Version != 0 { + t.Fatalf("expected zero ConnectionState for plain conn, got %+v", got) + } + _ = c2 +} From 86155072dee15ddf64bf36b414c3c28f6948324b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 May 2026 11:03:00 -0700 Subject: [PATCH 3/4] Fix the redirect --- netstack2/http_handler.go | 70 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 0056df1..ece82e9 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -131,33 +131,23 @@ func (l *chanListener) send(conn net.Conn) bool { // httpConnCtx – conn wrapper that carries a SubnetRule through the listener // --------------------------------------------------------------------------- -// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed -// through the chanListener into the http.Server's ConnContext callback, -// making it available to request handlers via the request context. +// httpConnCtx wraps a net.Conn so the matching SubnetRule and TLS state can +// be passed through the chanListener into the http.Server's ConnContext +// callback, making them available to request handlers via the request context. type httpConnCtx struct { net.Conn - rule *SubnetRule -} - -// ConnectionState allows net/http.Server to populate Request.TLS when the -// underlying connection is TLS (e.g. *tls.Conn from tls.Server). Without this, -// the connection is not *tls.Conn and does not expose ConnectionState through -// the net.Conn interface field, so tlsState stays nil and the HTTPS redirect -// in handleRequest runs on every request. -func (c *httpConnCtx) ConnectionState() tls.ConnectionState { - type tlsConn interface { - ConnectionState() tls.ConnectionState - } - if tc, ok := c.Conn.(tlsConn); ok { - return tc.ConnectionState() - } - return tls.ConnectionState{} + rule *SubnetRule + isTLS bool // true when the conn was wrapped with tls.Server } // connCtxKey is the unexported context key used to store a *SubnetRule on the // per-connection context created by http.Server.ConnContext. type connCtxKey struct{} +// connTLSKey is the unexported context key used to store the isTLS flag on +// the per-connection context created by http.Server.ConnContext. +type connTLSKey struct{} + // --------------------------------------------------------------------------- // Constructor and lifecycle // --------------------------------------------------------------------------- @@ -190,7 +180,8 @@ func (h *HTTPHandler) Start() error { // that handleRequest can retrieve it without any global state. ConnContext: func(ctx context.Context, c net.Conn) context.Context { if cc, ok := c.(*httpConnCtx); ok { - return context.WithValue(ctx, connCtxKey{}, cc.rule) + ctx = context.WithValue(ctx, connCtxKey{}, cc.rule) + ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS) } return ctx }, @@ -218,19 +209,28 @@ func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) { var effectiveConn net.Conn = conn if rule.Protocol == "https" { - tlsCfg, err := h.getTLSConfig(rule) - if err != nil { - logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", - conn.RemoteAddr(), err) - conn.Close() - return + // Only perform TLS termination for connections arriving on port 443. + // Connections on port 80 are passed through as plain HTTP so that + // handleRequest can issue the HTTP→HTTPS redirect. + doTLS := false + if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok { + doTLS = tcpAddr.Port == 443 + } + if doTLS { + tlsCfg, err := h.getTLSConfig(rule) + if err != nil { + logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", + conn.RemoteAddr(), err) + conn.Close() + return + } + // tls.Server wraps the raw conn; the TLS handshake is deferred until + // the first Read, which the http.Server will trigger naturally. + effectiveConn = tls.Server(conn, tlsCfg) } - // tls.Server wraps the raw conn; the TLS handshake is deferred until - // the first Read, which the http.Server will trigger naturally. - effectiveConn = tls.Server(conn, tlsCfg) } - wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule} + wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule, isTLS: effectiveConn != conn} if !h.listener.send(wrapped) { // Listener is already closed — clean up the orphaned connection. effectiveConn.Close() @@ -374,9 +374,13 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // If the rule is HTTPS and a TLS certificate is configured, but the - // incoming request arrived over plain HTTP, redirect to HTTPS. - if rule.Protocol == "https" && rule.TLSCert != "" && rule.TLSKey != "" && r.TLS == nil { + // If the rule is HTTPS but the incoming request arrived over plain HTTP + // (port 80), redirect to HTTPS. We use the isTLS flag stored on the + // connection context rather than r.TLS, because Go's http.Server calls + // ConnectionState() before the TLS handshake completes, so r.TLS.Version + // is 0 even for genuine TLS connections at that point. + isTLS, _ := r.Context().Value(connTLSKey{}).(bool) + if rule.Protocol == "https" && !isTLS { host := r.Host if host == "" { host = r.URL.Host From a1218ab67aa715472f7a8204d2cf1080051f45e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 May 2026 11:05:24 -0700 Subject: [PATCH 4/4] Bump version --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b388cc9..78d0291 100644 --- a/flake.nix +++ b/flake.nix @@ -25,7 +25,7 @@ inherit (pkgs) lib; # Update version when releasing - version = "1.12.4"; + version = "1.12.5"; in { default = self.packages.${system}.pangolin-newt;