From 559b7021fece50378719f2ed85de6eb7022cf048 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 16:24:26 -0700 Subject: [PATCH] Basic browser gateway target support --- browsergateway/browsergateway.go | 77 +++++++++++++++++++++++++++ browsergateway/rdp.go | 8 +++ browsergateway/ssh.go | 6 +++ browsergateway/vnc.go | 6 +++ main.go | 91 +++++++++++++++++++++++++------- 5 files changed, 170 insertions(+), 18 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 2f3a2e5..56faffc 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,7 +1,10 @@ package browsergateway import ( + "errors" + "net" "net/http" + "sync" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -9,6 +12,24 @@ import ( // without wasting memory per session. const forwardBufSize = 64 * 1024 +// ListenPort is the port the browser gateway HTTP server listens on inside the +// WireGuard netstack. This is a fixed value shared between newt and pangolin. +const ListenPort = 8082 + +// HardcodedAuthToken is a temporary shared secret used during development. +// TODO: replace with a per-session token negotiated with pangolin. +const HardcodedAuthToken = "pangolin-browser-gateway-dev" + +// Target represents an allowed proxy destination for the browser gateway. +// Only connections whose (Type, Destination, DestinationPort) match a +// registered Target will be forwarded; all others are rejected. +type Target struct { + ID int + Type string // "rdp" | "ssh" | "vnc" + Destination string + DestinationPort int +} + // Config holds the configuration for a Gateway. type Config struct { // AuthToken is the shared secret required by RDP clients in the RDCleanPath @@ -25,6 +46,11 @@ type Config struct { type Gateway struct { authToken string nativeSSH *NativeSSHConfig + + mu sync.RWMutex + targets map[int]Target // keyed by Target.ID + + server *http.Server } // New creates a new Gateway from the provided Config. @@ -32,9 +58,60 @@ func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, nativeSSH: cfg.NativeSSH, + targets: make(map[int]Target), } } +// SetTargets replaces the entire allowed-destination list atomically. +func (g *Gateway) SetTargets(targets []Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets = make(map[int]Target, len(targets)) + for _, t := range targets { + g.targets[t.ID] = t + } +} + +// AddTarget adds or updates a single allowed destination. +func (g *Gateway) AddTarget(t Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets[t.ID] = t +} + +// RemoveTarget removes an allowed destination by its ID. +func (g *Gateway) RemoveTarget(id int) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.targets, id) +} + +// isAllowed reports whether a connection to (targetType, host, port) is +// permitted by the current target list. +func (g *Gateway) isAllowed(targetType, host string, port int) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType && t.Destination == host && t.DestinationPort == port { + return true + } + } + return false +} + +// Start serves the browser gateway HTTP server on the provided listener. +// It returns nil when the listener is closed (normal shutdown). +func (g *Gateway) Start(ln net.Listener) error { + mux := http.NewServeMux() + g.RegisterHandlers(mux) + g.server = &http.Server{Handler: mux} + err := g.server.Serve(ln) + if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index dbd58e0..78046b3 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -68,6 +69,13 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { target = net.JoinHostPort(target, "3389") } + // Validate destination against the registered target allowlist. + rdpHost, rdpPortStr, _ := net.SplitHostPort(target) + rdpPort, _ := strconv.Atoi(rdpPortStr) + if !g.isAllowed("rdp", rdpHost, rdpPort) { + return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + } + log.Printf("Connecting to RDP server %s", target) // -- Open TCP connection to the destination RDP server -- diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 290eb5a..0596f99 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -8,6 +8,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -56,6 +57,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { if port == "" { port = "22" } + sshPort, _ := strconv.Atoi(port) + if !g.isAllowed("ssh", host, sshPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target = net.JoinHostPort(host, port) } diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 57a93a7..55ec439 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -7,6 +7,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -40,6 +41,11 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { if port == "" { port = "5900" } + vncPort, _ := strconv.Atoi(port) + if !g.isAllowed("vnc", host, vncPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target := net.JoinHostPort(host, port) // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; diff --git a/main.go b/main.go index 448f71d..c86a35c 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "time" "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" @@ -40,15 +41,23 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type BrowserGatewayTarget struct { + ID int `json:"id"` + Type string `json:"type"` + Destination string `json:"destination"` + DestinationPort int `json:"destinationPort"` +} + type WgData struct { - Endpoint string `json:"endpoint"` - RelayPort uint16 `json:"relayPort"` - PublicKey string `json:"publicKey"` - ServerIP string `json:"serverIP"` - TunnelIP string `json:"tunnelIP"` - Targets TargetsByType `json:"targets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - ChainId string `json:"chainId"` + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -134,6 +143,8 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() pendingPingChainId string healthFile string useNativeInterface bool @@ -150,15 +161,15 @@ var ( newtVersion = "version_replaceme" // Observability/metrics flags - metricsEnabled bool - otlpEnabled bool - adminAddr string - region string - metricsAsyncBytes bool - pprofEnabled bool - blueprintFile string - provisioningBlueprintFile string - noCloud bool + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -741,6 +752,13 @@ func runNewtMain(ctx context.Context) { pingStopChan = nil } + // Shutdown browser gateway if running + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + browserGateway = nil + } + // Stop proxy manager if running if pm != nil { pm.Stop() @@ -947,6 +965,43 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if err != nil { logger.Error("Failed to start proxy manager: %v", err) } + + // Start browser gateway if targets are present + if len(wgData.BrowserGatewayTargets) > 0 { + // Shutdown any existing gateway first + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + } + + bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets)) + for _, t := range wgData.BrowserGatewayTargets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{ + AuthToken: browsergateway.HardcodedAuthToken, + }) + browserGateway.SetTargets(bgTargets) + + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } }) client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { @@ -1841,7 +1896,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } else { logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") } - + sendBlueprint(client, blueprintFile) if client.WasJustProvisioned() { logger.Info("Provisioning detected – sending provisioning blueprint")