RPKI Origin Validation
Ze validates received BGP routes against RPKI ROA data. Invalid routes are rejected before entering the RIB. The feature connects to RTR cache servers (RFC 8210), downloads Validated ROA Payloads (VRPs), and applies the RFC 6811 origin validation algorithm to each received prefix.
Configuration
Add the bgp-rpki and bgp-adj-rib-in plugins, then configure one or more RTR cache servers under bgp { rpki { ... } }.
plugin {
internal rpki {
use bgp-rpki
}
internal adj-rib-in {
use bgp-adj-rib-in
}
}
bgp {
rpki {
cache-server 192.0.2.1 {
port 323
}
}
peer peer1 {
connection {
remote {
ip 10.0.0.1
}
local {
ip 10.0.0.2
}
}
session {
asn {
local 65000
remote 65001
}
router-id 10.0.0.2
family {
ipv4/unicast {
prefix {
maximum 1000000
}
}
}
}
attach process rpki {
receive [ update-received ]
}
attach process adj-rib-in {
receive [ update-received state ]
}
}
}
Config Reference
| Path | Type | Default | Description |
|---|---|---|---|
rpki / cache-server <addr> |
list | -- | RTR cache server (keyed by IP/hostname) |
rpki / cache-server / port |
uint16 | 323 | RTR TCP port |
rpki / cache-server / preference |
uint8 | 100 | Server preference (lower preferred) |
rpki / validation-timeout |
uint16 | 30 | Seconds before fail-open on pending routes |
rpki / action / invalid |
enum | reject | Action for Invalid routes: reject, log-only, accept |
rpki / action / not-found |
enum | accept | Action for NotFound routes: accept, reject, log-only |
rpki / aspa / validation |
boolean | false | Enable ASPA path verification using RTR v2 ASPA records |
rpki / aspa / action / invalid |
enum | log-only | Action for ASPA Invalid routes: reject, log-only, accept |
rpki / aspa / action / unknown |
enum | accept | Action for ASPA Unknown routes: accept, reject, log-only |
Multiple cache servers are supported for redundancy. VRP tables from all servers are merged (union).
Per-peer and per-group actions
The action block (both the origin action and the ASPA action) can also be set under a
peer or a group, overriding the global rpki / action for routes learned from that peer.
Only the action blocks are per-peer; cache-server, validation-timeout, and aspa / validation
remain global. Resolution is peer > group > global, per leaf: a leaf left unset on the peer
inherits the group's value, then the global value.
A listen-range group states the actions for every session it accepts. Such a session is
created from the group's template when the connection arrives, so it has no peer block of
its own, and it inherits what the group states. A NAMED peer that gives no
connection / remote / ip of its own is different: ze never builds it from the template, so
it has no address at all and it uses the global actions. It is reported at startup with
rpki: per-peer action override ignored: no static remote ip.
bgp {
rpki { /* global: caches + baseline actions */
cache-server 192.0.2.1 { port 323; }
action { invalid reject; not-found accept; }
}
group transit {
rpki { action { invalid reject; } } /* group default for members */
peer customer-a {
rpki { action { invalid log-only; } } /* per-peer override; not-found inherits global */
}
}
group ix { /* listen range: no peer blocks */
connection { remote { ip dynamic; range 192.0.2.0/24; } }
rpki { action { invalid reject; } } /* every session this group accepts */
}
}
show bgp rpki status reports the effective global actions (actions) and the resolved per-peer
overrides with the source of each leaf (peer-actions). An entry names what it is: "peer"
carries a remote address, and "group" carries a listen-range group's name and states what
every session that group accepts inherits.
Blackhole exemption
| Path | Type | Default | Description |
|---|---|---|---|
peer / rpki / blackhole-exempt |
boolean | false | Keep a BLACKHOLE-tagged route whose only origin-validation fault is prefix length |
The leaf resolves peer then group, and it has NO global level. RFC 7999 Section 3.3 binds the blackhole agreement to one BGP session, so a daemon-wide exemption would reach sessions that agreed to nothing.
A blackhole prefix is as long as possible, usually a /32 or a /128, while a ROA
for the covering block carries its maxLength at the aggregate. RFC 6811 then
makes the announcement Invalid on length alone, and a session running
action { invalid reject; } drops it before anything can honor it. RFC 7999
Section 3.3 states that an operator must make sure origin validation does not
block a legitimate announcement carrying BLACKHOLE, and this leaf is that
mechanism.
bgp {
peer transit-a {
rpki {
action { invalid reject; }
blackhole-exempt true;
}
blackhole {
communities blackhole;
prefixes 192.0.2.0/24;
}
}
}
The exemption is narrow. It applies only when a covering VRP names the route's own origin AS and disagrees on nothing but length. A wrong origin AS stays Invalid, which is the hijack RFC 6811 exists to catch. A prefix with no covering VRP is NotFound rather than Invalid, so the exemption never reaches it.
Set it on the same session that names a blackhole community. The exemption reads
the communities THAT session agreed to, so a peer running RTBH on 65001:666
gets it for 65001:666. On a session that names no community the leaf does
nothing: it would accept a route it would have rejected and discard nothing. See
Blackhole Honoring for the
blackhole container itself.
Plugin Bindings
The rpki plugin must be bound to peers with attach process rpki { receive [ update-received ]; }. It validates what the peer announces, so it asks for one direction. The adj-rib-in plugin must also be bound with attach process adj-rib-in { receive [ update-received state ]; } -- it provides the validation gate that holds routes pending validation.
How It Works
Validation States
Each received route gets one of three states (RFC 6811):
| State | Meaning | Default Action |
|---|---|---|
| Valid | Origin AS and prefix length match a VRP | Accept |
| Invalid | A VRP covers the prefix but origin AS or length doesn't match | Reject |
| NotFound | ✕ | Accept |
A prefix ze cannot parse gets Invalid and a warning, never NotFound. NotFound
states that the VRP set was consulted and covers nothing, and the default
not-found accept action accepts a route on that reading. A prefix that was
never validated fails closed instead.
Validation Flow
- Ze connects to configured RTR cache servers and downloads VRPs
- A BGP UPDATE arrives from a peer
- The adj-rib-in plugin stores the route as "pending"
- The rpki plugin extracts the origin AS (rightmost AS in final AS_SEQUENCE segment)
- For each NLRI prefix, the rpki plugin looks up covering VRPs and computes the validation state
- Valid/NotFound routes are promoted to installed; Invalid routes are discarded
Fail-Open Safety
If the rpki plugin does not respond within validation-timeout seconds (default: 30), pending routes are automatically promoted to installed. This prevents route black-holing if the RPKI infrastructure is unavailable.
If all RTR cache servers disconnect, the existing VRP cache is retained until the connection is re-established. Routes continue to be validated against the last known good cache.
Re-validation on VRP Change
When the VRP set changes, ze re-validates every tracked route and applies the current action to each route whose state changed (RFC 6811 Section 4). The decision reaches routes that are already installed, not only routes still pending: an accept rewrites the state in place and keeps the route's sequence number, and a reject removes the route from the Adj-RIB-In. No session clear and no route refresh is needed to pick up a new ROA.
This matters most when UPDATEs arrive before the first sync completes. Those
routes validate NotFound against an empty VRP set, and the default
not-found accept installs them. The re-validation after the sync is what turns
an RPKI-Invalid one into a reject.
RTR Poll Timing
RFC 8210 Section 6 splits two intervals, and ze uses each for its own event. The Refresh Interval from the End Of Data PDU is the wait before the next poll, and its countdown starts when that PDU arrives. The Retry Interval is the wait after a query FAILED, and it also covers a session that has never completed a successful query.
AS_PATH Edge Cases
| AS_PATH | Origin AS | Result |
|---|---|---|
Normal sequence [65000 65001] |
65001 (rightmost) | Normal validation |
Ends with AS_SET {65001 65002} |
None | Always Invalid if covered by VRP |
| Empty (iBGP, no AS prepend) | None | NotFound (no origin to match) |
CLI Commands
Query RPKI status through the ze CLI:
| Command | Description |
|---|---|
show bgp rpki status |
Show RTR session count, sync state, and VRP counts |
show bgp rpki cache |
Show cache server connection details |
show bgp rpki roa |
Show ROA table summary |
show bgp rpki summary |
Show validation statistics |
Example:
$ ze cli -c "show bgp rpki status"
{"running":true,"vrp-count-ipv4":3,"vrp-count-ipv6":0,"sessions":1,"sessions-synced":1,"synced":true,"aspa-enabled":false,"aspa-records":0,"cache-servers":[{"address":"192.0.2.1","port":3323,"state":"idle","synced":true,"version":2}],"actions":{"invalid":"reject","not-found":"accept","aspa-invalid":"log-only","aspa-unknown":"accept"},"peer-actions":[]}
running says that a cache server is configured. synced says that a cache
server completed a sync and gave ze a VRP set. The two are different states:
while synced is false, ze holds no VRP set, so every prefix reads not-found
and the default not-found accept action accepts it. sessions-synced counts
the cache servers that delivered data, and each entry in cache-servers carries
its own synced. state is the RTR connection state, which returns to idle
between polls even after a successful sync.
RPKI Validation Events
When the rpki plugin is loaded, it emits validation events that other plugins can subscribe to. A plugin subscribing to rpki direction received receives a JSON event for each validated UPDATE:
{
"type": "bgp",
"bgp": {
"peer": {"address": "10.0.0.1", "local": {"address": "10.0.0.2", "as": 65000}, "remote": {"address": "10.0.0.1", "as": 65001}},
"message": {"id": 42, "type": "rpki"},
"rpki": {
"ipv4/unicast": {
"10.0.1.0/24": "valid",
"10.0.2.0/24": "invalid"
}
}
}
}
When the ROA cache is empty: "rpki": {"status": "unavailable"}.
Merged Events (bgp-rpki-decorator)
Instead of receiving separate UPDATE and rpki events, you can use the bgp-rpki-decorator plugin to get a single update-rpki event containing both the UPDATE data and the RPKI validation state:
plugin {
internal rpki-decorator {
use bgp-rpki-decorator
}
}
bgp {
peer peer1 {
attach process my-consumer {
receive [ update-rpki ]
}
attach process rpki {
receive [ update-received ]
}
attach process rpki-decorator {
receive [ update-received rpki ]
}
attach process adj-rib-in {
receive [ update-received state ]
}
}
}
The merged event contains the full UPDATE JSON with an rpki section injected:
{
"type": "bgp",
"bgp": {
"peer": {"address": "10.0.0.1", "local": {"address": "10.0.0.2", "as": 65000}, "remote": {"address": "10.0.0.1", "as": 65001}},
"message": {"id": 42, "type": "update-rpki"},
"update": {"attr": {"origin": "igp"}, ...},
"rpki": {"ipv4/unicast": {"10.0.1.0/24": "valid"}}
}
}
If the RPKI validation does not arrive within the timeout (2 seconds), the event is emitted without the rpki section (graceful degradation).
ASPA Path Verification
ASPA (Autonomous System Provider Authorization) verifies that AS_PATH hops are authorized by provider-customer relationships. ASPA records are distributed via RTR v2 (RFC 9582) alongside VRPs. Ze implements the verification algorithm from draft-ietf-sidrops-aspa-verification Section 6.
ASPA is opt-in. Enable it under the rpki { aspa { ... } } block. By default ASPA results are informational (included in the RPKI event JSON as "aspa-state"). Configure policy actions in the same block to enforce ASPA verification by rejecting routes with Invalid or Unknown paths.
Configuration
bgp {
rpki {
cache-server 192.0.2.1 {
port 323;
}
aspa {
validation true;
action {
invalid reject;
}
}
}
}
ASPA Policy Actions
| Setting | Values | Default | Effect |
|---|---|---|---|
aspa / action / invalid |
reject, log-only, accept | log-only | Action when a route's AS_PATH fails ASPA verification |
aspa / action / unknown |
accept, reject, log-only | accept | Action when ASPA records are missing for some ASes in the path |
The default for invalid is log-only (conservative) rather than reject because ASPA deployment is incomplete and missing ASPA records can cause false Invalid results. Set to reject once your upstream providers have published ASPA records.
ASPA policy overrides origin validation: a route that is ROA Valid but ASPA Invalid will be rejected when invalid is set to reject.
ASPA Validation States
Each route receives one of three ASPA states:
| State | Meaning |
|---|---|
| Valid | Every hop pair in the AS_PATH is authorized by an ASPA record |
| Invalid | At least one hop pair has an ASPA record that does not list the provider candidate |
| Unknown | ✕ |
How It Works
- The RTR session negotiates v2 (with v1 fallback on error code 4)
- The cache server sends ASPA PDUs (type 11) alongside VRPs
- For each received UPDATE, the plugin normalizes the AS_PATH: removes consecutive duplicate ASNs (prepend artifacts), strips AS_CONFED_SEQUENCE segments, and flags AS_SET or AS_CONFED_SET as unverifiable
- Each adjacent pair (provider candidate, customer) is checked against the ASPA cache
- The result is included as
"aspa-state"in the RPKI event JSON
ASPA Event Format
The "aspa-state" field is included alongside per-prefix origin validation results:
{
"type": "bgp",
"bgp": {
"peer": {"address": "10.0.0.1", "local": {"address": "10.0.0.2", "as": 65000}, "name": "upstream", "remote": {"address": "10.0.0.1", "as": 65001}},
"message": {"id": 42, "type": "rpki"},
"rpki": {
"ipv4/unicast": {
"10.0.1.0/24": "valid"
},
"aspa-state": "valid"
}
}
}
When ASPA validation is disabled or the cache has no ASPA records, the "aspa-state" field is omitted.
Re-validation on Cache Change
Routes are tracked with their normalized AS_PATH. When ASPA cache data changes (new records from the RTR server), affected routes are automatically re-validated and updated events are emitted for any route whose ASPA state changed. If policy is set to reject and a route's state changes to Invalid or Unknown, the route is withdrawn from the RIB.
Testing ASPA
The ze-test rtr-mock command supports ASPA records with the --aspa flag:
ze-test rtr-mock --port 3323 \
--vrp 10.0.0.0/8,24,65001 \
--aspa 64502:64501 \
--aspa 64501:64500
The format is customer:provider1,provider2,... (repeatable). When ASPA records are present, the mock server uses RTR v2. Seven functional tests cover ASPA: rpki-aspa-valid.ci, rpki-aspa-invalid.ci, rpki-aspa-unknown.ci, rpki-aspa-disabled.ci for verification states, and rpki-aspa-policy-reject.ci, rpki-aspa-policy-logonly.ci, rpki-aspa-policy-unknown-reject.ci for policy enforcement.
Testing RPKI Locally
The ze-test rpki command starts a deterministic mock RTR server that auto-generates VRPs based on the first octet of each /8 prefix:
ze-test rpki --port 3323
Validation states are predictable (for routes from AS 65001 with default flags):
| First octet | Modulo | State | Example |
|---|---|---|---|
| 0, 3, 6, 9... | %3 == 0 | Valid | 9.0.1.0/24 |
| 1, 4, 7, 10... | %3 == 1 | Invalid | 10.0.1.0/24 |
| 2, 5, 8, 11... | %3 == 2 | NotFound | 11.0.1.0/24 |
Accept valid routes and reject RPKI-invalid ones
Feed three local routes through a deterministic RTR cache, then show Valid and NotFound routes installed while the Invalid route is absent.
Read the demonstration transcript
$ ze cli -c 'show bgp rpki status | no-more'
sessions: 1
vrp-count-ipv4: 171
$ ze cli -c 'show bgp adj-rib-in | no-more'
9.43.0.0/24 validation-state: 1
11.43.0.0/24 validation-state: 2
The local RTR cache classifies 9.43.0.0/24 as Valid, 10.43.0.0/24 as Invalid, and 11.43.0.0/24 as NotFound. Policy accepts Valid and NotFound. The Invalid prefix is absent from Adj-RIB-In because Ze rejects it before installation.
Without RPKI
When the rpki plugin is not loaded, routes flow directly into the adj-rib-in with zero overhead. No pending state, no validation delay. The validation gate is only activated when the rpki plugin sends request bgp adj-rib-in enable-validation during startup.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Routes delayed 30s then accepted | RTR cache server unreachable | Check connectivity to cache server, verify port |
| All routes Invalid | Wrong cache server data, or origin AS mismatch | Check show bgp rpki roa output, verify VRP coverage |
| ✕ | RTR session not established | Check show bgp rpki status, verify cache server is running |
| Routes accepted without validation | rpki plugin not bound to peer | Add attach process rpki { receive [ update-received ]; } to peer config |