BMP Watcher¶
BMP Watcher brings the BGP control plane into Topolograph: peering sessions, the routes carried over them, VPN context, and every change to both.
It is a passive BMP station. Routers open a TCP session towards it and stream their Adj-RIB-In. The watcher never speaks BGP, never peers, and never connects to a router itself — so it adds no BGP state to the network it observes.
BGP state is kept separate from your IGP graph
A BGP session is a control-plane relationship, not a forwarding link. BGP is stored as its own graph with its own lifecycle and is bound to your OSPF and IS-IS graphs, never merged into them. A BGP graph also works on its own, with no IGP graph present at all.
What it collects¶
Address families¶
| Family | AFI | SAFI | Reported as |
|---|---|---|---|
| IPv4 unicast | 1 | 1 | prefix |
| IPv6 unicast | 2 | 1 | prefix |
| VPNv4 | 1 | 128 | l3vpn |
| VPNv6 | 2 | 128 | l3vpn |
| EVPN | 25 | 70 | evpn |
A VPN route is unique only together with its Route Distinguisher, so the RD is part of its identity. An EVPN route has no prefix at all and is identified by its RFC 7432 NLRI components — route type, Ethernet Segment ID, Ethernet Tag, MAC, IP.
BMP messages¶
| BMP message | What Topolograph does with it |
|---|---|
| Route Monitoring | builds the table, and every subsequent route change |
| Peer Up | session state, plus the peer's BGP Identifier — the Router ID events are attributed to |
| Peer Down | session teardown, plus a withdraw per route that peer carried |
| Initiation / Termination | collector session lifecycle |
| Statistics Report | ignored — counters are not routing state |
Policy streams and evidence¶
Both Adj-RIB-In streams are stored separately, and where the speaker supports RFC 9069 the Loc-RIB stream is kept as a third observation:
| Stream | Evidence level | Means |
|---|---|---|
pre / out-pre |
pre_policy |
the peer advertised it; the router may have rejected it |
post / out-post |
post_policy |
the router accepted it — a candidate path |
loc-rib |
loc_rib |
the router's own selection — the installed best path |
fib |
fib |
present in the forwarding table |
These are never merged. A route seen only pre-policy is never reported as selected or installed — that distinction is the reason both streams exist.
Observations, not subnets¶
The same prefix is stored once per speaker, once per peer, once per path ID and once per policy stream. Every copy survives, because "who advertised what to whom" is exactly the question a monitored table answers.
Try it first without a real network¶
Topolograph generates a BGP demo topology and opens it automatically on your first visit — 13 routers, 22 BGP sessions, two route reflectors with deliberately different BMP capabilities. The BGP overlay is on by default.
To explore it you do not need to configure BMP export on any router, run
bmpwatcher, or run the Fluent Bit shipper — those deliver data from a real
network. The step-by-step walkthrough, with screenshots, is the in-app guide at
/how-to/bgp.
Installing the collector¶
Compatibility
The BGP graph and this collector need topolograph v2.69 or later.
The collector is bmpwatcher — a
single static Go binary and a passive BMP server: routers dial in to
--bmp-port, it never connects out to a router. It separates the initial table
replay from the changes that follow. Its README covers the router-side BMP
configuration for FRR, IOS-XR, Junos and SR OS.
Create the API token first: Settings → API Tokens → Create token. The
workspace is resolved from the token on the server and is never taken from the
payload. The collector attaches it as Authorization: Bearer on every POST it
makes; --topolograph-api-token defaults to $TOPOLOGRAPH_API_TOKEN.
Quick check¶
Run it in the foreground once to confirm routers connect and a snapshot lands. Not durable — it stops with your shell.
export TOPOLOGRAPH_API_TOKEN=sk-...
bmpwatcher \
--bmp-port=11019 \
--source-id=pe1 \
--watcher-name=bmp-dc1 \
--events=/var/log/bmpwatcher/events.jsonl \
--topology-file=/var/log/bmpwatcher/topology.json \
--topolograph-topology-url=https://topolograph.com/api/watcher/bgp
With Docker, alongside Fluent Bit (recommended)¶
fluentbit/docker-compose.yml in the bmpwatcher repo carries an optional
bmpwatcher service behind the collector profile, so one compose file runs
the collector and the Fluent Bit event shipper together, both with
restart: unless-stopped:
cd fluentbit
cp .env.example .env # set TOPOLOGRAPH_API_TOKEN, SOURCE_ID, LABS_DIR,
# and TOPOLOGRAPH_TOPOLOGY_URL for a self-hosted instance
docker compose --profile collector up -d
As a service (systemd)¶
For a host without Docker, the static binary under systemd is the lightest
durable option. The repo ships bmpwatcher.service as a template.
sudo install -m 0755 bmpwatcher /usr/local/bin/bmpwatcher
sudo install -m 0644 bmpwatcher.service /etc/systemd/system/bmpwatcher.service
sudo mkdir -p /var/log/bmpwatcher
printf 'TOPOLOGRAPH_API_TOKEN=sk-...\n' | sudo tee /etc/bmpwatcher.env >/dev/null
sudo chmod 0600 /etc/bmpwatcher.env
sudo systemctl enable --now bmpwatcher
Restart=on-failure and WantedBy=multi-user.target cover a crash and a
reboot; logs go to the journal (journalctl -u bmpwatcher -f).
Ingest API¶
POST /api/watcher/bgp — the topology snapshot¶
The collector assembles the whole table over its collection window and posts it as one document.
curl -sS -X POST https://topolograph.com/api/watcher/bgp \
-H "Authorization: Bearer $TOPOLOGRAPH_API_TOKEN" \
-H "Content-Type: application/json" \
--data @topolograph-topology.json
{
"time": "2026-08-17T09:12:03Z",
"user": "bmp-dc1",
"srcid": "pe1",
"sesid": "b4f1c8e2",
"topology": {
"nodes": [
{"name": "10.0.0.1", "asn": "65001", "role": "speaker", "router_ip": "10.0.0.1"},
{"name": "10.0.0.2", "asn": "65002", "role": "peer"}
],
"edges": [
{"source": "10.0.0.1", "target": "10.0.0.2",
"peer_ip": "10.0.0.2", "local_ip": "10.0.0.1", "asn": "65002",
"peer_type": 0, "policies": ["pre", "post"], "families": ["1/1", "1/128"]}
],
"networks": [
{"subnet": "192.0.2.0/24", "type": "1", "subtype": 1,
"bmp_source": "10.0.0.1", "peer_ip": "10.0.0.2",
"policy": ["post"], "path_id": 0, "nexthop": "10.0.0.2",
"vpn_rd": "65001:100", "rt": "65001:100",
"labels": [24001], "data": {}}
]
}
}
| Field | Meaning |
|---|---|
time |
snapshot timestamp, ISO 8601 — also the staleness key |
srcid |
the collector instance |
sesid |
one collector run; changes on every restart |
nodes[].role |
speaker reports; peer was only reported about |
edges[] |
one BGP session, not one router pair |
networks[] |
one route observation |
networks[].type / subtype |
AFI as a string, SAFI as a number |
networks[].data |
the untouched collector record, so unpromoted attributes are never lost |
Response
graph_time is the public identifier used by every read endpoint below — the
same format IGP graphs use.
Ordering and re-sends. sesid is minted at collector start-up, exactly when
speakers replay their tables. Within one sesid the newest time wins; an
older or equal one is rejected with 400 stale snapshot. A periodic full
re-send under the same sesid is treated as a reconciliation checkpoint,
not a new graph — it returns checkpoint: true, proves the source is alive on a
quiet network, and corrects the current view if it has drifted. A new sesid
supersedes the previous run.
All Route Targets are extracted from data.base_attrs.ext_community_list, not
only the promoted rt field — a route with several RTs stays visible to a query
for any of them.
POST /api/watcher/bgp/events — the change stream¶
Accepts one event object or a list of them.
curl -sS -X POST https://topolograph.com/api/watcher/bgp/events \
-H "Authorization: Bearer $TOPOLOGRAPH_API_TOKEN" \
-H "Content-Type: application/json" \
-d '[{
"srcid": "pe1", "sesid": "b4f1c8e2", "seq": 41,
"watcher_time": "2026-08-17T09:14:11Z",
"event_name": "prefix", "event_status": "withdraw",
"event_object": "192.0.2.0/24", "event_detected_by": "10.0.0.2",
"bmp_source": "10.0.0.1", "policy": "post",
"afi": 1, "safi": 1, "prefix": "192.0.2.0", "prefix_len": 24,
"family_data": {"peer_ip": "10.0.0.2"}
}]'
| Field | Meaning |
|---|---|
event_name |
prefix, l3vpn, evpn, peer |
event_status |
add, change, withdraw (routes); up, down (peers) |
event_detected_by |
the router the change is about |
bmp_source |
the speaker that reported it — a different router on any reflected session |
seq |
monotonic per sesid; used for exact dedup and gap detection |
watcher_time |
collector clock — orders the stream |
bmp_timestamp |
router clock — correlation only, never ordering |
replay_suspect |
may be the tail of a replay rather than a live change |
An event whose (srcid, sesid) does not match a stored snapshot is rejected —
post the topology before starting the event feed. A repeated seq is
counted as a duplicate and dropped; a gap in seq is logged as a lost message.
A peer up/down event is display-only. The collector already emits an ordinary per-prefix withdraw for every route a downed peer carried, so the peer event itself never mutates route state.
POST /api/watcher/vrfs — VRF inventory¶
Route Distinguishers identify VPN routes, but only the device knows the VRF name and its import/export Route Targets. Posting the inventory lets you search by VRF name instead of by RD.
curl -sS -X POST https://topolograph.com/api/watcher/vrfs \
-H "Authorization: Bearer $TOPOLOGRAPH_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"router_id": "10.0.0.1",
"observed_at": "2026-08-17T09:10:00Z",
"vrfs": [{
"name": "Red",
"families": [{
"afi": "ipv4", "safi": "unicast",
"route_distinguisher": "65001:100",
"import_route_targets": ["65001:100", "65001:999"],
"export_route_targets": ["65001:100"]
}]
}]
}'
Each observation is stored with its own timestamp rather than overwriting the
previous one, so an older graph can still resolve the VRF state as it was at
that time. Uniqueness is (workspace, router_id, rd); an unchanged re-send
writes nothing.
Binding to your IGP graphs¶
After every BGP and every IGP save, Topolograph re-evaluates which OSPF or IS-IS graphs a BGP graph belongs to. Candidates are ranked by Router-ID overlap using a greedy set-cover, so a BGP graph spanning two IGP domains binds to both.
| State | Meaning |
|---|---|
bound |
Router-ID overlap ≥ 80 %, unambiguous |
needs_mapping |
below the floor, or two candidates tie — waiting for confirmation |
Router-ID overlap is evidence, not a requirement. A BGP Router ID and an OSPF Router ID usually match, but Topolograph never insists on it: ambiguous results stay visible for you to confirm.
# what a BGP graph is bound to
curl -sS ".../api/bgp-graph/17Aug2026_09h12m03s_6_hosts/bindings" -H "Authorization: Bearer $T"
# confirm a binding by hand
curl -sS -X PUT ".../api/bgp-graph/<bgp_graph_time>/<igp_graph_time>/binding" -H "Authorization: Bearer $T"
# drop one
curl -sS -X DELETE ".../api/bgp-graph/<bgp_graph_time>/<igp_graph_time>/binding" -H "Authorization: Bearer $T"
IS-IS needs a real Router ID
An IS-IS node is named internally by a parser-minted pseudo Router ID that exists nowhere on the network. Only a TE Router ID advertised by the device counts as an identity. A device that advertises none contributes nothing to the overlap score — which is the honest outcome, not a bug. Enable TE on the device, or set the Router ID by hand on the hostname mapping page; it then migrates to later graphs the way a hostname does.
Reading it back¶
Graphs, nodes and sessions¶
GET /api/bgp-graphs?page=1&per_page=50
GET /api/bgp-graph/{bgp_graph_time}
GET /api/bgp-graph/{bgp_graph_time}/nodes?role=rr&asn=65001
GET /api/bgp-graph/{bgp_graph_time}/sessions?igp_relation=inter-domain&bgp_session_type=ebgp
Each session is classified once the bindings are known:
igp_relation |
Meaning |
|---|---|
intra-domain |
both ends are in the same bound IGP graph |
inter-domain |
the ends are in two different bound IGP graphs |
external |
at least one end is in no bound IGP graph |
bgp_session_type is ibgp or ebgp, derived from the session's ASN against
the speaker's own — not from AS_PATH[0], which a reflected route would make
misleading.
Route search¶
GET /api/bgp-graph/{bgp_graph_time}/routes?prefix=192.0.2.0/24
GET /api/bgp-graph/{bgp_graph_time}/node/{router_id}/routes?evidence=loc_rib
| Query | Behaviour |
|---|---|
prefix=192.0.2.0/24 |
exact match on the full prefix |
prefix=192.0.2.5 |
containment — every route covering the address |
prefix=192.0.2.0/24&lpm=1 |
longest-prefix match, one row |
afi / safi |
numeric family |
rd |
Route Distinguisher |
vrf |
VRF name, resolved to its RDs through the inventory |
rt |
any Route Target on the route |
policy / evidence |
raw stream, or pre_policy / post_policy / loc_rib / fib |
as_path_contains |
substring match anywhere in AS_PATH |
community / large_community / extended_community |
community search |
origin, local_pref, med, originator_id, label |
attribute filters |
peer_ip, nexthop, bmp_source |
who advertised it, and how it is reached |
page, per_page |
pagination (per_page capped at 500) |
Every route carries VRF/RD/RT, AFI/SAFI, policy and evidence, path ID, communities, next hop, labels and origin.
History and comparison¶
# state of the table now, or as it was at a point in time
GET /api/bgp-graph/{bgp_graph_time}/routes/state
GET /api/bgp-graph/{bgp_graph_time}/routes/state?at=2026-08-17T09:20:00Z
# what changed between two moments
GET /api/bgp-graph/{bgp_graph_time}/routes/compare?t0=...&t1=...
# the event feed, and the monitoring-timeline lanes
GET /api/bgp-graph/{bgp_graph_time}/events?last_minutes=60&event_name=peer
GET /api/bgp-graph/{bgp_graph_time}/events/timeline
state with no at reads a continuously maintained current view, so it costs
the size of the answer rather than a replay of the whole event log. An explicit
at replays deltas from the snapshot baseline through that moment. Time bounds
are inclusive at both ends.
compare returns one row per change: added, withdrawn or changed with
before/after.
On the monitoring timeline, bgp_peer gets one marker per session up/down —
low volume, every flap matters — while bgp_route is clustered, so a burst of
route churn renders as one marker with a count instead of thousands of dots.
Route lookup¶
Route lookup answers "what does this router actually do with this destination", as opposed to a pure topology SPF.
{
"prefix": "192.0.2.0/24",
"start_node": "10.0.0.1",
"route_source": "BGP",
"admin_distance": 200,
"nexthop": "10.0.0.9",
"resolution_chain": ["192.0.2.0/24", "10.0.0.9/32"],
"path_segments": [{"domain": "17Aug2026_09h05m00s_6_hosts",
"path": ["10.0.0.1", "10.0.0.4", "10.0.0.9"]}],
"warning": null
}
The decision order is deliberate:
- Longest-prefix match inside the selected table or VRF.
- BGP best-path selection — one path per prefix (RFC 4271 §9.1;
RFC 4456 for reflected routes, RFC 4364 for VPNs), over the attributes a
collector feed carries:
- highest
LOCAL_PREF - shortest
AS_PATH - lowest
ORIGIN(IGP < EGP < incomplete) - lowest
MED, compared only within the same neighbouring AS - eBGP-learned over iBGP-learned
- lowest
ORIGINATOR_ID/ BGP Identifier
- highest
A Loc-RIB observation ends the comparison before these steps run — it is the router's own choice, not a candidate to re-rank. 3. Administrative distance between the surviving candidates of different protocols. 4. Recursive next-hop resolution, loop- and depth-guarded. 5. IGP SPF/CSPF transport toward that next hop, optionally over eligible LSP shortcuts.
| Protocol | AD |
|---|---|
| connected | 0 |
| static | 1 |
| eBGP | 20 |
| OSPF | 110 |
| IS-IS | 115 |
| iBGP | 200 |
Administrative distance belongs to routes, never to topology edges.
Metrics from OSPF, IS-IS and BGP are never compared with each other — a metric
is only meaningful inside its own protocol. iBGP versus eBGP is decided by the
session the route was learned on, not by AS_PATH[0].
Candidate routes are scoped to what the starting node can actually see: its own reported table plus the tables of its direct session neighbours. A router running no BGP inherits nothing.
Retention¶
Topolograph keeps the most recent BGP graphs per source (srcid), so an
installation running two collectors keeps a full window for each. When an epoch
falls out of the window, its routes and bindings go with it. Periodic re-sends
under the same sesid are checkpoints and do not consume the window.
Current limits¶
- Run one collector per BMP speaker and set
--source-id. Several speakers into one collector merge their observations. - EVPN routes are collected and stored, but the route table and route lookup are prefix-oriented; EVPN is not yet a first-class search subject.
- A Router ID that legitimately appears in two bound IGP domains resolves to one of them for session classification.
See also¶
- bmpwatcher on GitHub
- OSPF Watcher · IS-IS Watcher
- Events, Timeline & Status
- BGP-LS Session — BGP-LS carries IGP topology, a different subject from the BGP routing state on this page