Skip to content

Commit 997531c

Browse files
committed
fix(mikrotik): support RouterOS 7.21 wrapped/commented BGP output
RouterOS 7.21 emits BGP route detail differently over the looking-glass SSH driver, which broke the structured parser: - Active/best routes learned from a commented peer put the flag column and the ";;; comment" on a line of their own (afi=ip wraps to the next line), so the "A" flag was lost and no best route was highlighted. Derive active/filtered from the always-present "contribution=" field instead, falling back to the flag column for ROS v6. - Long ".communities" lists wrap across terminal-width lines; everything after the wrap was silently dropped. De-wrap comma-separated lists before tokenizing. - The garbage cleaner stripped any legend-looking line without "=", which also ate commented route flag lines. Match the "<char> - " legend shape instead. - Parse the v7 dotted ".ext-communities" key. Also restore the empty-BGP-table retry (4 attempts, 10s delay, MikroTik BGP queries only) that re-runs when RouterOS answers before its routing table has finished assembling. Test fixtures use RFC 5737 (IPv4) documentation addresses and RFC 5398 example ASNs only; no real network data.
1 parent 9189b98 commit 997531c

4 files changed

Lines changed: 206 additions & 64 deletions

File tree

hyperglass/api/routes.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Standard Library
44
import json
55
import time
6+
import asyncio
67
import typing as t
78
from datetime import UTC, datetime
89

@@ -36,6 +37,57 @@
3637
"query",
3738
)
3839

40+
# MikroTik occasionally answers a BGP query before its routing table has finished
41+
# assembling, returning an empty route table on the first attempt. For MikroTik
42+
# BGP queries only, retry a few times (with a delay) while the result is empty.
43+
_MIKROTIK_PLATFORMS = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik")
44+
_MIKROTIK_BGP_MAX_ATTEMPTS = 4
45+
_MIKROTIK_BGP_RETRY_DELAY = 10 # seconds
46+
47+
48+
def _is_empty_bgp_table(output: t.Any) -> bool:
49+
"""True if output is a structured BGP table containing no routes."""
50+
if not is_type(output, OutputDataModel):
51+
return False
52+
try:
53+
raw = json.loads(output.export_json())
54+
except Exception:
55+
return False
56+
return (
57+
isinstance(raw, dict)
58+
and raw.get("count", None) == 0
59+
and not raw.get("routes")
60+
)
61+
62+
63+
async def _execute_query(data: "Query") -> t.Any:
64+
"""Execute a query, retrying MikroTik BGP queries that return an empty table."""
65+
directive_name = getattr(getattr(data, "directive", None), "name", "") or ""
66+
is_bgp_query = "bgp_" in data.query_type or "bgp" in directive_name.lower()
67+
is_mikrotik_bgp = data.device.platform in _MIKROTIK_PLATFORMS and is_bgp_query
68+
max_attempts = _MIKROTIK_BGP_MAX_ATTEMPTS if is_mikrotik_bgp else 1
69+
70+
_log = log.bind(directive=data.query_type, device=data.device.name)
71+
if is_mikrotik_bgp:
72+
_log.bind(max_attempts=max_attempts, retry_delay=_MIKROTIK_BGP_RETRY_DELAY).debug(
73+
"MikroTik BGP empty-table retry enabled"
74+
)
75+
76+
output = None
77+
for attempt in range(1, max_attempts + 1):
78+
if attempt > 1:
79+
_log.bind(attempt=attempt, max_attempts=max_attempts).warning(
80+
"MikroTik returned empty BGP table - retrying after {}s", _MIKROTIK_BGP_RETRY_DELAY
81+
)
82+
await asyncio.sleep(_MIKROTIK_BGP_RETRY_DELAY)
83+
84+
output = await execute(data)
85+
86+
if not (is_mikrotik_bgp and attempt < max_attempts and _is_empty_bgp_table(output)):
87+
break
88+
89+
return output
90+
3991

