Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
574 lines
13 KiB
Go
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 !certReady(tunnel.Certificate) {
|
|
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
|
|
}
|