The HID failure came down to the endpoint type map being indexed by endpoint number without the direction bit. A composite device can have endpoint 1 as both interrupt IN (0x81) and bulk OUT (0x01); the last one read won, so interrupt URBs were submitted as bulk and the kernel rejected them. The device attached and stayed silent. Endpoint data now comes from the raw descriptors read from /dev/bus/usb rather than sysfs, which only ever exposes the active alternate setting — a webcam's isochronous endpoints are invisible there because they only exist after SET_INTERFACE. Two sysfs parsing bugs fell out of that too: the numeric endpoint attributes are hex without a prefix (wMaxPacketSize "0040" was read as 40, not 64), and bInterval was never read at all. Reliability: three places could freeze the whole process. The share path fed io.Pipe from the WebSocket read loop, so one slow USB transfer stalled every tunnel and the keepalives with them. The relay wrote to client sockets while holding the hub lock, so one peer that stopped reading blocked routing and registration for everyone. Control transfers ran inline in the protocol loop behind a 5s timeout. Also fixed: a use-after- free where a discarded URB's memory could be collected while the kernel still owned it, a reap loop that spun at 100% CPU on ioctl errors, a missing attach timeout, a double close(done) panic, and Hash[:8] in the relay's log line, which let a client with a short hash take the server down. Adds mode "both", so one client can offer and consume devices at once. The tunnel and client-left callbacks became multicast for it: as plain fields the second manager to register silently unhooked the first. Tunnel traffic is now AES-256-GCM end to end, on the relay path as well as directly. The key is derived from the three tokens, not from the group hash — the relay is told the hash, so a key derived from it would protect nothing from the one party in the middle. Group IDs are unchanged, so existing setups keep working; only clients configured without the tokens drop to unencrypted, relay-only operation. Peers now try to connect directly, with the relay supplying the public address neither side can determine for itself. Candidates are raced because an unreachable address hangs until timeout rather than refusing. Falling back to the relay is not an error. Platform reach: cross-compiled targets for ARM, MIPS and RISC-V (the Linux client needed no code changes — usbdevfs is not architecture specific), multi-arch Docker images, an Android bridge that accepts devices over SCM_RIGHTS because apps cannot open /dev/bus/usb, and macOS builds via system_profiler enumeration. Adds a Windows KMDF filter driver under driver/windows with its Go side. UNTESTED: it has never been compiled or run, needs the WDK to build and an EV certificate to distribute. Treat it as a starting point. Adds "usb-client diag": says per machine whether sharing and using are possible, what stands in the way, and what fixes it. Reports can be uploaded to a relay to get them off machines that are awkward to copy from. 96 tests, all green under -race. Builds for linux, windows and darwin on amd64 and arm64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
615 lines
15 KiB
Go
615 lines
15 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/duffy/usb-server/internal/config"
|
|
"github.com/duffy/usb-server/internal/crypto"
|
|
"github.com/duffy/usb-server/internal/protocol"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
// readTimeout is how long we tolerate silence from the relay. The relay
|
|
// pings every 20s, and gorilla answers pings automatically, so exceeding
|
|
// this means the connection is genuinely dead — including the case where
|
|
// a NAT or proxy dropped it without sending a TCP reset.
|
|
readTimeout = 60 * time.Second
|
|
|
|
// pingInterval is how often we ping the relay ourselves, so that an idle
|
|
// tunnel keeps NAT mappings alive from both directions.
|
|
pingInterval = 20 * time.Second
|
|
|
|
// writeTimeout bounds a single frame write.
|
|
writeTimeout = 20 * time.Second
|
|
|
|
// sendQueueDepth bounds outgoing backlog before we consider the link stuck.
|
|
sendQueueDepth = 256
|
|
|
|
// reconnectMin/reconnectMax bound the exponential backoff between
|
|
// reconnect attempts, so a relay outage does not turn into a hot loop.
|
|
reconnectMin = 1 * time.Second
|
|
reconnectMax = 30 * time.Second
|
|
)
|
|
|
|
// outMsg is one queued outgoing WebSocket frame.
|
|
type outMsg struct {
|
|
typ int
|
|
data []byte
|
|
}
|
|
|
|
// Client manages the connection to the relay server
|
|
type Client struct {
|
|
cfg *config.Config
|
|
clientID string
|
|
|
|
mu sync.Mutex
|
|
conn *websocket.Conn
|
|
send chan outMsg
|
|
dead chan struct{}
|
|
|
|
// Callbacks for messages that only one manager can own.
|
|
// In "both" mode the share manager takes the share-side ones and the use
|
|
// manager the use-side ones, so they never collide.
|
|
OnDeviceList func(msg *protocol.DeviceList) // use side
|
|
OnDeviceGranted func(msg *protocol.DeviceGranted) // use side
|
|
OnDeviceDenied func(msg *protocol.DeviceDenied) // use side
|
|
OnDeviceReleased func(msg *protocol.DeviceReleased) // use side
|
|
OnClientJoined func(msg *protocol.ClientJoined)
|
|
OnRequestDevice func(targetClient, fromClient, busID, requestID string) // share side
|
|
OnReleaseDevice func(busID, fromClient string) // share side
|
|
OnForceRelease func(targetClient, fromClient, busID string) // share side
|
|
|
|
// Multicast callbacks. Both managers care about these, so they are lists
|
|
// rather than single fields: in "both" mode a plain field would mean the
|
|
// second manager to register silently unhooked the first.
|
|
tunnelHandlers []func(tunnelID string, data []byte)
|
|
clientLeftHandlers []func(msg *protocol.ClientLeft)
|
|
disconnectHandlers []func()
|
|
handlerMu sync.RWMutex
|
|
|
|
// OnConnect fires once a registration has been sent successfully.
|
|
OnConnect func()
|
|
|
|
// secret derives per-tunnel keys and peer tokens. Nil when the config
|
|
// carries only a group hash, in which case tunnels stay unencrypted and
|
|
// direct connections are unavailable.
|
|
secret *crypto.TunnelSecret
|
|
|
|
// directPort is advertised to the relay so peers learn where to reach us.
|
|
directPort int
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewClient creates a new client instance
|
|
func NewClient(cfg *config.Config) *Client {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
c := &Client{
|
|
cfg: cfg,
|
|
clientID: uuid.New().String(),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
if cfg.HasTokens() {
|
|
secret, err := crypto.DeriveTunnelSecret(cfg.Token1, cfg.Token2, cfg.Token3)
|
|
if err != nil {
|
|
log.Printf("[client] tunnel encryption unavailable: %v", err)
|
|
} else {
|
|
c.secret = secret
|
|
}
|
|
} else {
|
|
log.Printf("[client] no tokens configured, only a group hash: " +
|
|
"tunnels will not be encrypted and direct connections are unavailable")
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// TunnelSecret returns the group secret, or nil if it could not be derived.
|
|
func (c *Client) TunnelSecret() *crypto.TunnelSecret { return c.secret }
|
|
|
|
// SetDirectPort records the port peers should use to reach this client
|
|
// directly. It is announced with the next registration.
|
|
func (c *Client) SetDirectPort(port int) {
|
|
c.mu.Lock()
|
|
c.directPort = port
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// ID returns the client ID
|
|
func (c *Client) ID() string {
|
|
return c.clientID
|
|
}
|
|
|
|
// Config returns the client config
|
|
func (c *Client) Config() *config.Config {
|
|
return c.cfg
|
|
}
|
|
|
|
// Context returns the client's lifetime context.
|
|
func (c *Client) Context() context.Context {
|
|
return c.ctx
|
|
}
|
|
|
|
// relayURL normalises the configured relay address into a WebSocket URL.
|
|
func (c *Client) relayURL() (string, error) {
|
|
u, err := url.Parse(c.cfg.RelayAddr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid relay address: %w", err)
|
|
}
|
|
|
|
switch u.Scheme {
|
|
case "ws", "wss":
|
|
// ok
|
|
case "http":
|
|
u.Scheme = "ws"
|
|
case "https":
|
|
u.Scheme = "wss"
|
|
default:
|
|
u.Scheme = "ws"
|
|
}
|
|
|
|
if u.Path == "" || u.Path == "/" {
|
|
u.Path = "/ws"
|
|
}
|
|
|
|
return u.String(), nil
|
|
}
|
|
|
|
// Connect establishes connection to the relay server
|
|
func (c *Client) Connect() error {
|
|
target, err := c.relayURL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("[client] connecting to %s", target)
|
|
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: 15 * time.Second,
|
|
ReadBufferSize: 64 * 1024,
|
|
WriteBufferSize: 64 * 1024,
|
|
}
|
|
|
|
conn, _, err := dialer.DialContext(c.ctx, target, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("connecting to relay: %w", err)
|
|
}
|
|
|
|
conn.SetReadLimit(maxMessageSize)
|
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
return nil
|
|
})
|
|
|
|
c.mu.Lock()
|
|
directPort := c.directPort
|
|
c.mu.Unlock()
|
|
|
|
reg := &protocol.Register{
|
|
Type: protocol.MsgRegister,
|
|
Hash: c.cfg.Hash,
|
|
Mode: c.cfg.Mode,
|
|
ClientID: c.clientID,
|
|
Name: c.cfg.Name,
|
|
DirectPort: directPort,
|
|
LocalEndpoints: localEndpoints(directPort),
|
|
}
|
|
regData, err := json.Marshal(reg)
|
|
if err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("encoding registration: %w", err)
|
|
}
|
|
|
|
// The registration is written directly because the write pump is not
|
|
// running yet; every later write goes through the pump.
|
|
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
if err := conn.WriteMessage(websocket.TextMessage, regData); err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("sending registration: %w", err)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.conn = conn
|
|
c.send = make(chan outMsg, sendQueueDepth)
|
|
c.dead = make(chan struct{})
|
|
sendCh, deadCh := c.send, c.dead
|
|
c.mu.Unlock()
|
|
|
|
go c.writePump(conn, sendCh, deadCh)
|
|
|
|
log.Printf("[client] registered as %s (mode=%s, name=%s)",
|
|
protocol.ShortID(c.clientID), c.cfg.Mode, c.cfg.Name)
|
|
|
|
if c.OnConnect != nil {
|
|
c.OnConnect()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// maxMessageSize must match the relay's limit.
|
|
const maxMessageSize = 1024 * 1024
|
|
|
|
// writePump serialises all writes to the relay socket and sends keepalives.
|
|
func (c *Client) writePump(conn *websocket.Conn, send <-chan outMsg, dead <-chan struct{}) {
|
|
ticker := time.NewTicker(pingInterval)
|
|
defer ticker.Stop()
|
|
defer conn.Close() // unblocks the read loop if we give up first
|
|
|
|
for {
|
|
select {
|
|
case msg := <-send:
|
|
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
if err := conn.WriteMessage(msg.typ, msg.data); err != nil {
|
|
log.Printf("[client] write error: %v", err)
|
|
return
|
|
}
|
|
|
|
case <-ticker.C:
|
|
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
|
|
case <-dead:
|
|
return
|
|
|
|
case <-c.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// RunReadLoop reads messages from the relay and dispatches them
|
|
func (c *Client) RunReadLoop() error {
|
|
c.mu.Lock()
|
|
conn := c.conn
|
|
c.mu.Unlock()
|
|
|
|
if conn == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
for {
|
|
msgType, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
return fmt.Errorf("read error: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
|
|
switch msgType {
|
|
case websocket.TextMessage:
|
|
c.handleTextMessage(data)
|
|
case websocket.BinaryMessage:
|
|
c.handleBinaryMessage(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run connects and runs the main loop with auto-reconnect
|
|
func (c *Client) Run() error {
|
|
backoff := reconnectMin
|
|
|
|
for {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
if err := c.Connect(); err != nil {
|
|
log.Printf("[client] connection failed: %v, retrying in %s", err, backoff)
|
|
if !c.sleep(backoff) {
|
|
return nil
|
|
}
|
|
backoff = nextBackoff(backoff)
|
|
continue
|
|
}
|
|
|
|
// Connected: reset the backoff so a later blip retries promptly.
|
|
backoff = reconnectMin
|
|
|
|
err := c.RunReadLoop()
|
|
if err != nil {
|
|
log.Printf("[client] disconnected: %v", err)
|
|
} else {
|
|
log.Printf("[client] disconnected")
|
|
}
|
|
|
|
c.teardown()
|
|
|
|
// The relay dropped every tunnel involving us; local state that
|
|
// still references one has to go too.
|
|
c.fireDisconnect()
|
|
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
log.Printf("[client] reconnecting in %s", backoff)
|
|
if !c.sleep(backoff) {
|
|
return nil
|
|
}
|
|
backoff = nextBackoff(backoff)
|
|
}
|
|
}
|
|
|
|
// nextBackoff doubles the delay up to reconnectMax.
|
|
func nextBackoff(d time.Duration) time.Duration {
|
|
d *= 2
|
|
if d > reconnectMax {
|
|
return reconnectMax
|
|
}
|
|
return d
|
|
}
|
|
|
|
// sleep waits for d, returning false if the client is shutting down.
|
|
func (c *Client) sleep(d time.Duration) bool {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return true
|
|
case <-c.ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
// teardown closes the current connection and stops its write pump.
|
|
func (c *Client) teardown() {
|
|
c.mu.Lock()
|
|
if c.dead != nil {
|
|
close(c.dead)
|
|
c.dead = nil
|
|
}
|
|
if c.conn != nil {
|
|
c.conn.Close()
|
|
c.conn = nil
|
|
}
|
|
c.send = nil
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Close shuts down the client
|
|
func (c *Client) Close() {
|
|
c.cancel()
|
|
c.teardown()
|
|
}
|
|
|
|
// AddTunnelHandler registers a handler for incoming tunnel frames.
|
|
// Handlers receive every frame and must ignore tunnel IDs they do not own.
|
|
func (c *Client) AddTunnelHandler(fn func(tunnelID string, data []byte)) {
|
|
c.handlerMu.Lock()
|
|
defer c.handlerMu.Unlock()
|
|
c.tunnelHandlers = append(c.tunnelHandlers, fn)
|
|
}
|
|
|
|
// AddClientLeftHandler registers a handler for peer disconnects.
|
|
func (c *Client) AddClientLeftHandler(fn func(msg *protocol.ClientLeft)) {
|
|
c.handlerMu.Lock()
|
|
defer c.handlerMu.Unlock()
|
|
c.clientLeftHandlers = append(c.clientLeftHandlers, fn)
|
|
}
|
|
|
|
// AddDisconnectHandler registers a handler that runs after the relay
|
|
// connection drops and before reconnecting.
|
|
//
|
|
// The relay forgets every tunnel when a client disconnects, so anything still
|
|
// attached locally now points at a tunnel that no longer exists. Handlers use
|
|
// this to tear that state down instead of leaving devices wedged until the
|
|
// process restarts.
|
|
func (c *Client) AddDisconnectHandler(fn func()) {
|
|
c.handlerMu.Lock()
|
|
defer c.handlerMu.Unlock()
|
|
c.disconnectHandlers = append(c.disconnectHandlers, fn)
|
|
}
|
|
|
|
func (c *Client) fireDisconnect() {
|
|
c.handlerMu.RLock()
|
|
handlers := append([]func(){}, c.disconnectHandlers...)
|
|
c.handlerMu.RUnlock()
|
|
for _, fn := range handlers {
|
|
fn()
|
|
}
|
|
}
|
|
|
|
// Connected reports whether the client currently has a live relay connection.
|
|
func (c *Client) Connected() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.conn != nil
|
|
}
|
|
|
|
// enqueue queues an outgoing frame. It never blocks on the socket; a full
|
|
// queue means the relay link is stuck, which is reported as an error so the
|
|
// caller can tear down whatever it was trying to send.
|
|
func (c *Client) enqueue(typ int, data []byte) error {
|
|
c.mu.Lock()
|
|
send, dead := c.send, c.dead
|
|
c.mu.Unlock()
|
|
|
|
if send == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
select {
|
|
case send <- outMsg{typ: typ, data: data}:
|
|
return nil
|
|
case <-dead:
|
|
return fmt.Errorf("connection closed")
|
|
case <-c.ctx.Done():
|
|
return fmt.Errorf("client shutting down")
|
|
default:
|
|
return fmt.Errorf("send queue full, relay link stalled")
|
|
}
|
|
}
|
|
|
|
// SendJSON sends a JSON message to the relay
|
|
func (c *Client) SendJSON(v interface{}) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding message: %w", err)
|
|
}
|
|
return c.enqueue(websocket.TextMessage, data)
|
|
}
|
|
|
|
// SendBinary sends a binary message to the relay
|
|
func (c *Client) SendBinary(data []byte) error {
|
|
return c.enqueue(websocket.BinaryMessage, data)
|
|
}
|
|
|
|
// SendTunnelData sends tunnel data with the tunnel ID prefix
|
|
func (c *Client) SendTunnelData(tunnelID string, data []byte) error {
|
|
// Tunnel header: 16 bytes tunnel ID + payload
|
|
msg := make([]byte, protocol.TunnelHeaderSize+len(data))
|
|
copy(msg[:protocol.TunnelHeaderSize], tunnelID)
|
|
copy(msg[protocol.TunnelHeaderSize:], data)
|
|
return c.SendBinary(msg)
|
|
}
|
|
|
|
func (c *Client) handleTextMessage(data []byte) {
|
|
var env protocol.Envelope
|
|
if err := json.Unmarshal(data, &env); err != nil {
|
|
return
|
|
}
|
|
|
|
switch env.Type {
|
|
case protocol.MsgDeviceList:
|
|
if c.OnDeviceList != nil {
|
|
var msg protocol.DeviceList
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnDeviceList(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgRequestDevice:
|
|
if c.OnRequestDevice != nil {
|
|
var msg struct {
|
|
TargetClient string `json:"target_client"`
|
|
FromClient string `json:"from_client"`
|
|
BusID string `json:"bus_id"`
|
|
RequestID string `json:"request_id"`
|
|
}
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnRequestDevice(msg.TargetClient, msg.FromClient, msg.BusID, msg.RequestID)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgDeviceGranted:
|
|
if c.OnDeviceGranted != nil {
|
|
var msg protocol.DeviceGranted
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnDeviceGranted(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgDeviceDenied:
|
|
if c.OnDeviceDenied != nil {
|
|
var msg protocol.DeviceDenied
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnDeviceDenied(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgForceRelease:
|
|
if c.OnForceRelease != nil {
|
|
var msg struct {
|
|
TargetClient string `json:"target_client"`
|
|
FromClient string `json:"from_client"`
|
|
BusID string `json:"bus_id"`
|
|
}
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnForceRelease(msg.TargetClient, msg.FromClient, msg.BusID)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgReleaseDevice:
|
|
if c.OnReleaseDevice != nil {
|
|
var msg struct {
|
|
BusID string `json:"bus_id"`
|
|
FromClient string `json:"from_client"`
|
|
}
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnReleaseDevice(msg.BusID, msg.FromClient)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgDeviceReleased:
|
|
if c.OnDeviceReleased != nil {
|
|
var msg protocol.DeviceReleased
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnDeviceReleased(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgClientJoined:
|
|
if c.OnClientJoined != nil {
|
|
var msg protocol.ClientJoined
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.OnClientJoined(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgClientLeft:
|
|
var msg protocol.ClientLeft
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
c.handlerMu.RLock()
|
|
handlers := append([]func(*protocol.ClientLeft){}, c.clientLeftHandlers...)
|
|
c.handlerMu.RUnlock()
|
|
for _, fn := range handlers {
|
|
fn(&msg)
|
|
}
|
|
}
|
|
|
|
case protocol.MsgPong:
|
|
// ignore pong
|
|
|
|
case protocol.MsgError:
|
|
var msg protocol.ErrorMsg
|
|
if json.Unmarshal(data, &msg) == nil {
|
|
log.Printf("[client] error from relay: %s", msg.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) handleBinaryMessage(data []byte) {
|
|
if len(data) < protocol.TunnelHeaderSize {
|
|
return
|
|
}
|
|
|
|
tunnelID := string(data[:protocol.TunnelHeaderSize])
|
|
payload := data[protocol.TunnelHeaderSize:]
|
|
|
|
c.handlerMu.RLock()
|
|
handlers := c.tunnelHandlers
|
|
c.handlerMu.RUnlock()
|
|
|
|
for _, fn := range handlers {
|
|
fn(tunnelID, payload)
|
|
}
|
|
}
|