4092
@get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)})
4193
async def device(devices: Devices, id: str) -> APIDevice:
@@ -107,8 +159,8 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query
107159
structured=data.device.structured_output or False,
108160
)
109161
else:
110-
# Pass request to execution module
111-
output = await execute(data)
162+
# Pass request to execution module (retries empty MikroTik BGP tables)
163+
output = await _execute_query(data)
112164

113165
endtime = time.time()
114166
elapsedtime = round(endtime - starttime, 4)

hyperglass/models/parsing/mikrotik.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,21 @@ def peer_rid(self) -> str:
126126

127127

128128
def _extract_paths(lines: t.List[str]) -> MikrotikPaths:
129-
"""Simple count based on lines with dst/dst-address and preferred 'A' flag."""
129+
"""Count available/best paths.
130+
131+
Prefers the RouterOS v7 ``contribution=active`` marker (which sits on the same
132+
line as ``dst-address``) and falls back to the legacy ``A`` flag for ROS v6.
133+
"""
130134
available = 0
131135
best = 0
132136
for raw in lines:
133137
if ("dst-address=" in raw) or (" dst=" in f" {raw} "):
134138
available += 1
135-
m = FLAGS_RE.match(raw)
136-
if m:
137-
flags = set(m.group(1))
138-
if "A" in flags:
139+
if "contribution=active" in raw:
140+
best += 1
141+
elif "contribution=" not in raw:
142+
m = FLAGS_RE.match(raw)
143+
if m and "A" in set(m.group(1)):
139144
best += 1
140145
return MikrotikPaths(available=available, best=best, select=best)
141146

@@ -176,14 +181,19 @@ def _process_kv(route: dict, key: str, val: str):
176181
elif key in (".large-communities", "large-communities", "bgp-large-communities"):
177182
if val and val.lower() != "none":
178183
route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()]
179-
elif key == "bgp-ext-communities":
184+
elif key in (".ext-communities", "ext-communities", "bgp-ext-communities"):
180185
if val and val.lower() != "none":
181186
route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()]
182187
elif key == "rpki":
183188
clean_val = val.strip().strip('"').lower()
184189
route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2)
185190
elif key == "belongs-to":
186191
route["belongs_to"] = val
192+
elif key == "contribution":
193+
# ROS v7 selection state: active | candidate | filtered. This is the
194+
# authoritative active/filtered signal (see _parse_route_block); the
195+
# legacy flag column is only used as a fallback for ROS v6.
196+
route["contribution"] = val.strip().lower()
187197

188198

189199
def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]:
@@ -229,7 +239,13 @@ def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]:
229239
if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ":
230240
return None
231241

232-
flags = ""
242+
# RouterOS wraps long output at the terminal width, splitting unquoted
243+
# comma-separated lists (.communities / .large-communities) across physical
244+
# lines. Joining with a space above turns the wrap into ", " inside the list;
245+
# collapse it back so the whole list is captured as a single token. RouterOS
246+
# never emits ", " within a route value otherwise, so this is safe.
247+
full_block_text = re.sub(r",\s+", ",", full_block_text)
248+
233249
rd = {
234250
"prefix": "",
235251
"gateway": "",
@@ -249,25 +265,27 @@ def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]:
249265
"rpki_state": RPKI_STATE_MAP.get("unknown", 2),
250266
}
251267

