rustbgpd
Cookbook

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

IXP 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-ipv4

Render 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/rustbgpd

Expected 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@master4

The 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-ipv4

Add --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 noop

The 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 20

The 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:

ExitMeaningState afterwards
0activated, or no-op (equal content)current → the candidate's generation; receipt current
2refused before any effect (bad candidate/receipt, wrong modes, --initial misuse, helper contract violation)unchanged
7the 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
5the command started and then failed, timed out, or the runtime did not settle, and no no-effect rejection was provencurrent 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 untouched

4. 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-ipv4

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

ExitMeaning
0updated delivered
2no lock acquired, or a definite pre-activation refusal was released back to IXP Manager
7candidate not applied (activation command never started, or the daemon rejected the reload without runtime effect); exact prior runtime proven; release delivered
5lock acquisition or activation effect is uncertain — no callback is issued; the owner fence stands; operator recovery (runbook)
6one 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.sock

resume 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-host

Each 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.conf

Expected 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:

SignalHealthy shape
age of the newest render-receipt.json / activation-receipt.jsonwithin 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_secondsmoves 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_sha256equal after every exit-0 run
<host-state-dir>/ixp-manager-host-fence.jsonabsent — its presence is an exit-5 condition waiting for an operator
bgp_session_state_transitions_totalflat 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_compatibility is true: 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_supported in 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.version is 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 to 0 ("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 on SIGHUP; the activation publishes the new file atomically with the daemon generation. Direct --protocol-alias values remain startup-only. Nothing signals the adapter automatically.
  • Informational communities on accepted routes only. Accepted routes carry the RS:1000:* and RS: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's RS: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 keeps RS:1001:1000/1001/1002 (IRRDB filtered loose/strict, prefix empty) and any informational tag an earlier check set, such as RS:1000:2 before 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 one RS: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:1002 has 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-in rs-config-render prune command (a dry run unless --apply; see the renderer README). The helper does not retry indefinitely, deploy services, or call IXP Manager from activate.
  • 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 the next_hop_ownership reason (IXP Manager reject reason 8 through the adapter) rather than accepted with IXP_LC_INFO_SAME_AS_NEXT_HOP as in IXP Manager's BIRD templates (full same-AS next-hop parity waits for ADR-0107). Members with IRRDB filtering disabled (irrdbfilter off) 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

Source on GitHub

On this page