Skip to content

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.

Connect to the server WebSocket endpoint:

ws://localhost:7082/api/v1/ws

With Authentication:

ws://localhost:7082/api/v1/ws?token=your-api-token

A 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.

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.

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.com does not match an origin of http://bifrost.example.com:8080. Include the port whenever the browser’s URL shows one — i.e. anything other than plain :80 for http or :443 for https.
  • * spans dots but not the port separator. *.example.com matches a.example.com and a.b.example.com, but not a.example.com:1234. Use *.example.com:8080 for 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.

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 loopback
name, nor listed in api.allowed_origins; add it there to allow this host

If 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.

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.

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

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 seconds
setInterval(() => {
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);
};

All events follow this structure:

{
"type": "event.type",
"timestamp": "2024-01-15T10:00:00Z",
"data": { /* event-specific data */ }
}

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

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

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.


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

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

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

The client provides Server-Sent Events (SSE) for log streaming.

GET /api/v1/logs/stream

Content-Type: text/event-stream

Setting Value Description
Max Subscribers 100 Maximum concurrent log stream connections
Buffer Size 100 Messages buffered per subscriber
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"}}
{
"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

The server provides WebSocket events for mesh network peer changes.

ws://localhost:7082/api/v1/mesh/networks/{networkID}/events
{
"type": "peer.joined",
"timestamp": "2024-01-15T10:00:00Z",
"data": {
"peer_id": "peer-xyz789",
"name": "laptop-work",
"virtual_ip": "10.100.0.5"
}
}
{
"type": "peer.left",
"timestamp": "2024-01-15T10:00:00Z",
"data": {
"peer_id": "peer-xyz789",
"name": "laptop-work"
}
}
{
"type": "peer.updated",
"timestamp": "2024-01-15T10:00:00Z",
"data": {
"peer_id": "peer-xyz789",
"endpoints": [
{"address": "192.168.1.100", "port": 51820, "type": "lan"}
]
}
}

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();
}
}
// Usage
const 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();
import asyncio
import websockets
import 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']}")
# Usage
async 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"])
}
}
}

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();
}
}
// Usage
const 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);
}
);
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 component
function 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>
);
}