252-
# Interpret flags in the first line:
253-
# - "A" => active (preferred)
254-
# - otherwise not active
268+
# Find all key=value tokens in the entire block
269+
for k, v in TOKEN_RE.findall(full_block_text):
270+
_process_kv(rd, k, v)
271+
272+
# Determine active/filtered state. Prefer the ROS v7 "contribution" marker,
273+
# which always appears in the key=value data (robust to comment lines and to
274+
# the flag column being wrapped/stripped). Fall back to the legacy flag
275+
# column ("A" active; F/U/X filtered) for ROS v6, which has no contribution.
276+
contribution = rd.pop("contribution", "")
255277
m = FLAGS_RE.match(block[0])
256-
if m:
257-
flags = m.group(1)
278+
flags = m.group(1) if m else ""
279+
if contribution:
280+
rd["is_active"] = contribution == "active"
281+
rd["is_filtered"] = contribution == "filtered"
282+
elif flags:
258283
flag_set = set(flags)
259-
is_active = "A" in flag_set
260-
is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"})
261-
262-
if is_active:
284+
if "A" in flag_set:
263285
rd["is_active"] = True
264286
rd["is_filtered"] = False
265287
else:
266-
rd["is_filtered"] = is_filtered
267-
268-
# Find all key=value tokens in the entire block
269-
for k, v in TOKEN_RE.findall(full_block_text):
270-
_process_kv(rd, k, v)
288+
rd["is_filtered"] = bool(flag_set & {"F", "f", "U", "u", "X", "x"})
271289

272290
if rd["prefix"]:
273291
try:

hyperglass/plugins/_builtin/mikrotik_garbage_output.py

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
from hyperglass.models.api.query import Query
2020

2121

22+
# Matches a RouterOS flag-legend line, e.g. "c - connect, s - static" or
23+
# "H - hw-offloaded; + - ecmp". Deliberately anchored to "<char> - " so it never
24+
# matches a route line such as "b ;;; comment" or "Ab afi=ip ..." (whose flag
25+
# column is followed by whitespace, not " - ").
26+
LEGEND_LINE_RE = re.compile(r"^[A-Za-z+]\s-\s")
27+
28+
2229
class MikrotikGarbageOutput(OutputPlugin):
2330
"""Parse Mikrotik output to remove garbage before structured parsing."""
2431

@@ -84,21 +91,14 @@ def _clean_traceroute_output(self, raw_output: str) -> str:
8491
if not tables:
8592
# Fallback to previous behavior: remove prompts and flags
8693
filtered_lines: t.List[str] = []
87-
in_flags_section = False
8894
for line in lines:
8995
stripped_line = line.strip()
9096
if stripped_line.startswith("@") and stripped_line.endswith("] >"):
9197
continue
9298
if "[Q quit|D dump|C-z pause]" in stripped_line:
9399
continue
94-
if stripped_line.startswith("Flags:"):
95-
in_flags_section = True
100+
if stripped_line.startswith("Flags:") or LEGEND_LINE_RE.match(stripped_line):
96101
continue
97-
if in_flags_section:
98-
if "=" in stripped_line:
99-
in_flags_section = False
100-
else:
101-
continue
102102
filtered_lines.append(line)
103103
return "\n".join(filtered_lines)
104104

@@ -223,7 +223,6 @@ def process(self, *, output: OutputType, query: "Query") -> Series[str]:
223223
# Original logic for other outputs (BGP routes, etc.)
224224
lines = raw_output.splitlines()
225225
filtered_lines = []
226-
in_flags_section = False
227226

228227
for line in lines:
229228
stripped_line = line.strip()
@@ -236,17 +235,13 @@ def process(self, *, output: OutputType, query: "Query") -> Series[str]:
236235
if "[Q quit|D dump|C-z pause]" in stripped_line:
237236
continue
238237

239-
# Begin detecting the Flags section
240-
if stripped_line.startswith("Flags:"):
241-
in_flags_section = True
242-
continue # Skip the "Flags:" line itself
243-
244-
# Within the flags section, check whether the line is still part of it
245-
if in_flags_section:
246-
if "=" in stripped_line:
247-
in_flags_section = False
248-
else:
249-
continue # Skip the flag-legend lines
238+
# Skip the flag legend: the "Flags:" header and its continuation
239+
# lines ("c - connect, ...", "H - hw-offloaded; + - ecmp, ...").
240+
# Matching the "<char> - " shape (rather than "no '=' present")
241+
# ensures a commented route line like "b ;;; peer-name" — which
242+
# also has no "=" — is preserved instead of being dropped.
243+
if stripped_line.startswith("Flags:") or LEGEND_LINE_RE.match(stripped_line):
244+
continue
250245

