IXP Manager route server: Foil export → render → activate → lifecycle
When this is you: your exchange runs IXP Manager v7.4 as the member and router database, and you want a rustbgpd route server that IXP Manager provisions the way it provisions your BIRD route servers — members, IRR sets, max-prefix, MD5, RPKI, and route-server UI filters come from the member DB; nothing is hand-written per member; IXP Manager's router lock/update lifecycle drives every reconfiguration; members see the route server through IXP Manager's looking glass.
When this is you: your exchange runs IXP Manager v7.4 as the member and router database, and you want a rustbgpd route server that IXP Manager provisions the way it provisions your BIRD route servers — members, IRR sets, max-prefix, MD5, RPKI, and route-server UI filters come from the member DB; nothing is hand-written per member; IXP Manager's router lock/update lifecycle drives every reconfiguration; members see the route server through IXP Manager's looking glass.
This is one of three IXP provisioning modes
(the fork). If you have no
external provisioning source, start from the hand-written
route-server recipe; if your member data lives in
arouteserver's general.yml/clients.yml, use the
IXP filter pipeline. The modes are mutually
exclusive per route server: the renderer owns the whole output directory
in both automated modes, and this one activates only unmodified, receipted
candidates.
Proven by: M96 (pinned v7.4 Foil render → atomic local activation:
initial, no-op, hot reload, and pre-effect restoration against
MD5-authenticated FRR) and M97 (authenticated lock/fetch/callback lifecycle
for two IPv4/IPv6 handles on one host, shared fence, MD5-FRR session
continuity) — both local gates, described in
INTEROP.md;
and the pinned IXP Manager / Bird's Eye contract oracle
(tests/compat/ixp-manager-birdseye/),
which runs the real IXP Manager v7.4.0 router-config generator, PHP
looking-glass consumer, and MySQL fixture against the real renderer,
checker, daemon, and adapter. Every "expected output" block below is a
real run, on the checked-in v7.4 capture, of release-profile binaries built
from the source tree (they report version 0.71.0); hashes and version strings
change with the build.
The pipeline:
IXP Manager v7.4 member DB
│ Foil template (json.foil.php in your VIEW_SKIN)
▼
router-config/v2 JSON fetched over HTTPS, mode 0600
│ rs-config-render --input-format ixp-manager-v2 --check-with rustbgpd
▼
candidate/: config.toml + policy/*.rpol + datasets/*.list
+ birdwatcher-protocol-aliases.conf
+ render-receipt.json (written last, after --check --strict)
│ rs-config-render activate (immutable generation, atomic `current` swap,
▼ one synchronous reload, rbgp settlement check)
rustbgpd@<handle>.service reads <runtime>/activation/current/config.toml
│
▼
birdwatcher-adapter --protocol-alias-file …/current/birdwatcher-protocol-aliases.conf
│
▼
IXP Manager looking glass (pinned Bird's Eye journeys)ixp-manager-lifecycle run wraps the middle of that diagram — lock, fetch,
render, check, activate, callback — in one command (section 4). The manual
steps in sections 2–3 are the same code path and the right place to start.
1. The Foil template skin
The exporter is original GPL-2.0-only source kept outside every binary,
package, archive, and image:
integrations/ixp-manager/gpl-2.0-only/.
Install api/v4/router/server/rustbgpd/json.foil.php beneath the same path
in your active IXP Manager skin, then set the router's template to
api/v4/router/server/rustbgpd/json:
resources/skins/<VIEW_SKIN>/api/v4/router/server/rustbgpd/json.foil.phpIXP Manager then renders the strict rustbgpd.ixp-manager.router-config/v2
JSON document through its normal router configuration generator. The
template reads IXP Manager's sanitized session, IRR, max-prefix,
authentication, RPKI, and route-filter state; it copies no BIRD template.
It also reports things the renderer must refuse rather than silently
drop — active BIRD skin overrides, the legacy implicit no-transit token —
and resolves the pinned 15-ASN default no-transit list (or your explicit
IXP_NO_TRANSIT_ASNS_OVERRIDE) so old/new version skew fails closed.
One router in IXP Manager = one handle = one rustbgpd instance. A
dual-stack route server is two handles (rs1-ipv4, rs1-ipv6) on one
host — section 5.
2. Fetch and render a candidate
Fetch the document with authenticated curl into a regular, non-symlink,
mode-0600 file owned by the rustbgpd service identity; the renderer has
no HTTP client and never sees the API key in this mode:
umask 077
sudo -u rustbgpd curl --fail --silent --show-error \
-H "X-IXP-Manager-API-Key: $(cat /var/lib/rustbgpd/ixp-manager/api-key)" \
-o /var/lib/rustbgpd/ixp-manager/router.json \
https://ixp.example.net/admin/api/v4/router/gen-config/rs1-ipv4Render into an absent (or empty, mode-0700) candidate directory. IXP
Manager mode runs the selected binary as rustbgpd --version and then
rustbgpd --check --strict <candidate>/config.toml itself, with child
output suppressed so authentication values cannot leak through
diagnostics, and writes render-receipt.json last — only after the strict
check passes:
sudo -u rustbgpd /usr/bin/rs-config-render \
--input-format ixp-manager-v2 \
--context /var/lib/rustbgpd/ixp-manager/router.json \
--out-dir /var/lib/rustbgpd/ixp-manager/candidate \
--router-handle rs1-ipv4 \
--runtime-state-dir /var/lib/rustbgpd/rs1-ipv4 \
--max-prefix-restart-seconds 300 \
--check-with /usr/bin/rustbgpdExpected output (the checked-in v7.4 capture has two members, handle
b2-rs1-lan1-ipv4):
$ rs-config-render --input-format ixp-manager-v2 --context router.json \
--out-dir candidate --router-handle b2-rs1-lan1-ipv4 \
--runtime-state-dir /var/lib/rustbgpd/b2-rs1-lan1-ipv4 \
--max-prefix-restart-seconds 300 --check-with /usr/bin/rustbgpd
validated 9 candidate file(s) + receipt into candidate
$ echo $?
0
$ ls -la candidate candidate/datasets candidate/policy
candidate:
-rw------- birdwatcher-protocol-aliases.conf
-rw------- config.toml
drwx------ datasets
drwx------ policy
-rw------- render-receipt.json
candidate/datasets:
-rw------- client-1-origins.list
-rw------- client-1-prefixes.list
-rw------- client-4-origins.list
-rw------- client-4-prefixes.list
candidate/policy:
-rw------- client-1.rpol
-rw------- client-4.rpol
-rw------- ixp-hygiene.rpol
$ cat candidate/birdwatcher-protocol-aliases.conf
pb_0001_as1213=10.1.0.10@master4
pb_0004_as112=10.1.0.6@master4The receipt is the deploy gate — a candidate without one is incomplete and must not be activated:
{
"counts": { "clients": 2, "origins": 2, "prefixes": 2 },
"generated_files": {
"birdwatcher-protocol-aliases.conf": "08250f41…",
"config.toml": "b74665b0…",
"datasets/client-1-origins.list": "2c504ace…",
"datasets/client-1-prefixes.list": "2216ef2c…",
"datasets/client-4-origins.list": "f7dbab47…",
"datasets/client-4-prefixes.list": "26a65a96…",
"policy/client-1.rpol": "1ae08bbf…",
"policy/client-4.rpol": "0001d311…",
"policy/ixp-hygiene.rpol": "23a80e76…"
},
"host": { "router_handle": "b2-rs1-lan1-ipv4",
"runtime_state_dir": "/var/lib/rustbgpd/b2-rs1-lan1-ipv4" },
"input": { "ixp_manager_version": "7.4.0", "router_handle": "b2-rs1-lan1-ipv4",
"schema": "rustbgpd.ixp-manager.router-config/v2", "sha256": "76f72c25…" },
"irrdb_disabled_clients": [],
"refusals": { "active_ui_filters": 0, "multi_address_clients": 0,
"route_server_skin_files": 0, "status": "passed" },
"strict_check": { "binary_version": "rustbgpd 0.71.0", "passed": true },
"warnings": []
}What the candidate looks like
You do not write this file; read it once so the shape is familiar. This is
the exact config.toml rendered from the checked-in capture (MD5 values
replaced; the file as shown passes rustbgpd --check --strict):
# GENERATED candidate from IXP Manager 7.4.0.
[global]
asn = 65501
router_id = "192.0.2.18"
runtime_state_dir = "/var/lib/rustbgpd/b2-rs1-lan1-ipv4"
listen_port = 179
listen_addresses = ["192.0.2.18"]
ebgp_requires_policy = true
[global.telemetry]
log_format = "json"
[global.telemetry.grpc_uds]
path = "/var/lib/rustbgpd/b2-rs1-lan1-ipv4/grpc.sock"
mode = 0o600
[rpki]
[[rpki.cache_servers]]
address = "127.0.0.1:3323"
[policy.definitions.ixp-transparent-export]
default_action = "permit"
[policy]
rpol_files = [
"policy/ixp-hygiene.rpol",
"policy/client-1.rpol",
"policy/client-4.rpol",
]
export_chain = ["ixp-transparent-export", "ixp-manager-own-as-export-scrub"]
[policy.datasets.client-1-origins]
path = "datasets/client-1-origins.list"
[policy.datasets.client-1-prefixes]
path = "datasets/client-1-prefixes.list"
[policy.datasets.client-4-origins]
path = "datasets/client-4-origins.list"
[policy.datasets.client-4-prefixes]
path = "datasets/client-4-prefixes.list"
[[neighbors]]
address = "10.1.0.10"
remote_asn = 1213
description = "HEAnet"
families = ["ipv4_unicast"]
route_server_client = true
role = "route_server"
next_hop_ownership = "strict_peer"
per_client_best = true
rs_control_communities = true
interpret_rfc1997 = true
max_prefixes_ipv4 = 900
max_prefix_restart_seconds = 300
import_policy_chain = ["reject-special-purpose", "ixp-hygiene", "ixp-manager-hygiene", "client-1"]
export_policy_chain = ["ixp-transparent-export", "client-1-receive", "ixp-manager-own-as-export-scrub"]
md5_password = "member-md5-from-ixp-manager"
[[neighbors]]
address = "10.1.0.6"
remote_asn = 112
description = "AS112"
families = ["ipv4_unicast"]
route_server_client = true
role = "route_server"
next_hop_ownership = "strict_peer"
per_client_best = true
rs_control_communities = true
interpret_rfc1997 = true
max_prefixes_ipv4 = 20
max_prefix_restart_seconds = 300
import_policy_chain = ["reject-special-purpose", "ixp-hygiene", "ixp-manager-hygiene", "client-4"]
md5_password = "member-md5-from-ixp-manager"Things to notice, because they are the route-server shapes the other
recipes explain: every member is a transparent route_server_client with
the RFC 9234 route_server role, strict next-hop ownership (RFC 7948
§4.8), per_client_best path-hiding mitigation, IXP Manager's control
communities, and interpret_rfc1997 set from the router's IXP Manager
rfc1997_passthru flag (off in this capture, so the server enforces
NO_EXPORT itself; the hand-written example derives passthrough). The
import chain is hygiene → IXP Manager hygiene → the member's IRR set. With
RPKI on, the member's IRR prefix term passes an RPKI-valid route whose origin
is in the member's AS-SET, as IXP Manager's own BIRD templates do, so a member
with a ROA but no IRR route object keeps that route after cutover. Until the
RTR cache's first End of Data every route reads not-found, so such routes
are rejected until the cache syncs and the daemon refreshes the sessions.
Accepted routes carry IXP Manager's informational large communities where its
BIRD templates add them: RS:1000:1 RPKI valid, RS:1000:2 RPKI unknown,
RS:1000:3 RPKI not checked, RS:1001:1 IRRDB valid, and RS:1001:2 IRRDB
not checked, where RS is the router ASN. An RPKI-valid route skips the IRRDB
prefix check upstream, so it carries only RS:1000:1. Every export chain ends
with ixp-manager-own-as-export-scrub, which removes large communities under
the router ASN, informational ones included, and preserves everyone else's.
ebgp_requires_policy = true makes deleting a chain fail closed. The
gRPC socket and runtime_state_dir are fixed under the handle's directory;
the candidate is mode 0600 because it carries the members' MD5 secrets.
The render refuses (exit 2, no receipt) rather than degrade: active BIRD
skin overrides, applicable UI filters it cannot translate exactly, the
legacy implicit no-transit token, quarantine or non-route-server routers,
clients with IRR enabled but an empty or invalid IRR answer, missing or
zero-port RPKI caches, wrong-family client data, peering addresses that are
not exactly the member's own interface addresses, interfaces of one member
that disagree on IRR filtering or more-specifics, placeholder or overlong
MD5, and a symlinked or non-0600 input file. Each refusal names its cause on
stderr; the member data is the thing to fix. Three other failures have their
own codes: an unreadable or unparseable document, or one with an unknown
schema field, exits 1 (invalid input); a candidate directory that is not
absent or an empty mode-0700 directory (including a symlink) exits 8; and a
candidate that rustbgpd --check --strict rejects exits 9, leaving its files
without a receipt. See the
renderer's exit codes.
Members with multiple router connections on the peering LAN render one
session per VLAN interface. Every interface of the member must carry the
same irrdbfilter and rsmorespecifics flags: IXP Manager's BIRD template
filters every session of an ASN by its first interface's flags, while
rustbgpd refuses the render when they disagree. Under
next_hop_ownership = "strict_peer" each session must announce its own
router's address as the BGP next hop. A route whose next hop is a sibling
router's address is rejected with the daemon's next_hop_ownership reason
(rbgp rib received <addr> --rejected), which the Birdwatcher adapter
reports to IXP Manager as reject reason 8, "NEXT HOP NOT PEER IP"; IXP
Manager's upstream BIRD templates accept such a route and tag it
IXP_LC_INFO_SAME_AS_NEXT_HOP instead. With per_client_best and no
Add-Path, each router receives its own best path; there is no ECMP toward
third parties, the same as BIRD. Members with IRRDB filtering disabled
(irrdbfilter off) are rendered with hygiene and first-AS checks and no IRR
terms; RPKI-invalid rejection applies too when the router has RPKI enabled.
On a router with RPKI off, such a member is filtered only by hygiene and the
first-AS check. The render prints a warning and the receipt names the member
in irrdb_disabled_clients and warnings.
3. Activate atomically
Pre-create the per-handle state once, and enable the packaged per-handle
unit (rustbgpd@.service —
%i is the literal handle; it reads
/var/lib/rustbgpd/%i/activation/current/config.toml with a private
runtime/UDS and no write access to the shared fence):
handle=rs1-ipv4
sudo install -d -m 0700 -o rustbgpd -g rustbgpd \
"/var/lib/rustbgpd/$handle" "/var/lib/rustbgpd/$handle/activation" \
/var/lib/rustbgpd/ixp-manager-host
sudo systemctl enable "rustbgpd@$handle"Authorize the rustbgpd account in sudoers for exactly
/usr/bin/systemctl reload-or-restart rustbgpd@rs1-ipv4 — the literal
instance, never a wildcard. Then activate the reviewed candidate with an
exact executable and literal arguments (nothing is shell-evaluated):
sudo -u rustbgpd /usr/bin/rs-config-render activate \
--router-handle rs1-ipv4 \
--candidate /var/lib/rustbgpd/ixp-manager/candidate \
--runtime-state-dir /var/lib/rustbgpd/rs1-ipv4 \
--state-dir /var/lib/rustbgpd/rs1-ipv4/activation \
--host-state-dir /var/lib/rustbgpd/ixp-manager-host \
--check-with /usr/bin/rustbgpd --rbgp /usr/bin/rbgp \
--rbgp-addr unix:///var/lib/rustbgpd/rs1-ipv4/grpc.sock \
--activation-command /usr/bin/sudo \
--activation-arg=-n --activation-arg /usr/bin/systemctl \
--activation-arg reload-or-restart --activation-arg rustbgpd@rs1-ipv4Add --initial only for the very first publication, when no current
generation and no reachable daemon exist. The helper rechecks the candidate
against its receipt, copies it into an immutable content-addressed
generation, atomically renames the relative current symlink, runs the one
synchronous activation command, and requires both rbgp health and
rbgp config diff against the live daemon to settle. Equal content is a
no-op.
Expected output — initial activation, the settled state, and a second run of the same candidate:
$ rs-config-render activate … --candidate candidate-1 --initial
activation activated
$ echo $?
0
$ rbgp -s unix:///var/lib/rustbgpd/b2-rs1-lan1-ipv4/grpc.sock neighbor
Neighbor AS State Uptime Rx Pfx Tx Pfx Description
10.1.0.6 112 Active 00:00:00 0 0 AS112
10.1.0.10 1213 Active 00:00:00 0 0 HEAnet
$ ls -la /var/lib/rustbgpd/b2-rs1-lan1-ipv4/activation
-rw------- activation-receipt.json
-rw------- activation.lock
lrwxrwxrwx current -> generations/0310599c…
drwx------ generations
$ rs-config-render activate … --candidate candidate-1
activation noopThe private activation-receipt.json (written last) records what was
proven:
{
"activation_runs": 1,
"candidate_sha256": "0310599c…",
"host": { "activation_state_dir": "/var/lib/rustbgpd/b2-rs1-lan1-ipv4/activation",
"host_state_dir": "/var/lib/rustbgpd/ixp-manager-host",
"rbgp_addr": "unix:///var/lib/rustbgpd/b2-rs1-lan1-ipv4/grpc.sock",
"router_handle": "b2-rs1-lan1-ipv4",
"runtime_state_dir": "/var/lib/rustbgpd/b2-rs1-lan1-ipv4" },
"initial": true,
"phases": {
"candidate_activation_ran": true,
"candidate_link": { "durable": true, "published": true },
"health_checked": true,
"initial_unreachable_checked": true,
"rollback_activation_ran": false,
"rollback_link": { "durable": false, "published": false },
"runtime_equal": true
},
"previous_generation": null,
"schema": "rustbgpd.ixp-manager.activation/v1",
"status": "activated",
"strict_check": { "binary_version": "rustbgpd 0.71.0", "passed": true }
}A later member change is a fresh fetch, a fresh render into a fresh
candidate directory, and another activate: the daemon hot-reloads
(one SIGHUP runtime generation through reload-or-restart; the other
members keep their sessions), current moves to
the new generation, and the previous generation stays on disk. Re-rendering
the same upstream state produces byte-identical files and a no-op.
Before activating a candidate, an operator can inspect one member's IRR
filter impact with rbgp policy test. Use that member's rendered policy,
dataset names, and peer address; for the client-1 candidate shown above:
CANDIDATE=/var/lib/rustbgpd/ixp-manager/candidate
rbgp -s unix:///var/lib/rustbgpd/b2-rs1-lan1-ipv4/grpc.sock \
policy test "$CANDIDATE/policy/client-1.rpol" \
--policy client-1 --direction import --neighbor 10.1.0.10 \
--dataset client-1-origins="$CANDIDATE/datasets/client-1-origins.list" \
--dataset client-1-prefixes="$CANDIDATE/datasets/client-1-prefixes.list" \
--show-rejected 20The rejected count and bounded samples cover candidate rejections among retained post-policy routes for that member. This read-only check does not activate the candidate. See the dry-run scope for what retained routes and attributes represent.
Exit codes, and exactly what each one guarantees:
| Exit | Meaning | State afterwards |
|---|---|---|
| 0 | activated, or no-op (equal content) | current → the candidate's generation; receipt current |
| 2 | refused before any effect (bad candidate/receipt, wrong modes, --initial misuse, helper contract violation) | unchanged |
| 7 | the activation command could not start, or the daemon rejected the reload without runtime effect (proven from rbgp metrics: one rejected_no_effect SIGHUP outcome in the same process, no settlement in progress) | the prior current is restored without a second activation and the prior runtime is verified unchanged — proven prior-runtime restoration |
| 5 | the command started and then failed, timed out, or the runtime did not settle, and no no-effect rejection was proven | current stays on the candidate (or on the previous generation if the rejection could not be re-proven after restoring it); recovery is operator-owned; a synced owner fence stays in the host-state directory and every later activation or lifecycle run returns 5 until it is resolved — Activation manual recovery |
Real exit-7 and exit-5 runs, on a changed candidate:
$ rs-config-render activate … --candidate candidate-2 --activation-command /usr/local/bin/does-not-exist
rs-config-render: activation: candidate not applied; prior generation restored
$ echo $?
7
$ readlink /var/lib/rustbgpd/b2-rs1-lan1-ipv4/activation/current
generations/0310599c… # unchanged
$ rs-config-render activate … --candidate candidate-3 --activation-command /bin/false
rs-config-render: activation: recovery required; inspect private activation state
$ echo $?
5
$ ls /var/lib/rustbgpd/ixp-manager-host
ixp-manager-host-fence.json ixp-manager-host.lock
$ rs-config-render activate … --candidate candidate-2 # anything, while the fence exists
rs-config-render: activation: recovery required; inspect private activation state
$ echo $?
5
$ rbgp -s unix:///var/lib/rustbgpd/b2-rs1-lan1-ipv4/grpc.sock health
Status: healthy # the daemon itself is untouched4. Let IXP Manager drive it
ixp-manager-lifecycle run is the same renderer and the same activation
helper wrapped in IXP Manager v7.4's router API: acquire the router update
lock, fetch the Foil JSON over HTTPS, render and strictly check a fresh
private candidate, activate it, then deliver updated (or
release-update-lock on a definite pre-activation refusal). This is what a
cron entry or IXP Manager's own "update router" flow should call:
sudo -u rustbgpd /usr/bin/rs-config-render ixp-manager-lifecycle run \
--ixp-origin https://ixp.example.net \
--router-handle rs1-ipv4 \
--api-key-file /var/lib/rustbgpd/ixp-manager/api-key \
--candidate-dir /var/lib/rustbgpd/ixp-manager/candidate-1 \
--runtime-state-dir /var/lib/rustbgpd/rs1-ipv4 \
--state-dir /var/lib/rustbgpd/rs1-ipv4/activation \
--host-state-dir /var/lib/rustbgpd/ixp-manager-host \
--max-prefix-restart-seconds 300 \
--check-with /usr/bin/rustbgpd --rbgp /usr/bin/rbgp \
--rbgp-addr unix:///var/lib/rustbgpd/rs1-ipv4/grpc.sock \
--activation-command /usr/bin/sudo \
--activation-arg=-n --activation-arg /usr/bin/systemctl \
--activation-arg reload-or-restart --activation-arg rustbgpd@rs1-ipv4Rules that are enforced, not advisory: a new absent-or-empty mode-0700
--candidate-dir per run; the API key in an absolute, regular, mode-0600
file (never argv or environment; it appears only in the
X-IXP-Manager-API-Key header and is never journaled); HTTPS with platform
roots, redirects and proxies disabled, bounded deadlines and body sizes.
Lifecycle intent is written and synced before every upstream request. On
success run prints IXP Manager lifecycle activated (or IXP Manager lifecycle noop when the rendered candidate equals current) after delivering
the updated callback; resume prints IXP Manager lifecycle updated once it
replays a pending callback.
| Exit | Meaning |
|---|---|
| 0 | updated delivered |
| 2 | no lock acquired, or a definite pre-activation refusal was released back to IXP Manager |
| 7 | candidate not applied (activation command never started, or the daemon rejected the reload without runtime effect); exact prior runtime proven; release delivered |
| 5 | lock acquisition or activation effect is uncertain — no callback is issued; the owner fence stands; operator recovery (runbook) |
| 6 | one durable updated or release callback is still pending — retry only that with resume |
sudo -u rustbgpd /usr/bin/rs-config-render ixp-manager-lifecycle resume \
--ixp-origin https://ixp.example.net --router-handle rs1-ipv4 \
--api-key-file /var/lib/rustbgpd/ixp-manager/api-key \
--runtime-state-dir /var/lib/rustbgpd/rs1-ipv4 \
--state-dir /var/lib/rustbgpd/rs1-ipv4/activation \
--host-state-dir /var/lib/rustbgpd/ixp-manager-host \
--rbgp-addr unix:///var/lib/rustbgpd/rs1-ipv4/grpc.sockresume never refetches, rerenders, or activates; it replays the pending
callback with the exact same handle/runtime/activation/host-state/UDS
identity. Delivery is at-least-once (IXP Manager v7.4 offers no
idempotency token). resume has no automatic action for exit 5.
5. Paired handles on one host
A second handle — the IPv6 side of the same route server, or a second
route server a small exchange co-hosts — is a second IXP Manager router
entry, a second rustbgpd@<handle> instance, and its own
runtime/activation/UDS directories, all under the same rustbgpd account
and the same --host-state-dir. The shared host-state directory is
what serializes lifecycle ownership across the host (M97: two handles,
distinct PIDs, TCP/179 listeners, state, sessions, and failure domains;
one fence; paired competing-lock behavior; sequential callbacks):
for handle in rs1-ipv4 rs1-ipv6; do
sudo install -d -m 0700 -o rustbgpd -g rustbgpd \
"/var/lib/rustbgpd/$handle" "/var/lib/rustbgpd/$handle/activation"
sudo systemctl enable "rustbgpd@$handle"
done
sudo install -d -m 0700 -o rustbgpd -g rustbgpd /var/lib/rustbgpd/ixp-manager-hostEach handle gets its own sudoers line and its own lifecycle invocation;
run them one at a time, and note that a fence left by either handle makes
every run on the host return 5 until it is resolved. The second host of
a redundant pair is a second, independent lifecycle with its own router
handle in IXP Manager and its own host-state directory — there is no
cross-host coordination, and none is needed: the
paired route servers runbook's staggered
rollout and rbgp diff advertised consistency check apply unchanged.
6. Observe through the Birdwatcher surface
Every candidate carries birdwatcher-protocol-aliases.conf, IXP Manager's
pb_<vlan-interface-id>_as<asn> protocol names bound to the member's
session address and master4/master6 table, mode 0600, hashed into the
receipt and published atomically with the daemon generation at
<runtime-state-dir>/activation/current/birdwatcher-protocol-aliases.conf.
Point the birdwatcher adapter
at that stable path and send it SIGHUP after every successful activation
(neither the renderer nor the helper signals it — that is your one
post-activation step, and the file-backed resolver reloads as one whole
generation without changing the adapter PID; a malformed file is rejected
and the prior generation stays):
birdwatcher-adapter \
--grpc-addr unix:///var/lib/rustbgpd/rs1-ipv4/grpc.sock \
--listen 127.0.0.1:8080 \
--protocol-alias-file /var/lib/rustbgpd/rs1-ipv4/activation/current/birdwatcher-protocol-aliases.confExpected shape of the IXP Manager inventory journey, against the activated capture (trimmed):
$ curl -s http://127.0.0.1:8080/protocols/bgp
{"api":{"Version":"rustbgpd 0.71.0",…,"version":"rustbgpd 0.71.0"},
"protocols":{
"pb_0001_as1213":{"bgp_state":"Active","description":"HEAnet","neighbor_address":"10.1.0.10",
"neighbor_as":1213,"protocol":"pb_0001_as1213","routes":{"exported":0,"filtered":0,"imported":0},
"table":"master4",…},
"pb_0004_as112":{…,"neighbor_address":"10.1.0.6","neighbor_as":112,"table":"master4",…}}}Point IXP Manager's looking glass at the adapter as it would be pointed at
Bird's Eye. What it gets is the pinned set of v7.4 journeys (the exact
list is the boundary, below): router status, live BGP inventory and
per-protocol detail, symbols, a member's received and exported routes
including exact-prefix lookups, a bounded longest-prefix table search, an
atomic capped full-table view, and the member filtered-prefix view — the
"why was my prefix filtered" page — which the adapter answers from the
daemon's retained rejected routes, one synthesized <router ASN>:1101:<id>
reason per route after scrubbing any wire-supplied value in that
namespace. For the member-support flow from the operator side
(rbgp rib received <member> --rejected, rbgp policy explain), see the
route-server recipe.
Watch
The route-server watch table applies unchanged. Add, for this mode:
| Signal | Healthy shape |
|---|---|
age of the newest render-receipt.json / activation-receipt.json | within your refresh cadence — a stale pair means the lifecycle is not running or is refusing; alert on age, not on absence |
bgp_policy_generation_loaded_timestamp_seconds | moves on every activation that changed policy; flat across a member change means the candidate was a no-op or never activated |
readlink <runtime>/activation/current vs the latest candidate receipt's candidate_sha256 | equal after every exit-0 run |
<host-state-dir>/ixp-manager-host-fence.json | absent — its presence is an exit-5 condition waiting for an operator |
bgp_session_state_transitions_total | flat across activations — hot reloads must not bounce members |
bgp_rejected_routes_retained{peer} | the member filtered-prefix view reads from this store; a member whose count keeps climbing is a member-support conversation |
Failure modes
Render exits 2 and names a refusal. The member data or the skin is the
problem, not the daemon: an active BIRD skin override (the exporter lists
route_server_skin_files), a UI filter the bounded subset cannot express,
a client with IRR enabled but an empty IRR answer, a zero-port RPKI cache,
or peering addresses omitting the session address. Fix it in IXP Manager;
nothing was published and no receipt was written — the receipt exists only
after a strict pass.
Activate exits 2. The candidate is not the renderer's (hand-edited
files fail the receipt hash recheck — "immutable generation content
mismatch"), a state path is wrong (the runtime basename must equal the
handle; the activation path must be exactly <runtime>/activation; mode
0700, owned by rustbgpd), or --initial was passed with a live daemon
or omitted on first publication. Nothing was published.
Activate exits 7. Either sudo/systemctl could not be executed
(sudoers line missing, wrong path), or the daemon rejected the reload without
runtime effect: its log carries SIGHUP reload rejected without runtime effect with the reason. The prior generation is restored and the prior
runtime proven unchanged. Fix the command or the candidate and re-run.
Activate or lifecycle exits 5. The command started and something after
it is unproven: a rejection the daemon's reload outcomes could not prove, a
daemon that did not settle within --settle-seconds, a timeout, or a
lifecycle lock state that is uncertain.
Nothing is retried for you, and every later run returns 5 while the fence
stands. Confirm the candidate's health with rbgp health and
rbgp neighbor, decide keep-or-roll-back, release the fence, handle the
receipt, then resume automation — the ordered checklist is
Activation manual recovery.
Lifecycle exits 6. The router was activated (or released) but the
callback to IXP Manager is pending: run resume with the same identity.
Until it succeeds, IXP Manager still shows the router locked.
A member's routes never appear; the session is Established. Same as
every route server: rbgp rib received <member> --rejected lists the
retained rejections with the deciding term; the IXP Manager filtered-prefix
view shows the same rows to the member, tagged <ASN>:1101:<id>.
Members cannot establish after a reload. Check MD5: the candidate carries IXP Manager's per-session password verbatim; a member whose IXP Manager record has a placeholder or overlong password is refused at render, not silently rendered without authentication.
The route server is missing from the generated Nagios configuration, and
nothing complained. If the router row in IXP Manager was added without
API type Birdseye and the Birdwatcher adapter URL, both of IXP Manager's
Nagios configuration generators return HTTP 200 and silently omit the
router — an unmonitored route server with no error anywhere
(M98, against pinned v7.4.0). Fix: set the router row's
API type to Birdseye and its API URL to the adapter. Check: the generated
Nagios configuration must contain the router's host entry.
The looking glass shows bgp_<address> names instead of pb_…. The
adapter was not pointed at the published alias file, or was not SIGHUPed
after activation. Point it at the current path (not a generation path)
and send SIGHUP; unchanged content is a no-op.
The boundary
Stated plainly, verified against the pinned contract
(contract.json)
and the adapter at this commit:
runtime_compatibilityistrue: verified IXP Manager 7.4 Bird's Eye API compatibility with documented BIRD-internal divergences. The claim covers the Bird's Eye surface IXP Manager v7.4.0 consumes, driven by the pinned oracle — the journeys listed in section 6 (runtime_supportedin the contract: exact protocol/export route, filtered-prefix wildcard, less-specific longest-prefix match, atomic full-table and all-candidate snapshots, file-backed alias reconfiguration, active reject-reason inventory, live session transport detail) — and the contract's divergence allow-list is the documented boundary.api.versionis rustbgpd product identity, not a Bird's Eye version claim. Full-table counts, live hold/keepalive countdowns, and the complete IXP Manager UI-filter policy engine are unsupported; any Bird's Eye client other than the pinned IXP Manager consumer, and Alice-LG beyond the separately documented Birdwatcher subset, is outside what is proven.- The reject-reason vocabulary is complete for the pinned templates.
The ten reasons the v7.4 route-server templates actually emit —
1,3,5,6,7,8,9,10,13,14(prefix length, bogon, AS-path length/first-AS, NEXT_HOP, IRRDB prefix/origin, RPKI-invalid, transit-free AS) — are runtime-supported and the member filtered-prefix page renders them. The five IXP Manager defines but its templates never set (2,4,11,12,15) and every ambiguous or custom cause fall back to0("Route was filtered"). The pinned PHP consumer translates all fifteen display strings; emission of the defined-only five is unsupported. - Protocol aliases reload. File-backed aliases
(
--protocol-alias-file) reload as one whole resolver generation onSIGHUP; the activation publishes the new file atomically with the daemon generation. Direct--protocol-aliasvalues remain startup-only. Nothing signals the adapter automatically. - Informational communities on accepted routes only. Accepted routes
carry the
RS:1000:*andRS:1001:*informational tags IXP Manager v7.4's templates set on them, so its looking-glass badges (RPKI VALID, IRRDB VALID and the rest) match. A rejected route carries only the adapter'sRS:1101:*reject reason. This divergence is deliberate. IXP Manager's import filter tags a filtered route and accepts it into a per-member table, so the route keepsRS:1001:1000/1001/1002(IRRDB filtered loose/strict, prefix empty) and any informational tag an earlier check set, such asRS:1000:2before the IRRDB prefix check. In rustbgpd a rejecting term discards the route; the daemon retains the route as received, with the deciding term as its reason, and the adapter derives the oneRS:1101:*value from that reason. Reproducing the extra tags would mean either accepting filtered routes and withholding them on export, which changes what the import policy filters, or handing the adapter per-member render facts (loose or strict IRRDB, RPKI on or off) it does not have. The filtered-prefix page still shows the reject reason.RS:1001:1002has no counterpart in any case: the renderer refuses an empty IRRDB answer.RS:1001:1200(same-AS next hop) is covered by the next-hop item below. - The bounded UI-filter subset. Advertise actions and ordered receive AS_IS/deny/PREPEND, including reachable overlap compiled into at most 4096 disjoint cells, 256 rows per client, 4096 total; 255 prepends succeed, 256 refuse. This is not a generic IXP Manager policy engine and not a custom-skin translator.
- Local host only. Render, activation, and lifecycle act on the local host: local state directories, a local executable as the activation command, one fence per host. A redundant pair on two hosts is two independent lifecycles. There is no remote activation and no cross-host fence.
- Generations are retained until you prune them. Every activated
generation stays under
<runtime>/activation/generations/<sha256>for inspection; activation and the lifecycle never remove one. Retention is the separate, opt-inrs-config-render prunecommand (a dry run unless--apply; see the renderer README). The helper does not retry indefinitely, deploy services, or call IXP Manager fromactivate. - IPv4/IPv6 unicast, route-server routers only; multi-connection members
rendered under strict peer next-hop ownership. Quarantine and
non-route-server modes, and protocols other than 4/6 are refused at
render. Members with multiple router connections on the peering LAN are
supported with one session per VLAN interface whose IRR and
more-specifics flags agree (IXP Manager's BIRD template uses the first
interface's flags for every session; rustbgpd refuses a disagreement).
Under
next_hop_ownership = "strict_peer", each router must announce its own next hop; routes whose next hop is a sibling router's address are rejected with thenext_hop_ownershipreason (IXP Manager reject reason 8 through the adapter) rather than accepted withIXP_LC_INFO_SAME_AS_NEXT_HOPas in IXP Manager's BIRD templates (full same-AS next-hop parity waits for ADR-0107). Members with IRRDB filtering disabled (irrdbfilteroff) render hygiene and first-AS checks without IRR terms, plus RPKI-invalid rejection only when the router has RPKI enabled; they are recorded in the receipt and logged at render. Filter translation beyond the bounded subset and custom-skin migration remain open. - No shadow/receive-only posture from this path. IXP Manager mode
refuses the site-local overlays (
--extra-rpol/--merge-toml) and the helper activates only unmodified receipted candidates, so a deny-all export route server cannot be produced here — see the shadow pilot for what an IXP Manager site does instead.
Everything above the line is proven by M96/M97 and the contract oracle on the pinned v7.4.0 commit. What does not exist yet is long-running history: the lifecycle stack has days of use behind it, where the route-server daemon itself has the 24 h flagship soak and the IXP receipt matrix. Both facts belong in your evaluation.
See also
- IXP route server — the hand-written mode and the route-server semantics every mode shares (transparency, roles, RPKI, path-hiding, member support).
- IXP filter pipeline — the arouteserver-driven
mode (
general.yml/clients.yml→rs-config-render→ reload). - Route-server shadow pilot — a zero-blast-radius pilot beside your incumbent, per mode.
- Paired route servers — two instances,
staggered rollout,
rbgp diff advertised. tools/rs-config-render/README.md— the renderer's exact contracts, refusals, and theresumesemantics;docs/how-to/deployment.md— install and the per-handle unit.