On this page
bgplay·vis
Live BGP route collector + visualizer. Point your routers at it, it keeps per-peer adj-rib-in tables, and you watch announces / withdrawals / path changes fly around an animated AS graph in your browser.
┌─────────┐ BGP :179 ┌──────────────┐ ws :8080 ┌──────────┐
│ router1 │ ────────────▶ │ │ ───────────▶ │ browser │
│ router2 │ ────────────▶ │ bgplay-vis │ │ (canvas) │
│ router3 │ ────────────▶ │ AS-graph UI │ ◀─────────── │ search │
└─────────┘ └──────────────┘ /api/* └──────────┘
- Passive BGP speaker — receives everything, never originates routes.
- IPv4 + IPv6 unicast, 4-byte ASNs, add-path receive (multiple paths per prefix from route servers), MP-BGP, extended message length (RFC 8654), graceful-restart tolerant.
- Routes kept per-peer (adj-rib-in); session drops mark routes
staleinstead of dropping them, resync flushes what wasn't refreshed. - Live canvas: local AS centered, peers on ring 1, transit/origin ASes on outer rings; announces stream in along the AS path and settle as glowing dots orbiting their origin; withdrawals race back out in red.
- Search by prefix (
1.1.1.0/24), bare IP (8.8.8.8→ LPM chain), AS number (as13335→ every route through it), community (community:64512:100), or peer name. Results group by prefix and badge the best path per group. - Pause/resume the live stream; missed events are caught up via the event-history API.
- Every event is retained in a 4096-event ring — replayable over
/api/eventsor thesnapshot.recentfield — ready for a future bgpath archiver to tail.
Run
go build -o bgplay-vis ./cmd/bgplay-vis
./bgplay-vis -asn 65000 -rid 10.0.0.5 -bgp :179 -web :8080
Port 179 is privileged → run as root, or setcap 'cap_net_bind_service=+ep' bgplay-vis,
or just run -bgp :1179 and point routers at port 1179.
| flag | default | meaning |
|---|---|---|
-asn |
65000 | local ASN presented in OPEN |
-rid |
192.0.2.1 | BGP router-id |
-bgp |
:179 |
listen addr (-bgp= disables) |
-peer |
— | outbound session, repeatable (-peer 192.0.2.1) |
-hold |
90 | holdtime seconds (0 disables keepalives) |
-web |
:8080 |
UI address |
-demo |
off | synthetic feed, no router needed |
-md5 |
— | BGP TCP-MD5SIG key ip=password, repeatable (Linux) |
-allow |
— | inbound BGP allowlist CIDR, repeatable (empty = all) |
-max-routes |
0 | cap on stored routes, 0 = unlimited |
-max-peer-routes |
0 | cap on routes per peer, 0 = unlimited |
-max-inbound |
512 | max concurrent inbound BGP connections |
-token |
— | require token on /api/* and /ws (?token= or Authorization: Bearer) |
-origin |
— | allowed Origin host for /ws, repeatable (suffix match) |
-cors |
off | send Access-Control-Allow-Origin: * on the JSON API |
Try it instantly: ./bgplay-vis -demo -bgp :1179 → http://localhost:8080
Access control
The UI and API are read-only but expose your full routing table, so lock them down on untrusted networks:
-token s3cret— every/api/*(except/api/health) and/wsrequires?token=s3cretor a Bearer header. In the browser, openhttps://host/?token=s3cretonce; it's kept in sessionStorage./wsrejects cross-site browser connections by default: theOriginhost must match the requestHost.-origin example.comreplaces that with an explicit allowlist (with.example.comsubdomain matching) for deployments served under multiple names. Non-browser clients send noOriginand are unaffected.-md5 192.0.2.1=sharedsecret— TCP-MD5SIG on the BGP session itself (Linux only; works with the file capability already used for:179).-allow 10.0.0.0/8— only accept BGP TCP from these prefixes.
/api/health is unauthenticated by design (container/load-balancer
liveness probe, returns {"ok":true}).
Docker
docker build -t bgplay-vis .
docker run -d -p 179:179 -p 8080:8080 bgplay-vis -asn 65000
or docker compose up -d. Runs as a non-root user with all capabilities
dropped except NET_BIND_SERVICE (file cap on the binary), read_only,
mem_limit/pids_limit, json-file log rotation, and a built-in
HEALTHCHECK against /api/health. Stateless — everything is in memory.
Install (Debian/Ubuntu, one command)
sudo deploy/install-debian.sh --domain bgplay.example.com --email [email protected]
Builds from source (installs a checksummed Go toolchain if needed), drops
a systemd unit with AmbientCapabilities=CAP_NET_BIND_SERVICE and all
flags baked into ExecStart, and front-ends the UI with nginx + certbot
(443 with a 80→443 redirect). Without --domain it serves plain HTTP
on :80.
Flags: --asn, --rid, --bgp, --peer, --demo, --md5 ip=pass,
--allow CIDR, --max-routes, --max-peer-routes, --max-inbound,
--token, --origin, --cors, --tls none|letsencrypt, --uninstall.
Router config
The collector is a passive iBGP-style neighbor. It only receives — give it a session that exports your table (full view, or a route-server feed).
FRR (router connects to collector):
router bgp 64512
neighbor 10.0.0.5 remote-as 65000
neighbor 10.0.0.5 description bgplay-vis
address-family ipv4 unicast
neighbor 10.0.0.5 activate
neighbor 10.0.0.5 addpath-tx-all-paths ! optional: all paths, not just best
exit-address-family
BIRD:
protocol bgp bgplay {
local as 64512;
neighbor 10.0.0.5 port 179 as 65000;
ipv4 { export all; };
ipv6 { export all; };
add paths tx; # optional
}
pathvector: add a peer block — it renders the same BIRD config:
peers:
bgplay-vis:
asn: 65000
neighbors: ["10.0.0.5"]
type: upstream # exports the full table toward it
add-path: true # if your pathvector version supports it
Or let the collector dial out instead: ./bgplay-vis -peer r1.example.net -peer r2.example.net
(configure neighbor … passive on the routers).
API
| endpoint | returns |
|---|---|
GET /ws |
websocket: snapshot then batched events + 1s stats. Optional filters: ?pfx=, ?asn=, ?peer= (repeatable/combinable, applied server-side before send). Origin-checked (same-origin by default) |
GET /api/stats |
counters + peer table |
GET /api/peers |
peer table only |
GET /api/search?q= |
prefix / IP / asNNNN / community:x:y / peer-name → matching routes, best-path grouped client-side |
GET /api/events?since=<seq>&limit=<n> |
retained event history (ring of 4096), oldest→newest |
GET /api/metrics |
uptime, route/peer/event counters, ws client count |
GET /api/health |
{"ok":true} liveness probe (unauthenticated) |
/api/search is rate-limited per client IP (60/min). The JSON API sends
no CORS headers unless -cors is given; the UI is same-origin and doesn't
need them. Security headers (nosniff, Referrer-Policy, frame-ancestors,
CSP default-src 'self') are set on every response.
Event shape on the wire:
{"seq":4102,"ts":1793403661,"type":"announce","peer":"10.0.0.9","pa":64512,
"pfx":"203.0.113.0/24","path":[64512,174,64496],"nh":"10.0.0.1","fam":"ipv4-unicast"}
type ∈ announce | withdraw | change | peer_up | peer_down | peer_state | eor | flush.
Limits & failure modes
Everything is bounded so a hostile or buggy peer can't take the daemon down:
- BGP messages capped at 4 KB, or 64 KB once the peer negotiates RFC 8654 extended message length.
-max-inboundcaps concurrent inbound BGP sessions (semaphore, pre-auth); outbound sessions are per-peerflag.-max-routes/-max-peer-routescap stored adj-rib-in entries; overflow announces are dropped and counted instats.dropped.- Per-peer route indexes make session teardown, EOR flush and peer search O(peer routes) instead of O(table).
- Websocket clients capped at 256; slow consumers are disconnected rather than allowed to stall the event loop. Idle clients are pinged every 20 s and dropped after ~2 min of silence.
/api/searchrate-limited; trie and result scans bounded.- HTTP server:
ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeoutall set; hijacked WS conns manage their own deadlines. - Inbound source allowlist (
-allow) and per-peer TCP-MD5SIG (-md5).
Layout
cmd/bgplay-vis flags + wiring
internal/bgp wire format, OPEN caps, UPDATE parser, session FSM, speaker, TCP-MD5SIG
internal/rib adj-rib-in, diff engine, prefix trie, per-peer index, search
internal/hub event batching → fanout, replay ring, per-client filters, history
internal/web embedded UI, REST, hand-rolled websocket, auth/origin/CORS
internal/demo synthetic feed (-demo)
No external dependencies. go test ./... covers the UPDATE parser
(MP-BGP, AS4 merge, add-path, EOR), the session FSM over net.Pipe
(including malformed-message handling), RIB diff/caps/search/stale-flush,
hub ring wraparound + history + filters, and web auth/security headers.
go test -race ./... is clean.
Not (yet) included
BMP (RFC 7854) ingestion, RPKI origin-validation status, an ASN
organization/country dataset, and a radix-trie rewrite of the prefix
index. The per-bit trie is fine to ~hundreds of thousands of routes;
-max-routes bounds worst-case memory until then.