251246
filtered_lines.append(line)
252247

hyperglass/plugins/tests/test_bgp_route_mikrotik.py

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""MikroTik structured BGP route parsing tests.
22
3-
The sample below is a trimmed, real `routing route print detail` capture from a
4-
RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route.
5-
Long community lists are kept on a single line, matching what the device emits
6-
to the looking-glass SSH driver (no terminal-width wrapping).
3+
The samples below are synthetic captures shaped exactly like RouterOS
4+
`routing route print detail` output, but scrubbed of any real network data: all
5+
addresses use the RFC 5737 (IPv4) documentation ranges, autonomous-system
6+
numbers use the RFC 5398 documentation range (64496-64511), and peer/interface
7+
names are generic. Only the *structure* of the output is real, which is all the
8+
parser cares about.
79
"""
810

911
# flake8: noqa
@@ -22,16 +24,18 @@
2224
if t.TYPE_CHECKING:
2325
from hyperglass.state import HyperglassState
2426

27+
# One active and one filtered route, each emitted on a single (unwrapped) line,
28+
# as a wide terminal would produce.
2529
SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active;
2630
c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn;
2731
H - hw-offloaded; + - ecmp, B - blackhole
28-
Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198
29-
immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198"
30-
rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp
32+
Ab afi=ip contribution=active dst-address=203.0.113.0/24 routing-table=main pref-src=192.0.2.1 gateway=198.51.100.1
33+
immediate-gw=198.51.100.1%[ether1] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-198.51.100.1"
34+
rpki=valid bgp.session=peer-ix-1 .as-path="64496" .communities=64496:10045 .large-communities=64500:2000:0,64500:2001:0 .local-pref=250 .med=0 .origin=igp
3135
32-
Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2
33-
immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2"
34-
rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete
36+
Fb afi=ip contribution=filtered dst-address=198.51.100.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2
37+
immediate-gw=192.0.2.2%[ether2] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2"
38+
rpki=invalid bgp.session=transit-1 .as-path="64497,64498,64499,64500,64501" .communities=64497:14100,64497:2000 .med=0 .origin=incomplete
3539
"""
3640

3741

@@ -58,21 +62,94 @@ def test_mikrotik_bgp_route(state):
5862
assert table.count == 2, f"Expected 2 routes, got {table.count}"
5963

6064
by_prefix = {r.prefix: r for r in table.routes}
61-
assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"}
65+
assert set(by_prefix) == {"203.0.113.0/24", "198.51.100.0/24"}
6266

63-
# Active route (flag "A"): RPKI valid (state 1), single-ASN path.
64-
active = by_prefix["1.0.0.0/24"]
67+
# Active route (contribution=active): RPKI valid (state 1), single-ASN path.
68+
active = by_prefix["203.0.113.0/24"]
6569
assert active.active is True
6670
assert active.filtered is False
67-
assert active.as_path == [13335]
68-
assert active.next_hop == "196.60.8.198"
69-
assert "13335:10045" in active.communities
71+
assert active.as_path == [64496]
72+
assert active.next_hop == "198.51.100.1"
73+
assert "64496:10045" in active.communities
7074
assert active.rpki_state == 1 # valid
7175

