Skip to content

Commit 8648bad

Browse files
committed
feat(huawei): structured BGP route output
1 parent fd34bda commit 8648bad

13 files changed

Lines changed: 798 additions & 52 deletions

File tree

docs/pages/configuration/config/structured-output.mdx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@ Devices that support responding to a query with structured or easily parsable da
44

55
- Arista EOS
66
- Juniper Junos
7+
- Huawei VRP
78

89
When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods:
910

1011
1. From the router's perspective
11-
2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/)
12+
2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance
13+
14+
When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`.
1215

1316
Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user.
1417

1518
| Parameter | Type | Default Value | Description |
1619
| :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- |
17-
| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). |
18-
| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. |
19-
| `structured.communities.items` | List of Strings | | List of communities to match. |
20+
| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). |
21+
| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. |
22+
| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). |
23+
| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. |
24+
| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). |
25+
| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. |
2026

2127
### RPKI Examples
2228

@@ -28,12 +34,23 @@ structured:
2834
mode: router
2935
```
3036
31-
#### Show RPKI State from a Public/External Perspective
37+
#### Show RPKI State from a Public/External Perspective (Cloudflare)
3238
33-
```yaml filename="config.yaml" copy {2}
39+
```yaml filename="config.yaml" copy {2-3}
40+
structured:
41+
rpki:
42+
mode: external
43+
backend: cloudflare
44+
```
45+
46+
#### Validate RPKI State Against a Self-Hosted Routinator
47+
48+
```yaml filename="config.yaml" copy {2-5}
3449
structured:
3550
rpki:
3651
mode: external
52+
backend: routinator
53+
rpki_server_url: "http://routinator.example.net:8323"
3754
```
3855
3956
### Community Filtering Examples
@@ -59,3 +76,16 @@ structured:
5976
- "^65000:.*$" # permit any communities starting with 65000, but no others.
6077
- "1234:1" # permit only the 1234:1 community.
6178
```
79+
80+
#### Show Friendly Names Alongside Communities
81+
82+
With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as `<community> (<name>)` in the UI).
83+
84+
```yaml filename="config.yaml" {3-6}
85+
structured:
86+
communities:
87+
mode: name
88+
names:
89+
"65000:100": "Customer Route"
90+
"65000:200": "Peer Route"
91+
```

hyperglass/api/state.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,52 @@
11
"""hyperglass state dependencies."""
22

33
# Standard Library
4+
import asyncio
45
import typing as t
56

67
# Project
7-
from hyperglass.state import use_state
8+
from hyperglass.state import HyperglassState, use_state
9+
from hyperglass.exceptions.private import StateError
10+
11+
12+
def _is_retryable(attr: t.Optional[str]) -> bool:
13+
"""Whether a StateError for this attr is a transient (not-yet-populated) error.
14+
15+
A request can arrive before the Redis-backed state is populated on startup,
16+
which surfaces as a StateError. Referencing an attribute that does not exist
17+
on HyperglassState is a permanent programming error and must not be retried.
18+
"""
19+
return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties()
20+
21+
22+
async def _get_state_with_retry(
23+
attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5
24+
) -> t.Any:
25+
"""Get hyperglass state, retrying transient startup StateErrors."""
26+
for attempt in range(1, max_retries + 1):
27+
try:
28+
return use_state(attr)
29+
except StateError:
30+
if not _is_retryable(attr) or attempt == max_retries:
31+
raise
32+
await asyncio.sleep(retry_delay)
833

934

1035
async def get_state(attr: t.Optional[str] = None):
1136
"""Get hyperglass state as a FastAPI dependency."""
12-
return use_state(attr)
37+
return await _get_state_with_retry(attr)
1338

1439

1540
async def get_params():
1641
"""Get hyperglass params as FastAPI dependency."""
17-
return use_state("params")
42+
return await _get_state_with_retry("params")
1843

1944

2045
async def get_devices():
2146
"""Get hyperglass devices as FastAPI dependency."""
22-
return use_state("devices")
47+
return await _get_state_with_retry("devices")
2348

