WebSocket API
WebSocket API
Section titled “WebSocket API”Bifrost provides WebSocket APIs for real-time event streaming, allowing applications to receive instant notifications about connections, backend health, configuration changes, and statistics.
Server WebSocket
Section titled “Server WebSocket”Connection
Section titled “Connection”Connect to the server WebSocket endpoint:
ws://localhost:7082/api/v1/wsWith Authentication:
ws://localhost:7082/api/v1/ws?token=your-api-tokenA browser cannot set an Authorization header on a WebSocket handshake, which is
why ?token= is accepted. The server strips the parameter out of the URL before
anything logs the request, so the token does not appear in Bifrost’s own request
log — but a reverse proxy in front of Bifrost will still log the full URL. From a
browser, prefer the session cookie: POST /api/v1/login exchanges the token for
an HttpOnly cookie, which the handshake then carries automatically and which no
URL ever contains.
Origin Restrictions
Section titled “Origin Restrictions”WebSockets are exempt from both the same-origin policy and CORS, so — unlike a
fetch() — any web page loaded in a browser can attempt to open a socket to any
address that browser can reach, including localhost. Without an origin check, a
random page could connect to a local Bifrost and read the live traffic stream.
When no api.token is configured, /api/v1/ws has no authentication either.
The upgrade is therefore accepted only when one of the following holds:
| Condition | Result |
|---|---|
The Origin header’s host matches the request Host |
Accepted — this is the dashboard Bifrost itself serves, and needs no configuration |
The origin matches an entry in api.allowed_origins |
Accepted |
No Origin header is sent at all |
Accepted — non-browser clients (CLI, curl, scripts) do not send one, and a web page cannot suppress its own Origin |
| Anything else | Rejected with HTTP 403 |
The request Host is checked as well, because the first rule above would
otherwise be forgeable by DNS rebinding (see below). A Host
is accepted when it is an IP literal (127.0.0.1:7082, [::1]:7082,
192.168.1.10:7082), a loopback name (localhost, anything under
.localhost), or named in api.allowed_origins. Reaching the dashboard by
any other hostname — an mDNS name like bifrost.local, or an internal DNS
record — means adding that host to api.allowed_origins, the same entry a
Host-rewriting proxy already needs.
Reverse proxies that rewrite Host
Section titled “Reverse proxies that rewrite Host”If a proxy in front of Bifrost rewrites Host, the browser’s Origin no longer
matches it and the handshake is refused. This is the common case for Home
Assistant Ingress, and also applies to Traefik, nginx and Cloudflare Tunnel
setups that do not preserve the original Host. Name the browser-visible origin
explicitly:
api: enabled: true listen: "0.0.0.0:7082" allowed_origins: # The origin the browser sees, NOT Bifrost's own listen address. - "https://bifrost.example.com" # Home Assistant Ingress: the Home Assistant origin. - "http://homeassistant.local:8123"Each entry is either a host pattern (bifrost.example.com,
homeassistant.local:8123) or a scheme-qualified origin
(https://bifrost.example.com). Matching is case-insensitive and supports
shell-style wildcards. Entries carrying a path, an invalid wildcard, or stray
whitespace are rejected at startup rather than silently never matching.
Two matching rules are worth knowing, because getting them wrong produces a 403 with no other symptom:
- Ports are part of the host and are matched literally.
bifrost.example.comdoes not match an origin ofhttp://bifrost.example.com:8080. Include the port whenever the browser’s URL shows one — i.e. anything other than plain:80forhttpor:443forhttps. *spans dots but not the port separator.*.example.commatchesa.example.comanda.b.example.com, but nota.example.com:1234. Use*.example.com:8080for that, or list the origin in full.
Wildcards cannot be tricked into matching a longer suffix: bifrost.example.com
does not match bifrost.example.com.evil.com, because a pattern must match the
whole host.
allowed_origins is read at startup, so changes require a restart — editing
it in the dashboard and saving is not enough.
DNS rebinding
Section titled “DNS rebinding”An Origin-only check would not survive DNS rebinding. An attacker who
controls a hostname serves a page from it, re-points that name at your Bifrost’s
address, and their page then arrives with Origin and Host both reading
evil.example:7082 — which agree, so a same-origin comparison passes.
Bifrost therefore also requires the Host itself to be one an attacker cannot
forge that way. Rebinding fundamentally needs a name to re-point, so IP
literals and the reserved loopback names are inherently safe; everything else
must be named in api.allowed_origins. A rebound Host matches none of those
and is refused with HTTP 403, logged as:
rejected WebSocket upgrade: request Host is neither an IP literal, a loopbackname, nor listed in api.allowed_origins; add it there to allow this hostIf you see that log line for a hostname you use legitimately, add it to
api.allowed_origins and restart.
This check only ever constrains browser-driven attacks. A process that can
reach the port directly sets any header it likes, so it is unaffected — for that
threat the control is api.token, and with no token configured /api/v1/ws is
readable by anything that can reach it.
Disabling the check
Section titled “Disabling the check”A single "*" entry turns origin verification off completely:
api: allowed_origins: ["*"]This is an escape hatch for setups whose origin cannot be predicted. It disables
the Host check as well, logs a warning at startup, and means any web page
opened in a browser that can reach this server may connect to /api/v1/ws and
read the live traffic stream. Name the real origins instead wherever you can.
"*" must be the only entry. A list such as
["https://app.example", "*"] is rejected at startup, because it reads as a
narrow grant while actually disabling the check entirely.
Connection Limits
Section titled “Connection Limits”| Setting | Default | Description |
|---|---|---|
| Max Clients | 100 | Maximum concurrent WebSocket connections |
| Read Timeout | 60s | Time to wait for client messages |
| Low-Power Mode | 5-10 | Recommended for OpenWrt/embedded devices |
Keep-Alive
Section titled “Keep-Alive”Send periodic ping messages to keep the connection alive:
const ws = new WebSocket('ws://localhost:7082/api/v1/ws?token=your-token');
// Send ping every 30 secondssetInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send('ping'); }}, 30000);
ws.onmessage = (event) => { if (event.data === 'pong') { console.log('Keep-alive acknowledged'); return; } // Handle other messages const data = JSON.parse(event.data); handleEvent(data);};Event Types
Section titled “Event Types”All events follow this structure:
{ "type": "event.type", "timestamp": "2024-01-15T10:00:00Z", "data": { /* event-specific data */ }}backend.health
Section titled “backend.health”Fired when a backend’s health status changes.
{ "type": "backend.health", "timestamp": "2024-01-15T10:00:00Z", "data": { "name": "wireguard-us", "healthy": false }}| Field | Type | Description |
|---|---|---|
name |
string | Backend name |
healthy |
boolean | Current health status |
Use Cases:
- Update dashboard health indicators
- Trigger failover notifications
- Log health state changes
connection.new
Section titled “connection.new”Fired when a new connection is established through the proxy.
{ "type": "connection.new", "timestamp": "2024-01-15T10:00:00Z", "data": { "protocol": "CONNECT", "host": "api.example.com:443", "backend": "direct", "client_ip": "192.168.1.100" }}| Field | Type | Description |
|---|---|---|
protocol |
string | HTTP, CONNECT, or SOCKS5 |
host |
string | Destination host and port |
backend |
string | Backend handling the connection |
client_ip |
string | Client’s IP address |
Use Cases:
- Real-time traffic monitoring
- Connection counting
- Live activity feeds
connection.close
Section titled “connection.close”Fired when a connection is closed.
{ "type": "connection.close", "timestamp": "2024-01-15T10:00:00Z", "data": { "protocol": "CONNECT", "host": "api.example.com:443", "backend": "direct", "client_ip": "192.168.1.100" }}Same structure as connection.new.
config.reload
Section titled “config.reload”Fired when configuration is reloaded.
{ "type": "config.reload", "timestamp": "2024-01-15T10:00:00Z", "data": { "changed_sections": ["routes", "rate_limit"], "requires_restart": false }}| Field | Type | Description |
|---|---|---|
changed_sections |
string[] | Config sections that changed |
requires_restart |
boolean | Whether restart is needed |
config.saved
Section titled “config.saved”Fired when configuration is saved to disk.
{ "type": "config.saved", "timestamp": "2024-01-15T10:00:00Z", "data": { "changed_sections": ["server", "access_control"], "hot_reloaded_sections": ["access_control"], "restart_required_sections": ["server"], "requires_restart": true }}| Field | Type | Description |
|---|---|---|
changed_sections |
string[] | Config sections that changed |
hot_reloaded_sections |
string[] | Changed sections applied to the running server immediately |
restart_required_sections |
string[] | Changed sections that take effect after a restart |
requires_restart |
boolean | True exactly when restart_required_sections is non-empty |
stats.update
Section titled “stats.update”Periodic statistics update (when enabled).
{ "type": "stats.update", "timestamp": "2024-01-15T10:00:00Z", "data": { "active_connections": 25, "total_connections": 5000, "bytes_sent": 104857600, "bytes_received": 209715200 }}| Field | Type | Description |
|---|---|---|
active_connections |
int | Current active connections |
total_connections |
int | Total connections since start |
bytes_sent |
int | Total bytes sent |
bytes_received |
int | Total bytes received |
Client Log Streaming
Section titled “Client Log Streaming”The client provides Server-Sent Events (SSE) for log streaming.
Connection
Section titled “Connection”GET /api/v1/logs/streamContent-Type: text/event-stream
Subscriber Limits
Section titled “Subscriber Limits”| Setting | Value | Description |
|---|---|---|
| Max Subscribers | 100 | Maximum concurrent log stream connections |
| Buffer Size | 100 | Messages buffered per subscriber |
Event Format
Section titled “Event Format”data: {"type": "connected"}
data: {"timestamp": "2024-01-15T10:00:00Z", "level": "info", "message": "GET https://api.example.com/v1/users", "fields": {"method": "GET", "status_code": 200}}
data: {"timestamp": "2024-01-15T10:00:01Z", "level": "error", "message": "Connection failed", "fields": {"error": "timeout", "host": "slow-api.example.com"}}Log Entry Structure
Section titled “Log Entry Structure”{ "timestamp": "2024-01-15T10:00:00Z", "level": "info", "message": "GET https://api.example.com/v1/users", "fields": { "method": "GET", "url": "api.example.com/v1/users", "status_code": 200, "duration_ms": 150, "action": "server", "error": "" }}| Field | Type | Description |
|---|---|---|
timestamp |
string | ISO 8601 timestamp |
level |
string | debug, info, warn, error |
message |
string | Human-readable message |
fields |
object | Additional structured data |
Mesh Network Events
Section titled “Mesh Network Events”The server provides WebSocket events for mesh network peer changes.
Connection
Section titled “Connection”ws://localhost:7082/api/v1/mesh/networks/{networkID}/eventsEvent Types
Section titled “Event Types”peer.joined
Section titled “peer.joined”{ "type": "peer.joined", "timestamp": "2024-01-15T10:00:00Z", "data": { "peer_id": "peer-xyz789", "name": "laptop-work", "virtual_ip": "10.100.0.5" }}peer.left
Section titled “peer.left”{ "type": "peer.left", "timestamp": "2024-01-15T10:00:00Z", "data": { "peer_id": "peer-xyz789", "name": "laptop-work" }}peer.updated
Section titled “peer.updated”{ "type": "peer.updated", "timestamp": "2024-01-15T10:00:00Z", "data": { "peer_id": "peer-xyz789", "endpoints": [ {"address": "192.168.1.100", "port": 51820, "type": "lan"} ] }}Implementation Examples
Section titled “Implementation Examples”JavaScript/TypeScript
Section titled “JavaScript/TypeScript”class BifrostWebSocket { private ws: WebSocket | null = null; private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectDelay = 1000;
constructor( private url: string, private token: string, private handlers: { onBackendHealth?: (data: { name: string; healthy: boolean }) => void; onConnectionNew?: (data: ConnectionEvent) => void; onConnectionClose?: (data: ConnectionEvent) => void; onStatsUpdate?: (data: StatsEvent) => void; onConfigReload?: (data: ConfigEvent) => void; } ) {}
connect(): void { const wsUrl = `${this.url}?token=${this.token}`; this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { console.log('Connected to Bifrost WebSocket'); this.reconnectAttempts = 0; this.startHeartbeat(); };
this.ws.onmessage = (event) => { if (event.data === 'pong') return;
const message = JSON.parse(event.data); this.handleMessage(message); };
this.ws.onclose = () => { console.log('WebSocket disconnected'); this.scheduleReconnect(); };
this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; }
private handleMessage(message: { type: string; data: any }): void { switch (message.type) { case 'backend.health': this.handlers.onBackendHealth?.(message.data); break; case 'connection.new': this.handlers.onConnectionNew?.(message.data); break; case 'connection.close': this.handlers.onConnectionClose?.(message.data); break; case 'stats.update': this.handlers.onStatsUpdate?.(message.data); break; case 'config.reload': case 'config.saved': this.handlers.onConfigReload?.(message.data); break; } }
private startHeartbeat(): void { setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send('ping'); } }, 30000); }
private scheduleReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('Max reconnection attempts reached'); return; }
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts); this.reconnectAttempts++;
setTimeout(() => this.connect(), delay); }
disconnect(): void { this.ws?.close(); }}
// Usageconst bifrost = new BifrostWebSocket( 'ws://localhost:7082/api/v1/ws', 'your-api-token', { onBackendHealth: (data) => { console.log(`Backend ${data.name} is ${data.healthy ? 'healthy' : 'unhealthy'}`); }, onConnectionNew: (data) => { console.log(`New connection: ${data.host} via ${data.backend}`); }, onStatsUpdate: (data) => { console.log(`Active connections: ${data.active_connections}`); }, });
bifrost.connect();Python
Section titled “Python”import asyncioimport websocketsimport json
class BifrostWebSocket: def __init__(self, url: str, token: str): self.url = f"{url}?token={token}" self.ws = None
async def connect(self): async with websockets.connect(self.url) as ws: self.ws = ws
# Start heartbeat task heartbeat_task = asyncio.create_task(self._heartbeat())
try: async for message in ws: if message == "pong": continue await self._handle_message(json.loads(message)) finally: heartbeat_task.cancel()
async def _heartbeat(self): while True: await asyncio.sleep(30) if self.ws: await self.ws.send("ping")
async def _handle_message(self, message: dict): event_type = message.get("type") data = message.get("data", {})
if event_type == "backend.health": print(f"Backend {data['name']}: {'healthy' if data['healthy'] else 'unhealthy'}") elif event_type == "connection.new": print(f"New connection: {data['host']} via {data['backend']}") elif event_type == "stats.update": print(f"Active connections: {data['active_connections']}")
# Usageasync def main(): client = BifrostWebSocket("ws://localhost:7082/api/v1/ws", "your-token") await client.connect()
asyncio.run(main())package main
import ( "encoding/json" "log" "time"
"golang.org/x/net/websocket")
type Event struct { Type string `json:"type"` Timestamp string `json:"timestamp"` Data map[string]interface{} `json:"data"`}
func main() { origin := "http://localhost:7082" url := "ws://localhost:7082/api/v1/ws?token=your-token"
ws, err := websocket.Dial(url, "", origin) if err != nil { log.Fatal(err) } defer ws.Close()
// Heartbeat goroutine go func() { ticker := time.NewTicker(30 * time.Second) for range ticker.C { websocket.Message.Send(ws, "ping") } }()
// Message handler for { var msg string if err := websocket.Message.Receive(ws, &msg); err != nil { log.Printf("Error receiving: %v", err) break }
if msg == "pong" { continue }
var event Event if err := json.Unmarshal([]byte(msg), &event); err != nil { log.Printf("Error parsing event: %v", err) continue }
switch event.Type { case "backend.health": log.Printf("Backend %s: healthy=%v", event.Data["name"], event.Data["healthy"]) case "connection.new": log.Printf("New connection: %s via %s", event.Data["host"], event.Data["backend"]) case "stats.update": log.Printf("Active connections: %v", event.Data["active_connections"]) } }}SSE Log Streaming Example
Section titled “SSE Log Streaming Example”JavaScript
Section titled “JavaScript”class LogStreamer { constructor(baseUrl, token) { this.baseUrl = baseUrl; this.token = token; this.eventSource = null; }
connect(onLog, onError) { const url = `${this.baseUrl}/api/v1/logs/stream`; this.eventSource = new EventSource(url);
this.eventSource.onopen = () => { console.log('Log stream connected'); };
this.eventSource.onmessage = (event) => { const log = JSON.parse(event.data);
if (log.type === 'connected') { console.log('Log stream ready'); return; }
onLog(log); };
this.eventSource.onerror = (error) => { console.error('Log stream error:', error); onError?.(error); }; }
disconnect() { this.eventSource?.close(); }}
// Usageconst streamer = new LogStreamer('http://localhost:7383', 'your-token');
streamer.connect( (log) => { const color = log.level === 'error' ? 'red' : 'inherit'; console.log(`%c[${log.level}] ${log.message}`, `color: ${color}`); }, (error) => { console.error('Stream error:', error); });React Hook
Section titled “React Hook”import { useEffect, useState, useCallback } from 'react';
interface LogEntry { timestamp: string; level: string; message: string; fields?: Record<string, any>;}
export function useLogStream(baseUrl: string, maxLogs = 1000) { const [logs, setLogs] = useState<LogEntry[]>([]); const [connected, setConnected] = useState(false); const [error, setError] = useState<Error | null>(null);
useEffect(() => { const eventSource = new EventSource(`${baseUrl}/api/v1/logs/stream`);
eventSource.onopen = () => { setConnected(true); setError(null); };
eventSource.onmessage = (event) => { const log = JSON.parse(event.data); if (log.type === 'connected') return;
setLogs((prev) => { const updated = [log, ...prev]; return updated.slice(0, maxLogs); }); };
eventSource.onerror = () => { setConnected(false); setError(new Error('Log stream disconnected')); };
return () => { eventSource.close(); }; }, [baseUrl, maxLogs]);
const clearLogs = useCallback(() => { setLogs([]); }, []);
return { logs, connected, error, clearLogs };}
// Usage in componentfunction LogViewer() { const { logs, connected, clearLogs } = useLogStream('http://localhost:7383');
return ( <div> <div>Status: {connected ? 'Connected' : 'Disconnected'}</div> <button onClick={clearLogs}>Clear</button> <ul> {logs.map((log, i) => ( <li key={i} className={`log-${log.level}`}> [{log.timestamp}] {log.message} </li> ))} </ul> </div> );}