72-
# Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI
73-
# invalid (state 0), multi-ASN path.
74-
filtered = by_prefix["5.101.88.0/24"]
76+
# Filtered route (contribution=filtered): RPKI invalid (state 0), multi-ASN path.
77+
filtered = by_prefix["198.51.100.0/24"]
7578
assert filtered.active is False
76-
assert filtered.filtered is True, "F-flagged route should be marked filtered"
77-
assert filtered.as_path == [37468, 41095, 51601, 50113, 207569]
79+
assert filtered.filtered is True, "filtered route should be marked filtered"
80+
assert filtered.as_path == [64497, 64498, 64499, 64500, 64501]
7881
assert filtered.rpki_state == 0 # invalid
82+
83+
84+
# Wrapped/commented capture as RouterOS 7.21 emits it over the looking-glass SSH
85+
# driver. Two properties that broke the legacy parser are exercised here:
86+
# 1. The *active* route is learned from a commented peer, so RouterOS emits the
87+
# flag column ("Ab") and the ";;; comment" on a line of their own, with
88+
# "afi=ip" wrapped to the next line. Active state must come from
89+
# "contribution=active", not the (now-separated) flag column.
90+
# 2. A long ".communities" list is wrapped across two terminal-width lines; all
91+
# members must survive (no truncation at the wrap point).
92+
# 3. An unquoted interface description containing spaces ("vlan10 CORE LINK")
93+
# must not swallow the following key=value tokens.
94+
WRAPPED_SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active;
95+
c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn;
96+
H - hw-offloaded; + - ecmp, B - blackhole
97+
Ab ;;; peer-ix-1
98+
afi=ip
99+
contribution=active dst-address=203.0.113.0/24 routing-table=main
100+
pref-src=192.0.2.1 gateway=198.51.100.1
101+
immediate-gw=198.51.100.1%[ether1] IX PEERING
102+
distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-198.51.100.1"
103+
rpki=valid bgp.session=peer-ix-1
104+
.aggregator="64496:192.0.2.1" .as-path="64496"
105+
.communities=64496:100,64496:200,64496:300,64497:100,64497:200,
106+
64497:300,64498:100,64498:200,64498:300
107+
.large-communities=64500:2000:0,64500:2001:0 .local-pref=250 .origin=igp
108+
debug.fwp-ptr=0x00000000
109+
110+
b afi=ip contribution=candidate dst-address=203.0.113.0/24 routing-table=main
111+
pref-src=192.0.2.1 gateway=192.0.2.10
112+
immediate-gw=192.0.2.10%vlan10 CORE LINK distance=200 scope=40
113+
target-scope=30 belongs-to="bgp-IP-192.0.2.10"
114+
bgp.session=core-edge-1 .as-path="64496" .communities=64496:400
115+
.local-pref=250 .origin=igp
116+
debug.fwp-ptr=0x00000001
117+
"""
118+
119+
120+
def test_mikrotik_bgp_route_wrapped_commented(state):
121+
"""Regression: RouterOS 7.21 wrapped output with a commented active route."""
122+
parsed = MikrotikBGPTable.parse_text(WRAPPED_SAMPLE)
123+
124+
# Exactly one active (best) route must be detected, via contribution=active,
125+
# even though its flag column sits on a separate ";;; comment" line.
126+
active_entries = [r for r in parsed.routes if r.is_active]
127+
assert len(active_entries) == 1, "commented active route must still be detected"
128+
active = active_entries[0]
129+
assert active.gateway == "198.51.100.1"
130+
131+
# The wrapped 9-member community list must be captured in full, not truncated
132+
# at the terminal-width wrap point.
133+
assert active.communities == [
134+
"64496:100",
135+
"64496:200",
136+
"64496:300",
137+
"64497:100",
138+
"64497:200",
139+
"64497:300",
140+
"64498:100",
141+
"64498:200",
142+
"64498:300",
143+
]
144+
145+
# The candidate route's unquoted interface description ("vlan10 CORE LINK")
146+
# must not have swallowed the following key=value tokens.
147+
candidate = [r for r in parsed.routes if not r.is_active][0]
148+
assert candidate.gateway == "192.0.2.10"
149+
assert candidate.local_preference == 250
150+
assert candidate.communities == ["64496:400"]
151+
152+
# The full conversion to the canonical BGP table must still succeed.
153+
table = parsed.bgp_table()
154+
assert isinstance(table, BGPRouteTable)
155+
assert table.count == 2, f"Expected 2 routes, got {table.count}"

0 commit comments

Comments
 (0)