Skip to content

Commit f837979

Browse files
authored
Added Routinator application (#625)
* Added Routinator application * Updated with compression support
1 parent beeb995 commit f837979

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

snmp/routinator.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""
3+
LibreNMS SNMP extend script for NLnet Labs Routinator (RPKI validator).
4+
5+
Fetches Routinator's JSON status API (`/api/v1/status`), aggregates it into a
6+
compact, stable shape and prints a single LibreNMS application JSON envelope to
7+
stdout. Designed to be wired into snmpd via:
8+
9+
extend routinator /etc/snmp/routinator.py
10+
11+
The output is gzip+base64-compressed. LibreNMS detects and decodes this
12+
automatically; it shrinks the payload over SNMP and sidesteps snmpd's mangling
13+
of some characters.
14+
15+
The script is stateless: it reports current values only. Rates (bytes/sec etc.)
16+
are derived by LibreNMS from successive counter samples.
17+
18+
Optional config file (JSON) at /etc/snmp/routinator.json overrides the defaults:
19+
20+
{
21+
"url": "http://127.0.0.1:8323/api/v1/status",
22+
"timeout": 5,
23+
"include_failed_uris": true,
24+
"max_failed_uris": 25
25+
}
26+
27+
`url` may instead be given as separate "host"/"port" keys. If the file is
28+
absent the built-in defaults are used; confirm the real http-listen port with
29+
the Routinator operator (the default 8323 is commonly changed).
30+
"""
31+
32+
import base64
33+
import gzip
34+
import json
35+
import re
36+
import sys
37+
import urllib.request
38+
from datetime import datetime, timezone
39+
40+
CONFIGFILE = "/etc/snmp/routinator.json"
41+
42+
DEFAULTS = {
43+
"host": "127.0.0.1",
44+
"port": 8323,
45+
"path": "/api/v1/status",
46+
"url": None, # full override; built from host/port/path when None
47+
"timeout": 5,
48+
"include_failed_uris": True,
49+
"max_failed_uris": 25,
50+
}
51+
52+
# The five production trust anchors. We iterate over whatever the API returns,
53+
# but this guarantees a stable, complete set of keys even if a TAL is absent
54+
# from a given run (e.g. a fresh start or a test environment).
55+
TAL_NAMES = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
56+
57+
58+
def load_config():
59+
"""Return the effective config, merging the optional config file over the
60+
defaults. A missing file is fine; a malformed file raises."""
61+
cfg = dict(DEFAULTS)
62+
try:
63+
with open(CONFIGFILE, "r") as fh:
64+
cfg.update(json.load(fh))
65+
except FileNotFoundError:
66+
pass
67+
if not cfg.get("url"):
68+
cfg["url"] = "http://%s:%s%s" % (cfg["host"], cfg["port"], cfg["path"])
69+
return cfg
70+
71+
72+
def parse_ts(value):
73+
"""Parse a Routinator RFC3339 timestamp into an aware datetime.
74+
75+
Routinator emits nanosecond precision (e.g. ...:24.483161221+00:00) which
76+
datetime cannot handle, so trim the fractional part to microseconds. Returns
77+
None if the value is missing or unparseable."""
78+
if not value:
79+
return None
80+
value = value.strip()
81+
# Normalise a trailing Z to an explicit UTC offset.
82+
if value.endswith("Z"):
83+
value = value[:-1] + "+00:00"
84+
# Trim fractional seconds to at most 6 digits.
85+
value = re.sub(r"(\.\d{6})\d+", r"\1", value)
86+
try:
87+
return datetime.fromisoformat(value)
88+
except ValueError:
89+
return None
90+
91+
92+
def seconds_since(now, then):
93+
"""Whole seconds between two datetimes, or None if either is missing."""
94+
if now is None or then is None:
95+
return None
96+
return round((now - then).total_seconds())
97+
98+
99+
def num(value, default=0):
100+
"""Coerce a value to a number, falling back to default on None/garbage."""
101+
if isinstance(value, bool):
102+
return default
103+
if isinstance(value, (int, float)):
104+
return value
105+
return default
106+
107+
108+
def build_global(status, now):
109+
payload = status.get("payload", {}) or {}
110+
111+
def final(kind):
112+
return num((payload.get(kind) or {}).get("final"))
113+
114+
# VRPs delivered to routers = final route-origin payloads (v4 + v6). This
115+
# sums to the per-TAL vrps_final totals below.
116+
vrps_final = final("routeOriginsIPv4") + final("routeOriginsIPv6")
117+
118+
# Routinator has no single "stale objects" figure in the JSON API, so roll
119+
# up stale manifests + CRLs across all trust anchors.
120+
stale = 0
121+
for tal in (status.get("tals") or {}).values():
122+
stale += num(tal.get("staleManifests")) + num(tal.get("staleCRLs"))
123+
124+
return {
125+
"last_update_done": seconds_since(now, parse_ts(status.get("lastUpdateDone"))),
126+
"last_update_duration": num(status.get("lastUpdateDuration")),
127+
"serial": num(status.get("serial")),
128+
"stale_objects": stale,
129+
"vrps_final": vrps_final,
130+
}
131+
132+
133+
def build_repos(status, cfg):
134+
rrdp = status.get("rrdp") or {}
135+
rsync = status.get("rsync") or {}
136+
137+
rrdp_ok = rrdp_failed = rrdp_unreachable = 0
138+
rrdp_duration_max = 0.0
139+
rrdp_failed_uris = []
140+
for uri, entry in rrdp.items():
141+
code = num((entry or {}).get("status"), default=None)
142+
# 200 = fetched, 304 = not modified (healthy). -1 = server unreachable
143+
# (a distinct failure class). Anything else is a genuine HTTP failure.
144+
if code in (200, 304):
145+
rrdp_ok += 1
146+
elif code == -1:
147+
rrdp_unreachable += 1
148+
rrdp_failed_uris.append(uri)
149+
else:
150+
rrdp_failed += 1
151+
rrdp_failed_uris.append(uri)
152+
rrdp_duration_max = max(rrdp_duration_max, num((entry or {}).get("duration")))
153+
154+
rsync_failed = 0
155+
rsync_duration_max = 0.0
156+
rsync_failed_uris = []
157+
for uri, entry in rsync.items():
158+
# rsync exit code: 0 = success, non-zero = failure.
159+
if num((entry or {}).get("status")) != 0:
160+
rsync_failed += 1
161+
rsync_failed_uris.append(uri)
162+
rsync_duration_max = max(rsync_duration_max, num((entry or {}).get("duration")))
163+
164+
repos = {
165+
"rrdp_total": len(rrdp),
166+
"rrdp_ok": rrdp_ok,
167+
"rrdp_failed": rrdp_failed,
168+
"rrdp_unreachable": rrdp_unreachable,
169+
"rrdp_duration_max": round(rrdp_duration_max, 3),
170+
"rsync_total": len(rsync),
171+
"rsync_failed": rsync_failed,
172+
"rsync_duration_max": round(rsync_duration_max, 3),
173+
}
174+
175+
if cfg["include_failed_uris"]:
176+
cap = cfg["max_failed_uris"]
177+
repos["rrdp_failed_uris"] = sorted(rrdp_failed_uris)[:cap]
178+
repos["rsync_failed_uris"] = sorted(rsync_failed_uris)[:cap]
179+
180+
return repos
181+
182+
183+
def build_tal(status):
184+
tals = status.get("tals") or {}
185+
# Union of the known five and whatever the API actually returned.
186+
names = list(TAL_NAMES)
187+
for name in tals:
188+
if name not in names:
189+
names.append(name)
190+
191+
out = {}
192+
for name in names:
193+
tal = tals.get(name) or {}
194+
invalid = (
195+
num(tal.get("invalidManifests"))
196+
+ num(tal.get("invalidCRLs"))
197+
+ num(tal.get("invalidCerts"))
198+
+ num(tal.get("invalidROAs"))
199+
+ num(tal.get("invalidASPAs"))
200+
+ num(tal.get("invalidGBRs"))
201+
)
202+
out[name] = {
203+
"total_vrps": num(tal.get("vrpsFinal")),
204+
"valid_roas": num(tal.get("validROAs")),
205+
"pub_points_valid": num(tal.get("validPublicationPoints")),
206+
"pub_points_rejected": num(tal.get("rejectedPublicationPoints")),
207+
"objects_invalid": invalid,
208+
"roa_invalid": num(tal.get("invalidROAs")),
209+
"manifests_missing": num(tal.get("missingManifests")),
210+
"manifests_stale": num(tal.get("staleManifests")),
211+
}
212+
return out
213+
214+
215+
def build_rtr(status, serial, now):
216+
rtr = status.get("rtr") or {}
217+
out_rtr = {
218+
"current_connections": num(rtr.get("currentConnections")),
219+
"bytes_written": num(rtr.get("bytesWritten")),
220+
"bytes_read": num(rtr.get("bytesRead")),
221+
}
222+
223+
# Per-client metrics are only present when Routinator runs with
224+
# --rtr-client-metrics. Absent -> empty map, never an error.
225+
clients = {}
226+
for addr, client in (rtr.get("clients") or {}).items():
227+
client = client or {}
228+
client_serial = num(client.get("serial"))
229+
clients[addr] = {
230+
"connections": num(client.get("connections")),
231+
"serial": client_serial,
232+
"serial_lag": serial - client_serial,
233+
"last_update_seconds": seconds_since(now, parse_ts(client.get("updated"))),
234+
"reset_queries": num(client.get("resetQueries")),
235+
"serial_queries": num(client.get("serialQueries")),
236+
"written_bytes": num(client.get("written")),
237+
"read_bytes": num(client.get("read")),
238+
"last_reset_seconds": seconds_since(now, parse_ts(client.get("lastReset"))),
239+
}
240+
return out_rtr, clients
241+
242+
243+
def build_data(status, cfg):
244+
now = parse_ts(status.get("now")) or datetime.now(timezone.utc)
245+
serial = num(status.get("serial"))
246+
rtr, clients = build_rtr(status, serial, now)
247+
return {
248+
"global": build_global(status, now),
249+
"rtr": rtr,
250+
"repos": build_repos(status, cfg),
251+
"tal": build_tal(status),
252+
"client": clients,
253+
}
254+
255+
256+
def emit(output):
257+
"""Serialise the envelope, gzip + base64 it, and print to stdout. LibreNMS
258+
auto-detects and decodes this."""
259+
text = json.dumps(output)
260+
print(base64.b64encode(gzip.compress(text.encode("utf-8"))).decode("ascii"))
261+
262+
263+
def main():
264+
output = {"version": 1, "error": 0, "errorString": "", "data": {}}
265+
266+
try:
267+
cfg = load_config()
268+
except (ValueError, OSError) as err:
269+
output["error"] = 1
270+
output["errorString"] = "config error: %s" % err
271+
emit(output)
272+
return
273+
274+
# A failed fetch is itself the most important signal (Routinator's HTTP
275+
# server, or the whole process, is likely down). Emit the error envelope.
276+
try:
277+
with urllib.request.urlopen(cfg["url"], timeout=cfg["timeout"]) as resp:
278+
status = json.loads(resp.read().decode("utf-8"))
279+
except Exception as err: # noqa: BLE001 - any failure becomes the signal
280+
output["error"] = 1
281+
output["errorString"] = "fetch %s failed: %s" % (cfg["url"], err)
282+
emit(output)
283+
return
284+
285+
try:
286+
output["data"] = build_data(status, cfg)
287+
except Exception as err: # noqa: BLE001 - never emit partial/garbage JSON
288+
output["error"] = 2
289+
output["errorString"] = "parse error: %s" % err
290+
output["data"] = {}
291+
292+
emit(output)
293+
294+
295+
if __name__ == "__main__":
296+
sys.exit(main())

0 commit comments

Comments
 (0)