RPKI Origin Validation
Ze validates received BGP routes against RPKI ROA data. By default, Invalid routes remain in Adj-RIB-In with their received attributes but are ineligible for selection and export. 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
trusted-network true
}
}
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 state ]
}
attach process adj-rib-in {
receive [ update-received state ]
}
}
}
RTR Protocol Versions
Ze starts RTR negotiation at version 2 and can reconnect at an advertised version 1. It does not implement version 0. Ze rejects an Unsupported Protocol Version reply advertising version 0 rather than guessing another version.
StayRTR v0.6.4 needs -protocol 1 -enforce.version=true for this exchange.
Without enforcement, its unsupported-version reply can carry the new client's
initial version 0 even though the server supports version 1. Enforcement
initializes the client at version 1 before it rejects Ze's version-2 query.
This cache setting leaves Ze's version negotiation unchanged.
Trusted RTR Transport
Choose TLS unless the cache is on a trusted, controlled network. Unprotected TCP
requires an explicit trusted-network true; that setting records the operator's
choice, not a measurement of the network's security.
For native mutual TLS, name a CA and a client identity from the PKI certificate store:
bgp {
rpki {
cache-server 192.0.2.1 {
source-address 192.0.2.2
tls {
ca-certificate rtr-ca
certificate rtr-router
server-name cache.example.net
}
}
}
}
The client certificate needs its private key, its intermediate chain, and one
or more iPAddress subjectAltNames. The cache checks those against the client's
address as the cache sees it, including any NAT translation, and must request
and authenticate this identity. Ze validates the cache
against the named CA and the DNS name in server-name, using a dNSName
subjectAltName, never the Common Name. A DNS cache address supplies the reference
name when server-name is omitted; an IP cache address requires an explicit
DNS name. TLS defaults to port 324, unprotected TCP to port 323.
An authentication failure sends no RTR query. Ze never downgrades that cache
connection to plaintext, even if trusted-network true is also present.
Candidate configuration validates
the named credentials without changing live PKI. A committed PKI-only rotation
restarts the cache transport with the candidate identity; rollback restores the
identity from before that transaction's apply. If the transaction fails before
RPKI applies its candidate, rollback leaves the committed policy and credentials
unchanged. Transport changes retain the last complete payload set only until
its existing expiration deadline.
Reload replaces only roots named in the delivery. An omitted bgp or pki
root keeps its committed value; an explicitly removed root does not.
Config Reference
| Path | Type | Default | Description |
|---|---|---|---|
rpki / cache-server <addr> |
list | -- | RTR cache server (keyed by IP/hostname) |
rpki / cache-server / port |
1..65535 | 323 TCP, 324 TLS | RTR transport port |
rpki / cache-server / trusted-network |
boolean | false | Explicitly permit unprotected TCP on a trusted, controlled network |
rpki / cache-server / tls / ca-certificate |
string | required with TLS | Named PKI CA for cache authentication |
rpki / cache-server / tls / certificate |
string | required with TLS | Named PKI client certificate and private key |
rpki / cache-server / tls / server-name |
string | cache DNS address | DNS reference identity; required for an IP cache address |
rpki / cache-server / preference |
uint8 | 100 | Server preference (lower preferred) |
rpki / validation-timeout |
1..65535 | 30 | Seconds before fail-open on pending routes; a reload applies the new deadline |
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 | reject | 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. Ze tries them in preference order and uses the most preferred server that answers. A completed full synchronization atomically replaces the previous server's data; partial transfers do not replace the working set.
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; trusted-network true; }
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.
running is the flag that controls per-prefix validation. synced reports a
completed synchronization for a configured session whose payload set is still
current and unexpired. After a transport change, those sessions can be unsynced
while Ze still uses the previous generation's unexpired data. The summary's
validation-enabled reports whether validation has a usable payload set.
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.
Cache updates also reconsider this exemption when the origin state remains Invalid. Replacing a length-only authorization with one for a different origin makes the retained route ineligible; restoring the correct origin can admit it again without another UPDATE.
Plugin Bindings
Bind the rpki plugin with attach process rpki { receive [ update-received state ]; }. UPDATEs supply the received routes; state events remove a disconnected peer's tracked paths. Bind adj-rib-in with attach process adj-rib-in { receive [ update-received state ]; } too: it owns the validation gate and retained received routes.
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
- The configured actions determine eligibility. Rejected routes retain their received data and validation state, but cannot be selected or exported.
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 or fail authentication, the last complete VRP and ASPA sets remain usable only until the Expire Interval from their last completed End of Data. Expiry clears both sets and triggers re-validation. Partial responses, reconnects, and credential changes do not extend that deadline.
Re-validation on VRP Change
When the VRP set changes, Ze re-validates every tracked route and applies the current action (RFC 6811 Section 4). A rejection marks the received route ineligible rather than deleting it. A later valid authorization can make that same received path eligible again, without another UPDATE or a route refresh.
RPKI events also report cache availability changes. A route received before a
nonempty ROA load initially reports unavailable; synchronization publishes its
current origin and ASPA verdicts even if both verdicts are unchanged. Cache
expiry publishes unavailable again. No second UPDATE is required.
Changing validation policy re-evaluates retained received attributes while Adj-RIB-In holds the affected routes pending. Disabling RPKI removes only RPKI's denial, not another validator's decision or a newer UPDATE's generation fence. Rolling back the configuration re-applies the previous policy. The retained-route snapshot also publishes current RPKI verdicts. This covers an UPDATE that arrived before RPKI's asynchronous startup callback, without waiting for another UPDATE or a later cache change. Each event groups all retained prefixes and families from the same peer and received UPDATE, so the RPKI decorator can correlate the complete verdict set with that UPDATE.
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
The Refresh Interval from End of Data controls the next successful-cache poll; the Retry Interval controls attempts after a failed query, including a cache that has never answered. The Expire Interval is a separate data lease starting at End of Data. Its timer runs independently of blocked network reads and route re-validation. A successful End of Data renews the lease; expiration invalidates the serial base, so the next query requests a complete reset.
A cache answering No Data Available ends the current attempt. Ze tries the next cache in preference order; if none answers, it retries with Reset Queries after the Retry Interval. It does not wait for the cache to close its connection.
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) | Local speaker AS | Normal validation |
| Ends with AS_CONFED_SEQUENCE or AS_CONFED_SET | Local speaker AS | Normal validation |
CLI Commands
Query RPKI status through the ze CLI:
| Command | Description |
|---|---|
show bgp rpki |
Show the validation counters with one row for each cache server |
show bgp rpki status |
Show RTR session count, sync state, VRP counts, and the effective actions |
show bgp rpki cache |
Show cache server connection details |
show bgp rpki roa |
Show ROA table summary, or the covering VRPs for a prefix |
show bgp rpki summary |
Show validation statistics |
show bgp rpki aspa |
Show the ASPA cache, or the providers for a customer AS |
request bgp rpki validate <prefix> <origin-asn> |
Validate one prefix against the ROA cache |
show bgp rpki aspa <customer-asn> answers its one result under entries, in
the same row shape the no-argument spelling writes, so | count and | display
act on either. found stays beside the rows: it separates a customer with no
ASPA record from an empty cache, which the row count alone cannot say.
show bgp rpki answers the counters and the cache server rows as siblings, so
show bgp rpki | summary cuts it down to the counters alone. That name is a pipe
alias the plugin declares over its own command, and it answers the same record
show bgp rpki summary answers. The seven counters are vrp-count,
validation-enabled, sessions-total, sessions-established,
sessions-synced, aspa-enabled and aspa-records.
validation-enabled is true when validation is active and an unexpired payload
set is available, including an authenticated empty set. It remains true while
that set is retained across a transport change, and becomes false on expiration
or when RPKI is disabled. It is not a claim that every prefix has a covering ROA.
A plugin's pipe alias lives in the daemon's registry. ze cli with no command
argument expands the chain in the client process instead, so | summary comes
back there as pipe error: unknown pipe operator: summary. Use
ze cli -c "..." as above, or the interactive session a plain ssh client
reaches. show bgp rpki summary works in every client.
Example:
$ ze cli -c "show bgp rpki status | json compact"
{"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":"reject","aspa-unknown":"accept"},"peer-actions":[]}
The | json compact pipe asks for that shape. Without it the answer is rendered
in the format environment cli format default names, whose registered value is
text.
running says that a cache server is configured. sessions-synced counts the
configured sessions supplying a current, unexpired set; each cache row has its
own synced flag. During transport rotation, a retained set may still be usable
even though none of the new sessions has synchronized. See validation-enabled
in the summary for data availability. 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 state ]
}
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) checks AS paths against published provider authorizations. Ze uses the RTR v2 wire format from draft-ietf-sidrops-8210bis-27 Section 5.12 and the verification procedures from draft-ietf-sidrops-aspa-verification-28 Section 5. RFC 9582 specifies the ROA profile, not RTR v2.
Verification runs on IPv4 unicast and IPv6 unicast routes only, as draft-ietf-sidrops-aspa-verification Section 6.2 requires. A route of any other address family carries no ASPA state, is not tracked for re-validation, and no ASPA action excludes it.
ASPA is opt-in. Enable it under rpki / aspa / validation and configure role / import on each participating peer or group. Once enabled, the default policy keeps Invalid routes in the Adj-RIB-In but excludes them from route selection and advertisement. Valid and Unknown routes are accepted unless another configured policy rejects them.
Configuration
bgp {
rpki {
cache-server 192.0.2.1 {
port 323;
trusted-network true;
}
aspa {
validation true;
action {
invalid reject;
}
}
}
}
The configured role describes Ze's local role. It selects the procedure for routes received on that session:
Local role / import |
Route received from | Procedure |
|---|---|---|
provider |
Customer | Upstream |
peer |
Peer | Upstream |
rs |
Route-server client | Upstream |
rs-client |
Transparent route server | Upstream |
customer |
Provider | Downstream |
Without a configured role, an ordered nonempty path is Unknown: Ze cannot infer whether the neighbor is a provider or a customer from the AS numbers. ASPA verification is not applied to iBGP UPDATEs.
ASPA Policy Actions
| Setting | Values | Default | Effect |
|---|---|---|---|
aspa / action / invalid |
reject, log-only, accept | reject | 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 |
Missing ASPA records produce Unknown, not a false Invalid. Invalid means the procedure found an unauthorized relationship or a structurally invalid path. An ASPA that omits a real provider can therefore make a legitimate path Invalid. Operators can choose log-only or accept instead of the default reject; log-only records a warning without making ASPA itself a reason to exclude the route.
Both origin and path policies apply. An ASPA rejection excludes an otherwise ROA-Valid route. Repairing its ASPA state does not override a remaining origin-validation rejection.
ASPA Validation States
Each route receives one of three ASPA states:
| State | Meaning |
|---|---|
| Valid | The applicable upstream path or downstream ramps satisfy the authorization procedure |
| Invalid | The procedure proves an unauthorized path, or the path is empty or contains an AS_SET |
| Unknown | Available authorizations or the configured relationship do not establish Valid or Invalid |
How It Works
- The RTR session starts at v2. If the cache names supported v1 in an Unsupported Version response, Ze reconnects at v1; ASPA records are unavailable at that version.
- The cache sends ASPA PDUs alongside VRPs. An announcement replaces the provider list for one customer AS; a 12-byte withdrawal removes that customer's complete record. An AS0-only list means no authorized providers. AS0 mixed with other providers is an RTR error.
- The BGP receive path checks the first AS against the neighbor after AS4 reconstruction. A mismatch is treated as withdrawal, except on a local transparent route-server-client session. AS_SET handling follows the receive path's RFC 9774 policy; when an AS_SET reaches ASPA verification, its result is Invalid.
- ASPA removes consecutive duplicate ASNs. Upstream verification checks authorization from origin toward neighbor; downstream verification compares the authorized and possible ramps from both ends of the path.
- The result appears 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"
}
}
}
The "aspa-state" field is omitted when ASPA verification does not apply, including disabled ASPA and UPDATEs containing only non-unicast families. In a mixed-family UPDATE, it describes the IPv4 and IPv6 unicast routes, not the other families sharing that event. An enabled verifier with an empty ASPA cache reports Unknown for a multi-AS path, not an omitted state.
Re-validation on Cache Change
Routes retain their received attributes and normalized AS_PATH. When ASPA data changes, Ze re-evaluates affected routes and emits updated states. A configured rejection makes the retained route ineligible and withdraws any advertisement; it does not delete the Adj-RIB-In copy. If later cache data makes both configured validation policies accept the route, it becomes eligible and can be advertised again without a new UPDATE from the neighbor. Withdrawals, replacement UPDATEs, and session teardown prevent old cache decisions from restoring obsolete routes.
Testing ASPA
The le test rtr-mock command supports ASPA records with the --aspa flag:
le 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 le test rpki command starts a deterministic mock RTR server that auto-generates VRPs based on the first octet of each /8 prefix:
le 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 Adj-RIB-In without an RPKI pending state or validation delay. The plugin enables the validation gate when startup or a configuration change adds a cache server. Removing the last cache server disables the gate and releases retained routes from RPKI policy.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Routes delayed 30s then accepted | RPKI validation callback missing or unresponsive | Check process bindings and plugin logs; an empty or expired cache returns NotFound without this delay |
| All routes Invalid | Wrong cache server data, or origin AS mismatch | Check show bgp rpki roa output, verify VRP coverage |
| ✕ | Cache empty or RTR synchronization failed | Check show bgp rpki status and plugin logs; for TLS, check the named credentials, certificate chains, and DNS reference name |
| Routes accepted without validation | rpki plugin not bound to peer | Add attach process rpki { receive [ update-received state ]; } to peer config |