id: bgp-prefix-origin-and-rpki-watch
namespace: company.team
description: |
Watch how the internet routes the IP prefixes your company announces. Using public RIPE RIS data,
alert in Slack when another network originates your prefix or a more-specific of it (a possible BGP
hijack), when the prefix loses visibility, or when its RPKI status for your ASN is no longer valid.
inputs:
- id: prefixes
type: JSON
displayName: Prefixes to watch
description: |
List of objects with the announced `prefix`, the `origin` ASN that should announce it, optional
`allowed_origins` (extra ASNs allowed to announce it or a more-specific, such as a DDoS scrubbing
provider), and an `owner` to mention.
defaults: |
[
{"prefix": "1.1.1.0/24", "origin": 13335, "allowed_origins": [], "owner": "@network"},
{"prefix": "2606:4700:4700::/48", "origin": 13335, "allowed_origins": [], "owner": "@dns-team"},
{"prefix": "140.82.112.0/20", "origin": 36459, "allowed_origins": [], "owner": "@network"}
]
- id: min_visibility_percent
type: INT
displayName: Minimum visibility (%)
description: Alert when fewer than this share of RIPE RIS full-table peers see
the prefix.
defaults: 80
tasks:
- id: for_each_prefix
type: io.kestra.plugin.core.flow.Loop
description: Check every prefix independently, four at a time.
values: "{{ inputs.prefixes }}"
concurrencyLimit: 4
tasks:
- id: fetch_routing_status
type: io.kestra.plugin.core.http.Download
description: Origins, more-specific announcements and peer visibility seen by
RIPE RIS (public, no key).
uri: "https://stat.ripe.net/data/routing-status/data.json?resource={{
fromJson(item.value).prefix }}&sourceapp=kestra-blueprint"
- id: fetch_rpki
type: io.kestra.plugin.core.http.Download
description: RPKI route origin validation of the prefix for the expected origin ASN.
uri: "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS{{
fromJson(item.value).origin }}&prefix={{ fromJson(item.value).prefix
}}&sourceapp=kestra-blueprint"
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: Findings reported at the last run. Missing on the first run.
key: "bgp_watch_{{ fromJson(item.value).prefix | replace({':': '-'}) | slugify
}}"
errorOnMissing: false
- id: evaluate
type: io.kestra.plugin.scripts.python.Script
description: Compare what the internet sees with what you expect, and list new
and cleared findings.
containerImage: python:3.12-slim
inputFiles:
routing.json: "{{ outputs.fetch_routing_status.uri }}"
rpki.json: "{{ outputs.fetch_rpki.uri }}"
expected.json: "{{ item.value }}"
previous.json: "{{ (outputs.load_previous.value ?? {}) | toJson }}"
script: |
import json
def load(name):
with open(name, encoding="utf-8") as f:
data = json.load(f)
return json.loads(data) if isinstance(data, str) else data
routing = load("routing.json")["data"]
rpki = load("rpki.json")["data"]
expected = load("expected.json")
previous = load("previous.json") or {}
prefix = expected["prefix"]
allowed = {int(expected["origin"])} | {int(a) for a in expected.get("allowed_origins", [])}
family = "v6" if ":" in prefix else "v4"
seen = routing["visibility"][family]["ris_peers_seeing"]
total = routing["visibility"][family]["total_ris_peers"] or 1
visibility = round(100 * seen / total)
origins = sorted(int(o["origin"]) for o in routing.get("origins", []))
rpki_status = rpki.get("status", "unknown")
findings = {}
for asn in origins:
if asn not in allowed:
findings[f"origin:{asn}"] = f"AS{asn} originates {prefix}, which only {', '.join(f'AS{a}' for a in sorted(allowed))} should announce."
for route in routing.get("more_specifics", []):
asn = int(route["origin"])
if asn not in allowed:
findings[f"more:{route['prefix']}:{asn}"] = f"AS{asn} announces the more-specific {route['prefix']} (more-specific routes win, so this attracts the traffic)."
if visibility < {{ inputs.min_visibility_percent }}:
findings["visibility"] = f"Only {seen} of {total} RIS peers ({visibility}%) see {prefix}. The prefix may be withdrawn or filtered."
if rpki_status != "valid":
roas = ", ".join(f"AS{r['origin']} {r['prefix']} max /{r['max_length']}" for r in rpki.get("validating_roas", [])) or "none"
effect = ("No ROA covers it, so networks that validate routes cannot reject a hijack of it."
if rpki_status == "unknown" else
"Networks that drop RPKI-invalid routes reject your own announcement.")
findings[f"rpki:{rpki_status}"] = f"RPKI status for AS{expected['origin']} is `{rpki_status}` (ROAs covering it: {roas}). {effect}"
new = [findings[k] for k in findings if k not in previous]
cleared = [previous[k] for k in previous if k not in findings]
print(f"{prefix}: origins {origins or 'none'}, visibility {visibility}%, RPKI {rpki_status}, "
f"{len(findings)} findings ({len(new)} new, {len(cleared)} cleared)")
print("::" + json.dumps({"outputs": {
"new_findings": new,
"cleared": cleared,
"notify": bool(new or cleared),
"origins": ", ".join(f"AS{a}" for a in origins) or "none",
"visibility": visibility,
"rpki_status": rpki_status,
"findings": findings,
}}) + "::")
- id: if_changed
type: io.kestra.plugin.core.flow.If
description: Only post when a finding appears or clears.
condition: "{{ outputs.evaluate.vars.notify }}"
then:
- id: alert_owner
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the new and cleared findings with the current routing facts
from RIPE RIS.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ outputs.evaluate.vars.new_findings | length > 0 ? ':rotating_light:' : ':white_check_mark:' }} *BGP routing of {{ fromJson(item.value).prefix }} changed* (owner: {{ fromJson(item.value).owner }})
{% for f in outputs.evaluate.vars.new_findings %}- {{ f }}
{% endfor %}{% for c in outputs.evaluate.vars.cleared %}- Resolved: {{ c }}
{% endfor %}Now: origins {{ outputs.evaluate.vars.origins }} | visibility {{ outputs.evaluate.vars.visibility }}% | RPKI {{ outputs.evaluate.vars.rpki_status }}
<https://stat.ripe.net/{{ fromJson(item.value).prefix }}|Open in RIPEstat>
- id: remember_findings
type: io.kestra.plugin.core.kv.Set
description: Store the current findings so each one is reported once. Runs after
the alert.
key: "bgp_watch_{{ fromJson(item.value).prefix | replace({':': '-'}) | slugify
}}"
kvType: JSON
overwrite: true
value: "{{ outputs.evaluate.vars.findings | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A prefix that cannot be checked must not look healthy.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: BGP prefix watch failed in execution {{ execution.id }}
({{ flow.namespace }}.{{ flow.id }}). Open the failed loop iteration: the
prefix may be malformed, or RIPEstat may be unavailable."
triggers:
- id: every_15_minutes
type: io.kestra.plugin.core.trigger.Schedule
description: RIPE RIS refreshes routing state continuously. Disabled until the
Slack secret is set.
cron: "*/15 * * * *"
disabled: true