Files
tunnel/internal/server/bridge.go
T

574 lines
13 KiB
Go

package server
import (
"context"
"encoding/json"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// Hub tracks the attached bridges: which tunnel they belong to and which
// routes each claimed. A route belongs to one bridge at a time.
type Hub struct {
store *Store
mu sync.Mutex
bridges map[*Bridge]struct{}
byRoute map[string]map[string]*Bridge // tunnel ID -> route -> bridge
}
func NewHub(store *Store) *Hub {
return &Hub{
store: store,
bridges: map[*Bridge]struct{}{},
byRoute: map[string]map[string]*Bridge{},
}
}
// online reports whether the tunnel has an attached bridge.
func (h *Hub) online(tunnelID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, b := range h.byRoute[tunnelID] {
_ = b
return true
}
return false
}
// bridgeFor returns the bridge that claimed route on the tunnel, or nil.
func (h *Hub) bridgeFor(tunnelID, route string) *Bridge {
h.mu.Lock()
defer h.mu.Unlock()
return h.byRoute[tunnelID][route]
}
// closeTunnel closes every bridge of the tunnel (it was deleted).
func (h *Hub) closeTunnel(tunnelID string) {
h.mu.Lock()
var bridges []*Bridge
for b := range h.bridges {
if b.tunnelID == tunnelID {
bridges = append(bridges, b)
}
}
h.mu.Unlock()
for _, b := range bridges {
b.close(websocket.StatusGoingAway, "tunnel deleted")
}
}
// drainAll announces drain to every bridge and closes it, so clients
// reconnect elsewhere (used on shutdown).
func (h *Hub) DrainAll() {
h.mu.Lock()
bridges := make([]*Bridge, 0, len(h.bridges))
for b := range h.bridges {
bridges = append(bridges, b)
}
h.mu.Unlock()
for _, b := range bridges {
drain, _ := json.Marshal(drainMessage{Type: "drain", Reason: "shutdown"})
b.trySend(websocket.MessageText, drain)
b.close(websocket.StatusServiceRestart, "shutdown")
}
}
// BridgeInfo is a snapshot of one attached bridge for the admin API.
type BridgeInfo struct {
Session string
TunnelID string
ClientVersion string
Routes []string
Visitors int
MaxConns int
ConnectedAt time.Time
}
// BridgeInfos snapshots every attached bridge. Field reads are safe without
// the bridge lock because they are immutable after construction; only the
// visitor count needs the lock.
func (h *Hub) BridgeInfos() []BridgeInfo {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]BridgeInfo, 0, len(h.bridges))
for b := range h.bridges {
b.mu.Lock()
visitors := len(b.conns)
b.mu.Unlock()
out = append(out, BridgeInfo{
Session: b.session,
TunnelID: b.tunnelID,
ClientVersion: b.clientVersion,
Routes: b.routes,
Visitors: visitors,
MaxConns: b.maxConns,
ConnectedAt: b.startedAt,
})
}
return out
}
func (h *Hub) register(b *Bridge) bool {
h.mu.Lock()
defer h.mu.Unlock()
claimed := h.byRoute[b.tunnelID]
if claimed == nil {
claimed = map[string]*Bridge{}
h.byRoute[b.tunnelID] = claimed
}
for _, route := range b.routes {
if _, taken := claimed[route]; taken {
return false
}
}
for _, route := range b.routes {
claimed[route] = b
}
h.bridges[b] = struct{}{}
return true
}
func (h *Hub) unregister(b *Bridge) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.bridges, b)
if claimed := h.byRoute[b.tunnelID]; claimed != nil {
for route, owner := range claimed {
if owner == b {
delete(claimed, route)
}
}
if len(claimed) == 0 {
delete(h.byRoute, b.tunnelID)
}
}
}
// Bridge is one attached client session: the WebSocket, its routes, and its
// visitor connections.
type Bridge struct {
hub *Hub
tunnelID string
session string
routes []string
maxConns int
ws *websocket.Conn
clientVersion string
startedAt time.Time
outbox chan outbound
done chan struct{}
closeOnce sync.Once
mu sync.Mutex
conns map[uint32]*visitorConn
nextConn uint32
lastActivity atomic.Int64
}
type outbound struct {
typ websocket.MessageType
data []byte
}
// attach upgrades the HTTP connection and runs the bridge session. It blocks
// until the session ends.
func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel) {
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{BridgeSubprotocol},
CompressionMode: websocket.CompressionDisabled,
OriginPatterns: nil, // CLI and SDK send no Origin header
})
if err != nil {
return // Accept already replied
}
ws.SetReadLimit(clientHelloLimit) // 64 KiB before attach
ctx := context.Background()
fail := func(code string) {
msg, _ := json.Marshal(attachErrorMessage{Type: "attach_error", Code: code})
writeWS(ctx, ws, websocket.MessageText, msg)
ws.Close(websocket.StatusPolicyViolation, code)
}
// The attach must arrive within 10 seconds.
readCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
typ, raw, err := ws.Read(readCtx)
cancel()
if err != nil {
ws.Close(websocket.StatusPolicyViolation, "attach timeout")
return
}
var attach attachMessage
if typ != websocket.MessageText || json.Unmarshal(raw, &attach) != nil || attach.Type != "attach" {
fail("bad_attach")
return
}
if !tokenMatches(attach.Token, tunnel.TokenHash) {
fail("bad_token")
return
}
if tunnel.Certificate == nil {
fail("cert_not_ready")
return
}
if len(attach.Routes) > maxRoutesPerAttach {
fail("too_many_routes")
return
}
routes := dedupe(attach.Routes)
for _, route := range routes {
if !validRoute(route) {
fail("invalid_route")
return
}
}
b := &Bridge{
hub: h,
tunnelID: tunnel.ID,
session: sessionID(),
routes: routes,
maxConns: maxConnsPerBridge,
ws: ws,
clientVersion: attach.Client.Version,
startedAt: time.Now(),
outbox: make(chan outbound, 256),
done: make(chan struct{}),
conns: map[uint32]*visitorConn{},
}
if attach.Client.MaxConns > 0 && attach.Client.MaxConns < b.maxConns {
b.maxConns = attach.Client.MaxConns
}
b.touch()
if !h.register(b) {
fail("route_conflict")
return
}
attached, _ := json.Marshal(attachedMessage{
Type: "attached",
Session: b.session,
Routes: routes,
HeartbeatMs: heartbeatMs,
IdleTimeoutMs: idleTimeoutMs,
})
if err := writeWS(ctx, ws, websocket.MessageText, attached); err != nil {
h.unregister(b)
ws.Close(websocket.StatusInternalError, "write failed")
return
}
ws.SetReadLimit(4 + maxPayload) // a data frame with the largest payload
go b.writeLoop()
go b.sweeper()
b.readLoop() // blocks until the WebSocket ends
b.close(websocket.StatusGoingAway, "session ended")
}
// openVisitor hands a new public connection to the bridge: an open control
// message, the ClientHello as the first data, and a pump for what follows.
func (b *Bridge) openVisitor(conn net.Conn, hello []byte, peer, sni, alpn string) {
b.mu.Lock()
if len(b.conns) >= b.maxConns {
b.mu.Unlock()
conn.Close()
return
}
id := b.nextConn
b.nextConn++
v := &visitorConn{id: id, conn: conn}
b.conns[id] = v
b.mu.Unlock()
open, _ := json.Marshal(openMessage{
Type: "open", Conn: id, Peer: peer, SNI: sni, ALPN: alpn,
})
if !b.send(websocket.MessageText, open) {
b.removeConn(id, true)
return
}
for len(hello) > 0 {
n := min(len(hello), maxPayload)
if !b.send(websocket.MessageBinary, buildFrame(id, hello[:n])) {
b.removeConn(id, true)
return
}
hello = hello[n:]
}
go b.pump(v)
}
// pump copies visitor bytes to the bridge and half-closes on EOF.
func (b *Bridge) pump(v *visitorConn) {
buf := make([]byte, maxPayload)
for {
n, err := v.conn.Read(buf)
v.touch()
if n > 0 {
if !b.send(websocket.MessageBinary, buildFrame(v.id, buf[:n])) {
return
}
}
if err != nil {
b.mu.Lock()
v.visitorEnded = true
bothEnded := v.clientEnded
stillKnown := b.conns[v.id] == v
b.mu.Unlock()
if !stillKnown {
return // reset or removed already
}
if err == io.EOF {
end, _ := json.Marshal(endMessage{Type: "end", Conn: v.id})
b.send(websocket.MessageText, end)
} else {
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: v.id, Code: ResetIOError})
b.send(websocket.MessageText, reset)
b.removeConn(v.id, true)
return
}
if bothEnded {
b.removeConn(v.id, false)
}
return
}
}
}
// readLoop applies client control messages and data frames until the
// WebSocket fails.
func (b *Bridge) readLoop() {
ctx := context.Background()
for {
typ, raw, err := b.ws.Read(ctx)
if err != nil {
log.Printf("bridge %s: read: %v", b.session, err)
return
}
b.touch()
if typ == websocket.MessageText {
b.handleControl(raw)
continue
}
connID, payload, ok := parseFrame(raw)
if !ok {
continue
}
b.mu.Lock()
v := b.conns[connID]
b.mu.Unlock()
if v == nil {
continue
}
v.touch()
if _, err := v.conn.Write(payload); err != nil {
b.removeConn(connID, true)
}
}
}
func (b *Bridge) handleControl(raw []byte) {
msg, err := parseClientControl(raw)
if err != nil {
return
}
switch msg.Type {
case "ping":
pong, _ := json.Marshal(pingPongMessage{Type: "pong", TimeSent: msg.TimeSent})
b.send(websocket.MessageText, pong)
case "end":
if msg.Conn != nil {
b.endFromClient(*msg.Conn)
}
case "reset":
if msg.Conn != nil {
b.removeConn(*msg.Conn, true)
}
}
// Unknown types and fields are ignored.
}
func (b *Bridge) endFromClient(connID uint32) {
b.mu.Lock()
v := b.conns[connID]
b.mu.Unlock()
if v == nil {
return
}
b.mu.Lock()
v.clientEnded = true
bothEnded := v.visitorEnded
b.mu.Unlock()
if tcp, ok := v.conn.(*net.TCPConn); ok {
tcp.CloseWrite()
}
if bothEnded {
b.removeConn(connID, false)
}
}
// writeLoop is the only writer on the WebSocket, keeping frames ordered.
func (b *Bridge) writeLoop() {
ctx := context.Background()
for {
select {
case <-b.done:
return
case msg := <-b.outbox:
if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil {
b.close(websocket.StatusInternalError, "write failed")
return
}
}
}
}
// sweeper retires a silent bridge (idle_timeout + heartbeat) and resets
// visitor connections with no traffic for 15 minutes.
func (b *Bridge) sweeper() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-b.done:
return
case now := <-ticker.C:
if now.UnixMilli()-b.lastActivity.Load() > (idleTimeoutMs + heartbeatMs) {
b.close(websocket.StatusGoingAway, "idle timeout")
return
}
b.mu.Lock()
var stale []uint32
for id, v := range b.conns {
if now.UnixMilli()-v.lastActivity.Load() > 15*60*1000 {
stale = append(stale, id)
}
}
b.mu.Unlock()
for _, id := range stale {
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout})
b.send(websocket.MessageText, reset)
b.removeConn(id, true)
}
}
}
}
// send queues a frame, applying backpressure when the client is slow.
// It returns false when the bridge is gone.
func (b *Bridge) send(typ websocket.MessageType, data []byte) bool {
select {
case <-b.done:
return false
case b.outbox <- outbound{typ, data}:
return true
}
}
// trySend queues a frame only if it fits immediately.
func (b *Bridge) trySend(typ websocket.MessageType, data []byte) {
select {
case b.outbox <- outbound{typ, data}:
default:
}
}
// removeConn closes the visitor socket (abortively when rst) and forgets it.
func (b *Bridge) removeConn(id uint32, rst bool) {
b.mu.Lock()
v := b.conns[id]
delete(b.conns, id)
b.mu.Unlock()
if v == nil {
return
}
if rst {
if tcp, ok := v.conn.(*net.TCPConn); ok {
tcp.SetLinger(0)
}
}
v.conn.Close()
}
func (b *Bridge) touch() {
b.lastActivity.Store(time.Now().UnixMilli())
}
// close ends the session exactly once: conns reset, routes released.
func (b *Bridge) close(status websocket.StatusCode, reason string) {
b.closeOnce.Do(func() {
close(b.done)
b.hub.unregister(b)
b.mu.Lock()
ids := make([]uint32, 0, len(b.conns))
for id := range b.conns {
ids = append(ids, id)
}
b.mu.Unlock()
for _, id := range ids {
b.removeConn(id, true)
}
b.ws.Close(status, reason)
})
}
// visitorConn is one public TCP connection carried over the bridge.
type visitorConn struct {
id uint32
conn net.Conn
visitorEnded bool // pump saw EOF: no more visitor data
clientEnded bool // client sent end: target will send no more
lastActivity atomic.Int64
}
func (v *visitorConn) touch() {
v.lastActivity.Store(time.Now().UnixMilli())
}
func writeWS(ctx context.Context, ws *websocket.Conn, typ websocket.MessageType, data []byte) error {
writeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return ws.Write(writeCtx, typ, data)
}
func dedupe(routes []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(routes))
for _, route := range routes {
if _, ok := seen[route]; !ok {
seen[route] = struct{}{}
out = append(out, route)
}
}
return out
}
// isWebSocketUpgrade reports the shape of the upgrade request, for the 426
// responses the contract pins.
func upgradeRequested(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func subprotocolOffered(r *http.Request) bool {
for _, offered := range strings.Split(r.Header.Get("Sec-Websocket-Protocol"), ",") {
if strings.TrimSpace(offered) == BridgeSubprotocol {
return true
}
}
return false
}