Basic browser gateway target support

This commit is contained in:
Owen
2026-05-13 16:24:26 -07:00
parent bd53edf8e4
commit 559b7021fe
5 changed files with 170 additions and 18 deletions

View File

@@ -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)

View File

@@ -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 --

View File

@@ -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)
}

View File

@@ -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;

91
main.go
View File

@@ -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")