2449

2550
async def get_ui_params():
2651
"""Get hyperglass ui_params as FastAPI dependency."""
27-
return use_state("ui_params")
52+
return await _get_state_with_retry("ui_params")

hyperglass/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos")
2121

22-
SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos")
22+
SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos", "huawei")
2323

2424
CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml")
2525

hyperglass/defaults/directives/huawei.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"Huawei_BGPRoute",
1616
"Huawei_Ping",
1717
"Huawei_Traceroute",
18+
"HuaweiBGPRouteTable",
19+
"HuaweiBGPASPathTable",
20+
"HuaweiBGPCommunityTable",
1821
)
1922

2023
NAME = "Huawei VRP"
@@ -36,7 +39,8 @@
3639
),
3740
],
3841
field=Text(description="IP Address, Prefix, or Hostname"),
39-
plugins=["bgp_route_huawei"],
42+
plugins=["bgp_route_huawei", "bgp_routestr_huawei"],
43+
table_output="__hyperglass_huawei_bgp_route_table__",
4044
platforms=PLATFORMS,
4145
)
4246

@@ -54,6 +58,7 @@
5458
)
5559
],
5660
field=Text(description="AS Path Regular Expression"),
61+
table_output="__hyperglass_huawei_bgp_aspath_table__",
5762
platforms=PLATFORMS,
5863
)
5964

@@ -71,6 +76,7 @@
7176
)
7277
],
7378
field=Text(description="BGP Community String"),
79+
table_output="__hyperglass_huawei_bgp_community_table__",
7480
platforms=PLATFORMS,
7581
)
7682

@@ -111,3 +117,58 @@
111117
field=Text(description="IP Address, Prefix, or Hostname"),
112118
platforms=PLATFORMS,
113119
)
120+
121+
# Table Output Directives
122+
123+
HuaweiBGPRouteTable = BuiltinDirective(
124+
id="__hyperglass_huawei_bgp_route_table__",
125+
name="BGP Route",
126+
rules=[
127+
RuleWithIPv4(
128+
condition="0.0.0.0/0",
129+
action="permit",
130+
command="display bgp routing-table {target} | no-more",
131+
),
132+
RuleWithIPv6(
133+
condition="::/0",
134+
action="permit",
135+
command="display bgp ipv6 routing-table {target} | no-more",
136+
),
137+
],
138+
field=Text(description="IP Address, Prefix, or Hostname"),
139+
platforms=PLATFORMS,
140+
)
141+
142+
HuaweiBGPASPathTable = BuiltinDirective(
143+
id="__hyperglass_huawei_bgp_aspath_table__",
144+
name="BGP AS Path",
145+
rules=[
146+
RuleWithPattern(
147+
condition="*",
148+
action="permit",
149+
commands=[
150+
'display bgp routing-table regular-expression "{target}"',
151+
'display bgp ipv6 routing-table regular-expression "{target}"',
152+
],
153+
)
154+
],
155+
field=Text(description="AS Path Regular Expression"),
156+
platforms=PLATFORMS,
157+
)
158+
159+
HuaweiBGPCommunityTable = BuiltinDirective(
160+
id="__hyperglass_huawei_bgp_community_table__",
161+
name="BGP Community",
162+
rules=[
163+
RuleWithPattern(
164+
condition="*",
165+
action="permit",
166+
commands=[
167+
'display bgp routing-table community "{target}"',
168+
'display bgp ipv6 routing-table community "{target}"',
169+
],
170+
)
171+
],
172+
field=Text(description="BGP Community String"),
173+
platforms=PLATFORMS,
174+
)

hyperglass/external/rpki.py

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,57 +3,83 @@
33
# Standard Library
44
import typing as t
55

6+
# Third Party
7+
import httpx
8+
69
# Project
710
from hyperglass.log import log
811
from hyperglass.state import use_state
912
from hyperglass.external._base import BaseExternal
1013

