Basic browser gateway target support

Former-commit-id: 559b7021fe
This commit is contained in:
Owen
2026-05-13 16:24:26 -07:00
parent b72c8e643d
commit c55071c60a
5 changed files with 170 additions and 18 deletions
+77
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)
+8
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 --
+6
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)
}
+6
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;