From 47c646bc33a563a8fa82b9e7a7c5c80ae01f1aeb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 11:43:26 -0400 Subject: [PATCH 1/7] Basic http is working --- clients/clients.go | 1 + netstack2/handlers.go | 18 ++- netstack2/http_handler.go | 282 ++++++++++++++++++++++++++++++++++++++ netstack2/proxy.go | 20 ++- netstack2/tun.go | 3 + 5 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 netstack2/http_handler.go diff --git a/clients/clients.go b/clients/clients.go index 78bc0c3..f1cf394 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -819,6 +819,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, + EnableHTTPProxy: true, }, ) if err != nil { diff --git a/netstack2/handlers.go b/netstack2/handlers.go index 07c235f..8baa5e2 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -137,14 +137,26 @@ func (h *TCPHandler) InstallTCPHandler() error { // handleTCPConn handles a TCP connection by proxying it to the actual target func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) { - defer netstackConn.Close() - - // Extract source and target address from the connection ID + // Extract source and target address from the connection ID first so they + // are available for HTTP routing before any defer is set up. srcIP := id.RemoteAddress.String() srcPort := id.RemotePort dstIP := id.LocalAddress.String() dstPort := id.LocalPort + // Route to the HTTP handler when the destination port belongs to it. + // The HTTP handler takes full ownership of the connection lifecycle, so we + // must NOT install the defer close before handing the conn off. + if h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { + if h.proxyHandler.httpHandler.HandlesPort(dstPort) { + logger.Info("+++++++++++++++++++++++TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler", srcIP, srcPort, dstIP, dstPort) + h.proxyHandler.httpHandler.HandleConn(netstackConn) + return + } + } + + defer netstackConn.Close() + logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) // Check if there's a destination rewrite for this connection (e.g., localhost targets) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go new file mode 100644 index 0000000..1502cf1 --- /dev/null +++ b/netstack2/http_handler.go @@ -0,0 +1,282 @@ +/* SPDX-License-Identifier: MIT + * + * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. + */ + +package netstack2 + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "sync" + + "github.com/fosrl/newt/logger" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// --------------------------------------------------------------------------- +// Hardcoded test configuration +// --------------------------------------------------------------------------- + +// testHTTPServeHTTPS controls whether the proxy presents HTTP or HTTPS to +// incoming connections. Flip to true and supply valid cert/key paths to test +// TLS termination. +const testHTTPServeHTTPS = false + +// testHTTPCertFile / testHTTPKeyFile are paths to a self-signed certificate +// used when testHTTPServeHTTPS == true. +const testHTTPCertFile = "/tmp/test-cert.pem" +const testHTTPKeyFile = "/tmp/test-key.pem" + +// testHTTPListenPort is the destination port the handler intercepts from the +// netstack TCP forwarder (e.g. 80 for plain HTTP, 443 for HTTPS termination). +const testHTTPListenPort uint16 = 80 + +// testHTTPTargets is the hardcoded list of downstream services used for +// testing. DestAddr / DestPort describe where the real HTTP(S) server lives; +// UseHTTPS controls whether the outbound leg uses TLS. +var testHTTPTargets = []HTTPTarget{ + {DestAddr: "127.0.0.1", DestPort: 8080, UseHTTPS: false}, +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// HTTPTarget describes a single downstream HTTP or HTTPS service. +type HTTPTarget struct { + DestAddr string // IP address or hostname of the downstream service + DestPort uint16 // TCP port of the downstream service + UseHTTPS bool // When true the outbound leg uses HTTPS +} + +// HTTPHandler intercepts TCP connections from the netstack forwarder and +// services them as HTTP or HTTPS, reverse-proxying each request to one of the +// configured downstream HTTPTarget services. +// +// It is intentionally separate from TCPHandler: there is no overlap between +// raw-TCP connections and HTTP-aware connections on the same destination port. +type HTTPHandler struct { + stack *stack.Stack + proxyHandler *ProxyHandler + + // Configuration (populated from hardcoded test values by NewHTTPHandler). + targets []HTTPTarget + listenPort uint16 // Port this handler claims; used for routing by TCPHandler + serveHTTPS bool // Present TLS to the incoming (client) side + certFile string // PEM certificate for the incoming TLS listener + keyFile string // PEM private key for the incoming TLS listener + + // Runtime state – initialised by Start(). + listener *chanListener + server *http.Server + // One pre-built reverse proxy per target entry. + proxies []*httputil.ReverseProxy +} + +// --------------------------------------------------------------------------- +// chanListener – net.Listener backed by a channel +// --------------------------------------------------------------------------- + +// chanListener implements net.Listener by receiving net.Conn values over a +// buffered channel. This lets the netstack TCP forwarder hand off connections +// directly to a running http.Server without any real OS socket. +type chanListener struct { + connCh chan net.Conn + closed chan struct{} + once sync.Once +} + +func newChanListener() *chanListener { + return &chanListener{ + connCh: make(chan net.Conn, 128), + closed: make(chan struct{}), + } +} + +// Accept blocks until a connection is available or the listener is closed. +func (l *chanListener) Accept() (net.Conn, error) { + select { + case conn, ok := <-l.connCh: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +// Close shuts down the listener; subsequent Accept calls return net.ErrClosed. +func (l *chanListener) Close() error { + l.once.Do(func() { close(l.closed) }) + return nil +} + +// Addr returns a placeholder address (the listener has no real OS socket). +func (l *chanListener) Addr() net.Addr { + return &net.TCPAddr{} +} + +// send delivers conn to the listener. Returns false if the listener is already +// closed, in which case the caller should close conn itself. +func (l *chanListener) send(conn net.Conn) bool { + select { + case l.connCh <- conn: + return true + case <-l.closed: + return false + } +} + +// --------------------------------------------------------------------------- +// HTTPHandler constructor and lifecycle +// --------------------------------------------------------------------------- + +// NewHTTPHandler creates an HTTPHandler wired to the given stack and +// ProxyHandler, using the hardcoded test configuration defined at the top of +// this file. +func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { + return &HTTPHandler{ + stack: s, + proxyHandler: ph, + targets: testHTTPTargets, + listenPort: testHTTPListenPort, + serveHTTPS: testHTTPServeHTTPS, + certFile: testHTTPCertFile, + keyFile: testHTTPKeyFile, + } +} + +// Start builds the per-target reverse proxies and launches the HTTP(S) server +// that will service connections delivered via HandleConn. +func (h *HTTPHandler) Start() error { + // Build one ReverseProxy per target. + h.proxies = make([]*httputil.ReverseProxy, 0, len(h.targets)) + for i, t := range h.targets { + scheme := "http" + if t.UseHTTPS { + scheme = "https" + } + targetURL := &url.URL{ + Scheme: scheme, + Host: fmt.Sprintf("%s:%d", t.DestAddr, t.DestPort), + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // For HTTPS downstream, allow self-signed certificates during testing. + if t.UseHTTPS { + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // intentional for test targets + }, + } + } + + idx := i // capture for closure + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("HTTP handler: upstream error (target %d, %s %s): %v", + idx, r.Method, r.URL.RequestURI(), err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + h.proxies = append(h.proxies, proxy) + } + + h.listener = newChanListener() + + h.server = &http.Server{ + Handler: http.HandlerFunc(h.handleRequest), + } + + if h.serveHTTPS { + cert, err := tls.LoadX509KeyPair(h.certFile, h.keyFile) + if err != nil { + return fmt.Errorf("HTTP handler: failed to load TLS keypair (%s, %s): %w", + h.certFile, h.keyFile, err) + } + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + tlsListener := tls.NewListener(h.listener, tlsCfg) + go func() { + if err := h.server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: HTTPS server exited: %v", err) + } + }() + logger.Info("HTTP handler: listening (HTTPS) on port %d, %d downstream target(s)", + h.listenPort, len(h.targets)) + } else { + go func() { + if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: HTTP server exited: %v", err) + } + }() + logger.Info("HTTP handler: listening (HTTP) on port %d, %d downstream target(s)", + h.listenPort, len(h.targets)) + } + + return nil +} + +// HandleConn accepts a TCP connection from the netstack forwarder and delivers +// it to the running HTTP(S) server. The HTTP handler takes full ownership of +// the connection's lifecycle; the caller must NOT close conn after this call. +func (h *HTTPHandler) HandleConn(conn net.Conn) { + if !h.listener.send(conn) { + // Listener already closed – clean up the orphaned connection. + conn.Close() + } +} + +// HandlesPort reports whether this handler is responsible for connections +// arriving on the given destination port. +func (h *HTTPHandler) HandlesPort(port uint16) bool { + return port == h.listenPort +} + +// Close shuts down the underlying HTTP server and the channel listener. +func (h *HTTPHandler) Close() error { + if h.server != nil { + if err := h.server.Close(); err != nil { + return err + } + } + if h.listener != nil { + h.listener.Close() + } + return nil +} + +// --------------------------------------------------------------------------- +// Request routing +// --------------------------------------------------------------------------- + +// handleRequest proxies an incoming HTTP request to the appropriate downstream +// target. Currently always routes to the first (and, in the hardcoded test +// setup, only) configured target. +func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { + if len(h.proxies) == 0 { + logger.Error("HTTP handler: no downstream targets configured") + http.Error(w, "no targets configured", http.StatusBadGateway) + return + } + + // TODO: add host/path-based routing when moving beyond hardcoded test config. + proxy := h.proxies[0] + target := h.targets[0] + + scheme := "http" + if target.UseHTTPS { + scheme = "https" + } + logger.Info("HTTP handler: %s %s -> %s://%s:%d", + r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) + + proxy.ServeHTTP(w, r) +} \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index e383fc0..1ad469e 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -114,6 +114,7 @@ type ProxyHandler struct { tcpHandler *TCPHandler udpHandler *UDPHandler icmpHandler *ICMPHandler + httpHandler *HTTPHandler subnetLookup *SubnetLookup natTable map[connKey]*natState reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT @@ -131,12 +132,13 @@ type ProxyHandlerOptions struct { EnableTCP bool EnableUDP bool EnableICMP bool + EnableHTTP bool MTU int } // NewProxyHandler creates a new proxy handler for promiscuous mode func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { - if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP { + if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP && !options.EnableHTTP { return nil, nil // No proxy needed } @@ -189,6 +191,17 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { logger.Debug("ProxyHandler: ICMP handler enabled") } + // Initialize HTTP handler if enabled. The HTTP handler piggybacks on the + // TCP forwarder: TCPHandler.handleTCPConn checks HandlesPort() and routes + // matching connections here instead of doing raw byte forwarding. + if options.EnableHTTP { + handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) + if err := handler.httpHandler.Start(); err != nil { + return nil, fmt.Errorf("failed to start HTTP handler: %v", err) + } + logger.Debug("ProxyHandler: HTTP handler enabled") + } + // // Example 1: Add a rule with no port restrictions (all ports allowed) // // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24 // sourceSubnet := netip.MustParsePrefix("10.0.0.0/24") @@ -794,6 +807,11 @@ func (p *ProxyHandler) Close() error { p.accessLogger.Close() } + // Shut down HTTP handler + if p.httpHandler != nil { + p.httpHandler.Close() + } + // Close ICMP replies channel if p.icmpReplies != nil { close(p.icmpReplies) diff --git a/netstack2/tun.go b/netstack2/tun.go index 3183c36..e879d3b 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -59,6 +59,7 @@ type NetTunOptions struct { EnableTCPProxy bool EnableUDPProxy bool EnableICMPProxy bool + EnableHTTPProxy bool } // CreateNetTUN creates a new TUN device with netstack without proxying @@ -67,6 +68,7 @@ func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, + EnableHTTPProxy: true, }) } @@ -93,6 +95,7 @@ func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, o EnableTCP: options.EnableTCPProxy, EnableUDP: options.EnableUDPProxy, EnableICMP: options.EnableICMPProxy, + EnableHTTP: options.EnableHTTPProxy, MTU: mtu, }) if err != nil { From 5848c8d4b4d4687e3bbd6b29ccdf1c6e2e519d16 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 16:04:11 -0400 Subject: [PATCH 2/7] Adjust to use data saved inside of the subnet rule --- clients/clients.go | 61 ++++++-- netstack2/handlers.go | 19 ++- netstack2/http_handler.go | 310 +++++++++++++++++++++---------------- netstack2/proxy.go | 45 +++--- netstack2/subnet_lookup.go | 26 ++-- netstack2/tun.go | 13 +- 6 files changed, 268 insertions(+), 206 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index f1cf394..d57ab70 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -74,18 +74,18 @@ type PeerReading struct { } type WireGuardService struct { - interfaceName string - mtu int - client *websocket.Client - config WgConfig - key wgtypes.Key - newtId string - lastReadings map[string]PeerReading - mu sync.Mutex - Port uint16 - host string - serverPubKey string - token string + interfaceName string + mtu int + client *websocket.Client + config WgConfig + key wgtypes.Key + newtId string + lastReadings map[string]PeerReading + mu sync.Mutex + Port uint16 + host string + serverPubKey string + token string stopGetConfig func() pendingConfigChainId string // Netstack fields @@ -697,7 +697,14 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { }) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } } @@ -819,7 +826,6 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, - EnableHTTPProxy: true, }, ) if err != nil { @@ -956,7 +962,14 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { if err != nil { return fmt.Errorf("invalid CIDR %s: %v", sp, err) } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1349,7 +1362,14 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } @@ -1467,7 +1487,14 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { logger.Info("Invalid CIDR %s: %v", sp, err) continue } - s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp, target.ResourceId) + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } } diff --git a/netstack2/handlers.go b/netstack2/handlers.go index 8baa5e2..dabfee9 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -144,13 +144,18 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo dstIP := id.LocalAddress.String() dstPort := id.LocalPort - // Route to the HTTP handler when the destination port belongs to it. - // The HTTP handler takes full ownership of the connection lifecycle, so we - // must NOT install the defer close before handing the conn off. - if h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { - if h.proxyHandler.httpHandler.HandlesPort(dstPort) { - logger.Info("+++++++++++++++++++++++TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler", srcIP, srcPort, dstIP, dstPort) - h.proxyHandler.httpHandler.HandleConn(netstackConn) + // For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has + // Protocol configured, hand the connection off to the HTTP handler which + // takes full ownership of the lifecycle (the defer close must not be + // installed before this point). + if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { + srcAddr, _ := netip.ParseAddr(srcIP) + dstAddr, _ := netip.ParseAddr(dstIP) + rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber) + if rule != nil && rule.Protocol != "" { + logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", + srcIP, srcPort, dstIP, dstPort, rule.Protocol) + h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) return } } diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 1502cf1..4efa6a1 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -6,6 +6,7 @@ package netstack2 import ( + "context" "crypto/tls" "fmt" "net" @@ -19,63 +20,52 @@ import ( ) // --------------------------------------------------------------------------- -// Hardcoded test configuration +// HTTPTarget // --------------------------------------------------------------------------- -// testHTTPServeHTTPS controls whether the proxy presents HTTP or HTTPS to -// incoming connections. Flip to true and supply valid cert/key paths to test -// TLS termination. -const testHTTPServeHTTPS = false - -// testHTTPCertFile / testHTTPKeyFile are paths to a self-signed certificate -// used when testHTTPServeHTTPS == true. -const testHTTPCertFile = "/tmp/test-cert.pem" -const testHTTPKeyFile = "/tmp/test-key.pem" - -// testHTTPListenPort is the destination port the handler intercepts from the -// netstack TCP forwarder (e.g. 80 for plain HTTP, 443 for HTTPS termination). -const testHTTPListenPort uint16 = 80 - -// testHTTPTargets is the hardcoded list of downstream services used for -// testing. DestAddr / DestPort describe where the real HTTP(S) server lives; -// UseHTTPS controls whether the outbound leg uses TLS. -var testHTTPTargets = []HTTPTarget{ - {DestAddr: "127.0.0.1", DestPort: 8080, UseHTTPS: false}, -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -// HTTPTarget describes a single downstream HTTP or HTTPS service. +// HTTPTarget describes a single downstream HTTP or HTTPS service that the +// proxy should forward requests to. type HTTPTarget struct { DestAddr string // IP address or hostname of the downstream service DestPort uint16 // TCP port of the downstream service UseHTTPS bool // When true the outbound leg uses HTTPS } -// HTTPHandler intercepts TCP connections from the netstack forwarder and -// services them as HTTP or HTTPS, reverse-proxying each request to one of the -// configured downstream HTTPTarget services. +// --------------------------------------------------------------------------- +// HTTPHandler +// --------------------------------------------------------------------------- + +// HTTPHandler intercepts TCP connections from the netstack forwarder on ports +// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request +// to downstream targets specified by the matching SubnetRule. // -// It is intentionally separate from TCPHandler: there is no overlap between -// raw-TCP connections and HTTP-aware connections on the same destination port. +// HTTP and raw TCP are fully separate: a connection is only routed here when +// its SubnetRule has Protocol set ("http" or "https"). All other connections +// on those ports fall through to the normal raw-TCP path. +// +// Incoming TLS termination (Protocol == "https") is performed per-connection +// using the certificate and key stored in the rule, so different subnet rules +// can present different certificates without sharing any state. +// +// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS +// independently of the incoming protocol. type HTTPHandler struct { stack *stack.Stack proxyHandler *ProxyHandler - // Configuration (populated from hardcoded test values by NewHTTPHandler). - targets []HTTPTarget - listenPort uint16 // Port this handler claims; used for routing by TCPHandler - serveHTTPS bool // Present TLS to the incoming (client) side - certFile string // PEM certificate for the incoming TLS listener - keyFile string // PEM private key for the incoming TLS listener - - // Runtime state – initialised by Start(). listener *chanListener server *http.Server - // One pre-built reverse proxy per target entry. - proxies []*httputil.ReverseProxy + + // proxyCache holds pre-built *httputil.ReverseProxy values keyed by the + // canonical target URL string ("scheme://host:port"). Building a proxy is + // cheap, but reusing one preserves the underlying http.Transport connection + // pool, which matters for throughput. + proxyCache sync.Map // map[string]*httputil.ReverseProxy + + // tlsCache holds pre-parsed *tls.Config values keyed by the concatenation + // of the PEM certificate and key. Parsing a keypair is relatively expensive + // and the same cert is likely reused across many connections. + tlsCache sync.Map // map[string]*tls.Config } // --------------------------------------------------------------------------- @@ -123,7 +113,7 @@ func (l *chanListener) Addr() net.Addr { } // send delivers conn to the listener. Returns false if the listener is already -// closed, in which case the caller should close conn itself. +// closed, in which case the caller is responsible for closing conn. func (l *chanListener) send(conn net.Conn) bool { select { case l.connCh <- conn: @@ -134,113 +124,96 @@ func (l *chanListener) send(conn net.Conn) bool { } // --------------------------------------------------------------------------- -// HTTPHandler constructor and lifecycle +// httpConnCtx – conn wrapper that carries a SubnetRule through the listener // --------------------------------------------------------------------------- -// NewHTTPHandler creates an HTTPHandler wired to the given stack and -// ProxyHandler, using the hardcoded test configuration defined at the top of -// this file. +// 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. +type httpConnCtx struct { + net.Conn + rule *SubnetRule +} + +// connCtxKey is the unexported context key used to store a *SubnetRule on the +// per-connection context created by http.Server.ConnContext. +type connCtxKey struct{} + +// --------------------------------------------------------------------------- +// Constructor and lifecycle +// --------------------------------------------------------------------------- + +// NewHTTPHandler creates an HTTPHandler attached to the given stack and +// ProxyHandler. Call Start to begin serving connections. func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { return &HTTPHandler{ stack: s, proxyHandler: ph, - targets: testHTTPTargets, - listenPort: testHTTPListenPort, - serveHTTPS: testHTTPServeHTTPS, - certFile: testHTTPCertFile, - keyFile: testHTTPKeyFile, } } -// Start builds the per-target reverse proxies and launches the HTTP(S) server -// that will service connections delivered via HandleConn. +// Start launches the internal http.Server that services connections delivered +// via HandleConn. The server runs for the lifetime of the HTTPHandler; call +// Close to stop it. func (h *HTTPHandler) Start() error { - // Build one ReverseProxy per target. - h.proxies = make([]*httputil.ReverseProxy, 0, len(h.targets)) - for i, t := range h.targets { - scheme := "http" - if t.UseHTTPS { - scheme = "https" - } - targetURL := &url.URL{ - Scheme: scheme, - Host: fmt.Sprintf("%s:%d", t.DestAddr, t.DestPort), - } - - proxy := httputil.NewSingleHostReverseProxy(targetURL) - - // For HTTPS downstream, allow self-signed certificates during testing. - if t.UseHTTPS { - proxy.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, //nolint:gosec // intentional for test targets - }, - } - } - - idx := i // capture for closure - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - logger.Error("HTTP handler: upstream error (target %d, %s %s): %v", - idx, r.Method, r.URL.RequestURI(), err) - http.Error(w, "Bad Gateway", http.StatusBadGateway) - } - - h.proxies = append(h.proxies, proxy) - } - h.listener = newChanListener() h.server = &http.Server{ Handler: http.HandlerFunc(h.handleRequest), + // ConnContext runs once per accepted connection and attaches the + // SubnetRule carried by httpConnCtx to the connection's context so + // 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) + } + return ctx + }, } - if h.serveHTTPS { - cert, err := tls.LoadX509KeyPair(h.certFile, h.keyFile) - if err != nil { - return fmt.Errorf("HTTP handler: failed to load TLS keypair (%s, %s): %w", - h.certFile, h.keyFile, err) + go func() { + if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: server exited unexpectedly: %v", err) } - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - tlsListener := tls.NewListener(h.listener, tlsCfg) - go func() { - if err := h.server.Serve(tlsListener); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP handler: HTTPS server exited: %v", err) - } - }() - logger.Info("HTTP handler: listening (HTTPS) on port %d, %d downstream target(s)", - h.listenPort, len(h.targets)) - } else { - go func() { - if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP handler: HTTP server exited: %v", err) - } - }() - logger.Info("HTTP handler: listening (HTTP) on port %d, %d downstream target(s)", - h.listenPort, len(h.targets)) - } + }() + logger.Info("HTTP handler: ready — routing determined per SubnetRule on ports 80/443") return nil } -// HandleConn accepts a TCP connection from the netstack forwarder and delivers -// it to the running HTTP(S) server. The HTTP handler takes full ownership of -// the connection's lifecycle; the caller must NOT close conn after this call. -func (h *HTTPHandler) HandleConn(conn net.Conn) { - if !h.listener.send(conn) { - // Listener already closed – clean up the orphaned connection. - conn.Close() +// HandleConn accepts a TCP connection from the netstack forwarder together +// with the SubnetRule that matched it. The HTTP handler takes full ownership +// of the connection's lifecycle; the caller must NOT close conn after this call. +// +// When rule.Protocol is "https", TLS termination is performed on conn using +// the certificate and key stored in rule.TLSCert and rule.TLSKey before the +// connection is passed to the HTTP server. The HTTP server itself is always +// plain-HTTP; TLS is fully unwrapped at this layer. +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 + } + // 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} + if !h.listener.send(wrapped) { + // Listener is already closed — clean up the orphaned connection. + effectiveConn.Close() } } -// HandlesPort reports whether this handler is responsible for connections -// arriving on the given destination port. -func (h *HTTPHandler) HandlesPort(port uint16) bool { - return port == h.listenPort -} - -// Close shuts down the underlying HTTP server and the channel listener. +// Close gracefully shuts down the HTTP server and the underlying channel +// listener, causing the goroutine started in Start to exit. func (h *HTTPHandler) Close() error { if h.server != nil { if err := h.server.Close(); err != nil { @@ -254,23 +227,86 @@ func (h *HTTPHandler) Close() error { } // --------------------------------------------------------------------------- -// Request routing +// Internal helpers // --------------------------------------------------------------------------- -// handleRequest proxies an incoming HTTP request to the appropriate downstream -// target. Currently always routes to the first (and, in the hardcoded test -// setup, only) configured target. +// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a +// cache to avoid re-parsing the same keypair on every connection. +// The cache key is the concatenation of the PEM cert and key strings, so +// different rules that happen to share the same material hit the same entry. +func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { + cacheKey := rule.TLSCert + "|" + rule.TLSKey + if v, ok := h.tlsCache.Load(cacheKey); ok { + return v.(*tls.Config), nil + } + + cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse TLS keypair: %w", err) + } + cfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + // LoadOrStore is safe under concurrent calls: if two goroutines race here + // both will produce a valid config; the loser's work is discarded. + actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg) + return actual.(*tls.Config), nil +} + +// getProxy returns a cached *httputil.ReverseProxy for the given target, +// creating one on first use. Reusing the proxy preserves its http.Transport +// connection pool, avoiding repeated TCP/TLS handshakes to the downstream. +func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { + scheme := "http" + if target.UseHTTPS { + scheme = "https" + } + cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort) + + if v, ok := h.proxyCache.Load(cacheKey); ok { + return v.(*httputil.ReverseProxy) + } + + targetURL := &url.URL{ + Scheme: scheme, + Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), + } + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + if target.UseHTTPS { + // Allow self-signed certificates on downstream HTTPS targets. + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration + }, + } + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("HTTP handler: upstream error (%s %s -> %s): %v", + r.Method, r.URL.RequestURI(), cacheKey, err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy) + return actual.(*httputil.ReverseProxy) +} + +// handleRequest is the http.Handler entry point. It retrieves the SubnetRule +// attached to the connection by ConnContext, selects the first configured +// downstream target, and forwards the request via the cached ReverseProxy. +// +// TODO: add host/path-based routing across multiple HTTPTargets once the +// configuration model evolves beyond a single target per rule. func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { - if len(h.proxies) == 0 { - logger.Error("HTTP handler: no downstream targets configured") + rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule) + if rule == nil || len(rule.HTTPTargets) == 0 { + logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI()) http.Error(w, "no targets configured", http.StatusBadGateway) return } - // TODO: add host/path-based routing when moving beyond hardcoded test config. - proxy := h.proxies[0] - target := h.targets[0] - + target := rule.HTTPTargets[0] scheme := "http" if target.UseHTTPS { scheme = "https" @@ -278,5 +314,5 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) - proxy.ServeHTTP(w, r) + h.getProxy(target).ServeHTTP(w, r) } \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 1ad469e..f4c2352 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -53,6 +53,14 @@ type SubnetRule struct { RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name PortRanges []PortRange // empty slice means all ports allowed ResourceId int // Optional resource ID from the server for access logging + + // HTTP proxy configuration (optional). + // When Protocol is non-empty the TCP connection is handled by HTTPHandler + // instead of the raw TCP forwarder. + Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol + HTTPTargets []HTTPTarget // downstream services to proxy requests to + TLSCert string // PEM-encoded certificate for incoming HTTPS termination + TLSKey string // PEM-encoded private key for incoming HTTPS termination } // GetAllRules returns a copy of all subnet rules @@ -132,13 +140,12 @@ type ProxyHandlerOptions struct { EnableTCP bool EnableUDP bool EnableICMP bool - EnableHTTP bool MTU int } // NewProxyHandler creates a new proxy handler for promiscuous mode func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { - if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP && !options.EnableHTTP { + if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP { return nil, nil // No proxy needed } @@ -166,12 +173,21 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { }), } - // Initialize TCP handler if enabled + // Initialize TCP handler if enabled. The HTTP handler piggybacks on the + // TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for + // ports 80/443 and routes matching connections to the HTTP handler, so + // the HTTP handler is always initialised alongside TCP. if options.EnableTCP { handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler) if err := handler.tcpHandler.InstallTCPHandler(); err != nil { return nil, fmt.Errorf("failed to install TCP handler: %v", err) } + + handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) + if err := handler.httpHandler.Start(); err != nil { + return nil, fmt.Errorf("failed to start HTTP handler: %v", err) + } + logger.Debug("ProxyHandler: HTTP handler enabled") } // Initialize UDP handler if enabled @@ -191,17 +207,6 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { logger.Debug("ProxyHandler: ICMP handler enabled") } - // Initialize HTTP handler if enabled. The HTTP handler piggybacks on the - // TCP forwarder: TCPHandler.handleTCPConn checks HandlesPort() and routes - // matching connections here instead of doing raw byte forwarding. - if options.EnableHTTP { - handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) - if err := handler.httpHandler.Start(); err != nil { - return nil, fmt.Errorf("failed to start HTTP handler: %v", err) - } - logger.Debug("ProxyHandler: HTTP handler enabled") - } - // // Example 1: Add a rule with no port restrictions (all ports allowed) // // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24 // sourceSubnet := netip.MustParsePrefix("10.0.0.0/24") @@ -221,16 +226,14 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { return handler, nil } -// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler -// sourcePrefix: The IP prefix of the peer sending the data -// destPrefix: The IP prefix of the destination -// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name -// If portRanges is nil or empty, all ports are allowed for this subnet -func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddSubnetRule adds a subnet rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { if p == nil || !p.enabled { return } - p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) + p.subnetLookup.AddSubnet(rule) } // RemoveSubnetRule removes a subnet from the proxy handler diff --git a/netstack2/subnet_lookup.go b/netstack2/subnet_lookup.go index 317f85c..757908a 100644 --- a/netstack2/subnet_lookup.go +++ b/netstack2/subnet_lookup.go @@ -44,24 +44,18 @@ func prefixEqual(a, b netip.Prefix) bool { return a.Masked() == b.Masked() } -// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions -// If portRanges is nil or empty, all ports are allowed for this subnet -// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddSubnet adds a subnet rule to the lookup table. +// If rule.PortRanges is nil or empty, all ports are allowed. +// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com"). +// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey. +func (sl *SubnetLookup) AddSubnet(rule SubnetRule) { sl.mu.Lock() defer sl.mu.Unlock() - rule := &SubnetRule{ - SourcePrefix: sourcePrefix, - DestPrefix: destPrefix, - DisableIcmp: disableIcmp, - RewriteTo: rewriteTo, - PortRanges: portRanges, - ResourceId: resourceId, - } + rulePtr := &rule // Canonicalize source prefix to handle host bits correctly - canonicalSourcePrefix := sourcePrefix.Masked() + canonicalSourcePrefix := rule.SourcePrefix.Masked() // Get or create destination trie for this source prefix destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix) @@ -76,12 +70,12 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite // Canonicalize destination prefix to handle host bits correctly // BART masks prefixes internally, so we need to match that behavior in our bookkeeping - canonicalDestPrefix := destPrefix.Masked() + canonicalDestPrefix := rule.DestPrefix.Masked() // Add rule to destination trie // Original behavior: overwrite if same (sourcePrefix, destPrefix) exists // Store as single-element slice to match original overwrite behavior - destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rule}) + destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr}) // Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one // Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24 @@ -91,7 +85,7 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite newRules = append(newRules, r) } } - newRules = append(newRules, rule) + newRules = append(newRules, rulePtr) destTriePtr.rules = newRules } diff --git a/netstack2/tun.go b/netstack2/tun.go index e879d3b..5d2d6e1 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -59,7 +59,6 @@ type NetTunOptions struct { EnableTCPProxy bool EnableUDPProxy bool EnableICMPProxy bool - EnableHTTPProxy bool } // CreateNetTUN creates a new TUN device with netstack without proxying @@ -68,7 +67,6 @@ func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, EnableTCPProxy: true, EnableUDPProxy: true, EnableICMPProxy: true, - EnableHTTPProxy: true, }) } @@ -95,7 +93,6 @@ func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, o EnableTCP: options.EnableTCPProxy, EnableUDP: options.EnableUDPProxy, EnableICMP: options.EnableICMPProxy, - EnableHTTP: options.EnableHTTPProxy, MTU: mtu, }) if err != nil { @@ -354,13 +351,13 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) { return net.DialUDP(laddr, nil) } -// AddProxySubnetRule adds a subnet rule to the proxy handler -// If portRanges is nil or empty, all ports are allowed for this subnet -// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com") -func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool, resourceId int) { +// AddProxySubnetRule adds a subnet rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (net *Net) AddProxySubnetRule(rule SubnetRule) { tun := (*netTun)(net) if tun.proxyHandler != nil { - tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp, resourceId) + tun.proxyHandler.AddSubnetRule(rule) } } From 092535441ece30b3ffa30722e8079c58290bab51 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 16:13:19 -0400 Subject: [PATCH 3/7] Pass the new data down from the websocket --- clients/clients.go | 34 +++++++++++++++++++++++++++------- netstack2/http_handler.go | 8 ++++---- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index d57ab70..e646053 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -40,13 +40,17 @@ type WgConfig struct { } type Target struct { - SourcePrefix string `json:"sourcePrefix"` - SourcePrefixes []string `json:"sourcePrefixes"` - DestPrefix string `json:"destPrefix"` - RewriteTo string `json:"rewriteTo,omitempty"` - DisableIcmp bool `json:"disableIcmp,omitempty"` - PortRange []PortRange `json:"portRange,omitempty"` - ResourceId int `json:"resourceId,omitempty"` + SourcePrefix string `json:"sourcePrefix"` + SourcePrefixes []string `json:"sourcePrefixes"` + DestPrefix string `json:"destPrefix"` + RewriteTo string `json:"rewriteTo,omitempty"` + DisableIcmp bool `json:"disableIcmp,omitempty"` + PortRange []PortRange `json:"portRange,omitempty"` + ResourceId int `json:"resourceId,omitempty"` + Protocol string `json:"protocol,omitempty"` // for now practicably either http or https + HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across + TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination + TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination } type PortRange struct { @@ -704,6 +708,10 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) } @@ -969,6 +977,10 @@ func (s *WireGuardService) ensureTargets(targets []Target) error { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1369,6 +1381,10 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } @@ -1494,6 +1510,10 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { PortRanges: portRanges, DisableIcmp: target.DisableIcmp, ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, }) logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) } diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 4efa6a1..c4d3a7c 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -26,9 +26,9 @@ import ( // HTTPTarget describes a single downstream HTTP or HTTPS service that the // proxy should forward requests to. type HTTPTarget struct { - DestAddr string // IP address or hostname of the downstream service - DestPort uint16 // TCP port of the downstream service - UseHTTPS bool // When true the outbound leg uses HTTPS + DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service + DestPort uint16 `json:"destPort"` // TCP port of the downstream service + UseHTTPS bool `json:"useHttps"` // When true the outbound leg uses HTTPS } // --------------------------------------------------------------------------- @@ -315,4 +315,4 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) h.getProxy(target).ServeHTTP(w, r) -} \ No newline at end of file +} From 342af9e42df54547b339ab1b7615a2a60100e2d4 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Apr 2026 17:21:36 -0400 Subject: [PATCH 4/7] Switch to scheme --- netstack2/http_handler.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index c4d3a7c..c31a791 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -28,7 +28,7 @@ import ( type HTTPTarget struct { DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service DestPort uint16 `json:"destPort"` // TCP port of the downstream service - UseHTTPS bool `json:"useHttps"` // When true the outbound leg uses HTTPS + Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS } // --------------------------------------------------------------------------- @@ -257,10 +257,7 @@ func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { // creating one on first use. Reusing the proxy preserves its http.Transport // connection pool, avoiding repeated TCP/TLS handshakes to the downstream. func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { - scheme := "http" - if target.UseHTTPS { - scheme = "https" - } + scheme := target.Scheme cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort) if v, ok := h.proxyCache.Load(cacheKey); ok { @@ -273,7 +270,7 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { } proxy := httputil.NewSingleHostReverseProxy(targetURL) - if target.UseHTTPS { + if target.Scheme == "https" { // Allow self-signed certificates on downstream HTTPS targets. proxy.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ @@ -307,10 +304,7 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { } target := rule.HTTPTargets[0] - scheme := "http" - if target.UseHTTPS { - scheme = "https" - } + scheme := target.Scheme logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) From 12776d65c172d09fc4ec19e70c8c12efb94584eb Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 11 Apr 2026 21:56:28 -0700 Subject: [PATCH 5/7] Add logging --- clients/clients.go | 7 ++ netstack2/http_handler.go | 45 ++++++++- netstack2/http_request_log.go | 175 ++++++++++++++++++++++++++++++++++ netstack2/proxy.go | 27 ++++++ netstack2/tun.go | 10 ++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 netstack2/http_request_log.go diff --git a/clients/clients.go b/clients/clients.go index e646053..3862160 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -850,6 +850,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { }) }) + // Configure the HTTP request log sender to ship compressed request logs via websocket + s.tnet.SetHTTPRequestLogSender(func(data string) error { + return s.client.SendMessageNoLog("newt/request-log", map[string]interface{}{ + "compressed": data, + }) + }) + // Create WireGuard device using the shared bind s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger( device.LogLevelSilent, // Use silent logging by default - could be made configurable diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index c31a791..8e04413 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -14,6 +14,7 @@ import ( "net/http/httputil" "net/url" "sync" + "time" "github.com/fosrl/newt/logger" "gvisor.dev/gvisor/pkg/tcpip/stack" @@ -50,8 +51,9 @@ type HTTPTarget struct { // Outbound connections to downstream targets honour HTTPTarget.UseHTTPS // independently of the incoming protocol. type HTTPHandler struct { - stack *stack.Stack - proxyHandler *ProxyHandler + stack *stack.Stack + proxyHandler *ProxyHandler + requestLogger *HTTPRequestLogger listener *chanListener server *http.Server @@ -152,6 +154,12 @@ func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { } } +// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request +// is recorded and periodically shipped to the server. +func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) { + h.requestLogger = rl +} + // Start launches the internal http.Server that services connections delivered // via HandleConn. The server runs for the lifetime of the HTTPHandler; call // Close to stop it. @@ -289,6 +297,19 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { return actual.(*httputil.ReverseProxy) } +// statusCapture wraps an http.ResponseWriter and records the HTTP status code +// written by the upstream handler. If WriteHeader is never called the status +// defaults to 200 (http.StatusOK), matching net/http semantics. +type statusCapture struct { + http.ResponseWriter + status int +} + +func (sc *statusCapture) WriteHeader(code int) { + sc.status = code + sc.ResponseWriter.WriteHeader(code) +} + // handleRequest is the http.Handler entry point. It retrieves the SubnetRule // attached to the connection by ConnContext, selects the first configured // downstream target, and forwards the request via the cached ReverseProxy. @@ -308,5 +329,23 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { logger.Info("HTTP handler: %s %s -> %s://%s:%d", r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) - h.getProxy(target).ServeHTTP(w, r) + timestamp := time.Now() + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK} + + h.getProxy(target).ServeHTTP(sc, r) + + if h.requestLogger != nil && rule.ResourceId != 0 { + h.requestLogger.LogRequest(HTTPRequestLog{ + ResourceID: rule.ResourceId, + Timestamp: timestamp, + Method: r.Method, + Scheme: rule.Protocol, + Host: r.Host, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + UserAgent: r.UserAgent(), + SourceAddr: r.RemoteAddr, + TLS: rule.Protocol == "https", + }) + } } diff --git a/netstack2/http_request_log.go b/netstack2/http_request_log.go new file mode 100644 index 0000000..85ab5db --- /dev/null +++ b/netstack2/http_request_log.go @@ -0,0 +1,175 @@ +package netstack2 + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/json" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler. +type HTTPRequestLog struct { + RequestID string `json:"requestId"` + ResourceID int `json:"resourceId"` + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + RawQuery string `json:"rawQuery,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + SourceAddr string `json:"sourceAddr"` + TLS bool `json:"tls"` +} + +// HTTPRequestLogger buffers HTTP request logs and periodically flushes them +// to the server via a configurable SendFunc. +type HTTPRequestLogger struct { + mu sync.Mutex + pending []HTTPRequestLog + sendFn SendFunc + stopCh chan struct{} + flushDone chan struct{} +} + +// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop. +func NewHTTPRequestLogger() *HTTPRequestLogger { + rl := &HTTPRequestLogger{ + pending: make([]HTTPRequestLog, 0), + stopCh: make(chan struct{}), + flushDone: make(chan struct{}), + } + go rl.backgroundLoop() + return rl +} + +// SetSendFunc sets the callback used to send compressed HTTP request log batches +// to the server. This can be called after construction once the websocket +// client is available. +func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.sendFn = fn +} + +// LogRequest adds an HTTP request log entry to the buffer. If the buffer +// reaches maxBufferedSessions entries a flush is triggered immediately. +func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) { + if log.RequestID == "" { + log.RequestID = generateSessionID() + } + + rl.mu.Lock() + rl.pending = append(rl.pending, log) + shouldFlush := len(rl.pending) >= maxBufferedSessions + rl.mu.Unlock() + + if shouldFlush { + rl.flush() + } +} + +// backgroundLoop handles periodic flushing of buffered request logs. +func (rl *HTTPRequestLogger) backgroundLoop() { + defer close(rl.flushDone) + + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + for { + select { + case <-rl.stopCh: + return + case <-ticker.C: + rl.flush() + } + } +} + +// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc. +// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries +// to prevent unbounded memory growth when the server is unreachable. +func (rl *HTTPRequestLogger) flush() { + rl.mu.Lock() + if len(rl.pending) == 0 { + rl.mu.Unlock() + return + } + batch := rl.pending + rl.pending = make([]HTTPRequestLog, 0) + sendFn := rl.sendFn + rl.mu.Unlock() + + if sendFn == nil { + logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch)) + return + } + + compressed, err := compressRequestLogs(batch) + if err != nil { + logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err) + return + } + + if err := sendFn(compressed); err != nil { + logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err) + // Re-queue the batch so we don't lose data + rl.mu.Lock() + rl.pending = append(batch, rl.pending...) + // Cap re-queued data to prevent unbounded growth if server is unreachable + if len(rl.pending) > maxBufferedSessions*5 { + dropped := len(rl.pending) - maxBufferedSessions*5 + rl.pending = rl.pending[:maxBufferedSessions*5] + logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped) + } + rl.mu.Unlock() + return + } + + logger.Info("HTTP request logger: sent %d requests to server", len(batch)) +} + +// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and +// returns a base64-encoded string suitable for embedding in a JSON message. +func compressRequestLogs(logs []HTTPRequestLog) (string, error) { + jsonData, err := json.Marshal(logs) + if err != nil { + return "", err + } + + var buf bytes.Buffer + w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// Close shuts down the background loop and performs one final flush to send +// any remaining buffered requests to the server. +func (rl *HTTPRequestLogger) Close() { + select { + case <-rl.stopCh: + // Already closed + return + default: + close(rl.stopCh) + } + + // Wait for the background loop to exit so we don't race on flush + <-rl.flushDone + + rl.flush() +} \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go index f4c2352..b08eea3 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -133,6 +133,7 @@ type ProxyHandler struct { icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel notifiable channel.Notification // Notification handler for triggering reads accessLogger *AccessLogger // Access logger for tracking sessions + httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests } // ProxyHandlerOptions configures the proxy handler @@ -187,6 +188,9 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { if err := handler.httpHandler.Start(); err != nil { return nil, fmt.Errorf("failed to start HTTP handler: %v", err) } + + handler.httpRequestLogger = NewHTTPRequestLogger() + handler.httpHandler.SetRequestLogger(handler.httpRequestLogger) logger.Debug("ProxyHandler: HTTP handler enabled") } @@ -289,6 +293,24 @@ func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) { p.accessLogger.SetSendFunc(fn) } +// GetHTTPRequestLogger returns the HTTP request logger. +func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger { + if p == nil { + return nil + } + return p.httpRequestLogger +} + +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) { + if p == nil || !p.enabled || p.httpRequestLogger == nil { + return + } + p.httpRequestLogger.SetSendFunc(fn) +} + // LookupDestinationRewrite looks up the rewritten destination for a connection // This is used by TCP/UDP handlers to find the actual target address func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) { @@ -810,6 +832,11 @@ func (p *ProxyHandler) Close() error { p.accessLogger.Close() } + // Shut down HTTP request logger + if p.httpRequestLogger != nil { + p.httpRequestLogger.Close() + } + // Shut down HTTP handler if p.httpHandler != nil { p.httpHandler.Close() diff --git a/netstack2/tun.go b/netstack2/tun.go index 5d2d6e1..fae90dd 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -394,6 +394,16 @@ func (net *Net) SetAccessLogSender(fn SendFunc) { } } +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (net *Net) SetHTTPRequestLogSender(fn SendFunc) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.SetHTTPRequestLogSender(fn) + } +} + type PingConn struct { laddr PingAddr raddr PingAddr From 5c9d13bccae79b163ce1b3f8543cec688c8137c2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 17:00:06 -0700 Subject: [PATCH 6/7] Add ldflags version to local --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c35bbbf..53c4bb2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= dev LDFLAGS = -X main.newtVersion=$(VERSION) local: - CGO_ENABLED=0 go build -o ./bin/newt + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt docker-build: docker build -t fosrl/newt:latest . From 26de268466549b6fa96286926afedc30a60858c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Apr 2026 15:04:59 -0700 Subject: [PATCH 7/7] Add x-forwarded-for --- netstack2/http_handler.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index 8e04413..4ff7ed9 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -276,17 +276,29 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { Scheme: scheme, Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), } - proxy := httputil.NewSingleHostReverseProxy(targetURL) - + insecureTransport := (*http.Transport)(nil) if target.Scheme == "https" { // Allow self-signed certificates on downstream HTTPS targets. - proxy.Transport = &http.Transport{ + insecureTransport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration }, } } + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(targetURL) + // 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 + // Director means the proxy does not append its own automatic + // X-Forwarded-For entry, so the header is set exactly once. + pr.SetXForwarded() + }, + Transport: insecureTransport, + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { logger.Error("HTTP handler: upstream error (%s %s -> %s): %v", r.Method, r.URL.RequestURI(), cacheKey, err)