1114
if t.TYPE_CHECKING:
12-
# Standard Library
1315
from ipaddress import IPv4Address, IPv6Address
1416

15-
RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3}
16-
RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()}
17+
# Maps normalized (lower-case, separators stripped) backend state names to the
18+
# integer states hyperglass uses internally.
19+
RPKI_STATE_MAP = {
20+
"invalid": 0,
21+
"valid": 1,
22+
"notfound": 2,
23+
"unknown": 2,
24+
"default": 3,
25+
}
26+
# Canonical integer -> display name, kept stable for logging.
27+
RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"}
1728
CACHE_KEY = "hyperglass.external.rpki"
1829

1930

20-
def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int:
31+
def _normalize_state(value: str) -> int:
32+
"""Normalize a backend RPKI state string to an internal integer state."""
33+
key = str(value).strip().lower().replace("-", "").replace("_", "")
34+
return RPKI_STATE_MAP.get(key, 3)
35+
36+
37+
def rpki_state(
38+
prefix: t.Union["IPv4Address", "IPv6Address", str],
39+
asn: t.Union[int, str],
40+
backend: str = "cloudflare",
41+
rpki_server_url: str = "",
42+
) -> int:
2143
"""Get RPKI state and map to expected integer."""
2244
_log = log.bind(prefix=prefix, asn=asn)
2345
_log.debug("Validating RPKI State")
2446

2547
cache = use_state("cache")
26-
2748
state = 3
2849
ro = f"{prefix!s}@{asn!s}"
2950

3051
cached = cache.get_map(CACHE_KEY, ro)
31-
3252
if cached is not None:
3353
state = cached
3454
else:
35-
ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}'
36-
query = ql.format(prefix, asn)
37-
_log.bind(query=query).debug("Cloudflare RPKI GraphQL Query")
3855
try:
39-
with BaseExternal(base_url="https://rpki.cloudflare.com") as client:
40-
response = client._post("/api/graphql", data={"query": query})
41-
try:
56+
if backend == "cloudflare":
57+
ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}'
58+
query = ql.format(prefix, asn)
59+
_log.bind(query=query).debug("Cloudflare RPKI GraphQL Query")
60+
with BaseExternal(base_url="https://rpki.cloudflare.com") as client:
61+
response = client._post("/api/graphql", data={"query": query})
4262
validation_state = response["data"]["validation"]["state"]
43-
except KeyError as missing:
44-
_log.error("Response from Cloudflare missing key '{}': {!r}", missing, response)
45-
validation_state = 3
63+
elif backend == "routinator":
64+
url = f"{rpki_server_url.rstrip('/')}/validity"
65+
_log.bind(url=url).debug("Routinator RPKI HTTP Query")
66+
response = httpx.get(
67+
url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5
68+
)
69+
response.raise_for_status()
70+
data = response.json()
71+
validation_state = data["validated_route"]["validity"]["state"]
72+
else:
73+
raise ValueError(f"Unknown RPKI backend: {backend}")
4674

47-
state = RPKI_STATE_MAP[validation_state]
75+
state = _normalize_state(validation_state)
4876
cache.set_map_item(CACHE_KEY, ro, state)
4977
except Exception as err:
5078
log.error(err)
51-
# Don't cache the state when an error produced it.
5279
state = 3
5380

5481
msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state])
5582
if cached is not None:
5683
msg += " [CACHED]"
57-
5884
log.debug(msg)
5985
return state

hyperglass/external/tests/test_rpki.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ def test_rpki():
1919
result = rpki_state(prefix, asn)
2020
result_name = RPKI_NAME_MAP.get(result, "No Name")
2121
expected_name = RPKI_NAME_MAP.get(expected, "No Name")
22-
assert result == expected, (
23-
"RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format(
24-
prefix, asn, result, result_name, expected, expected_name
25-
)
22+
assert (
23+
result == expected
24+
), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format(
25+
prefix, asn, result, result_name, expected, expected_name
2626
)

0 commit comments

Comments
 (0)