Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions docs/pages/configuration/config/structured-output.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ Devices that support responding to a query with structured or easily parsable da

- Arista EOS
- Juniper Junos
- Huawei VRP

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

1. From the router's perspective
2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/)
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

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`.

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

| Parameter | Type | Default Value | Description |
| :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- |
| `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). |
| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. |
| `structured.communities.items` | List of Strings | | List of communities to match. |
| `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). |
| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. |
| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). |
| `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`. |
| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). |
| `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`. |

### RPKI Examples

Expand All @@ -28,12 +34,23 @@ structured:
mode: router
```

#### Show RPKI State from a Public/External Perspective
#### Show RPKI State from a Public/External Perspective (Cloudflare)

```yaml filename="config.yaml" copy {2}
```yaml filename="config.yaml" copy {2-3}
structured:
rpki:
mode: external
backend: cloudflare
```

#### Validate RPKI State Against a Self-Hosted Routinator

```yaml filename="config.yaml" copy {2-5}
structured:
rpki:
mode: external
backend: routinator
rpki_server_url: "http://routinator.example.net:8323"
```

### Community Filtering Examples
Expand All @@ -59,3 +76,16 @@ structured:
- "^65000:.*$" # permit any communities starting with 65000, but no others.
- "1234:1" # permit only the 1234:1 community.
```

#### Show Friendly Names Alongside Communities

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).

```yaml filename="config.yaml" {3-6}
structured:
communities:
mode: name
names:
"65000:100": "Customer Route"
"65000:200": "Peer Route"
```
35 changes: 30 additions & 5 deletions hyperglass/api/state.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
"""hyperglass state dependencies."""

# Standard Library
import asyncio
import typing as t

# Project
from hyperglass.state import use_state
from hyperglass.state import HyperglassState, use_state
from hyperglass.exceptions.private import StateError


def _is_retryable(attr: t.Optional[str]) -> bool:
"""Whether a StateError for this attr is a transient (not-yet-populated) error.

A request can arrive before the Redis-backed state is populated on startup,
which surfaces as a StateError. Referencing an attribute that does not exist
on HyperglassState is a permanent programming error and must not be retried.
"""
return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties()


async def _get_state_with_retry(
attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5
) -> t.Any:
"""Get hyperglass state, retrying transient startup StateErrors."""
for attempt in range(1, max_retries + 1):
try:
return use_state(attr)
except StateError:
if not _is_retryable(attr) or attempt == max_retries:
raise
await asyncio.sleep(retry_delay)


async def get_state(attr: t.Optional[str] = None):
"""Get hyperglass state as a FastAPI dependency."""
return use_state(attr)
return await _get_state_with_retry(attr)


async def get_params():
"""Get hyperglass params as FastAPI dependency."""
return use_state("params")
return await _get_state_with_retry("params")


async def get_devices():
"""Get hyperglass devices as FastAPI dependency."""
return use_state("devices")
return await _get_state_with_retry("devices")


async def get_ui_params():
"""Get hyperglass ui_params as FastAPI dependency."""
return use_state("ui_params")
return await _get_state_with_retry("ui_params")
2 changes: 1 addition & 1 deletion hyperglass/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos")

SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos")
SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos", "huawei")

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

Expand Down
63 changes: 62 additions & 1 deletion hyperglass/defaults/directives/huawei.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"Huawei_BGPRoute",
"Huawei_Ping",
"Huawei_Traceroute",
"HuaweiBGPRouteTable",
"HuaweiBGPASPathTable",
"HuaweiBGPCommunityTable",
)

NAME = "Huawei VRP"
Expand All @@ -36,7 +39,8 @@
),
],
field=Text(description="IP Address, Prefix, or Hostname"),
plugins=["bgp_route_huawei"],
plugins=["bgp_route_huawei", "bgp_routestr_huawei"],
table_output="__hyperglass_huawei_bgp_route_table__",
platforms=PLATFORMS,
)

Expand All @@ -54,6 +58,7 @@
)
],
field=Text(description="AS Path Regular Expression"),
table_output="__hyperglass_huawei_bgp_aspath_table__",
platforms=PLATFORMS,
)

Expand All @@ -71,6 +76,7 @@
)
],
field=Text(description="BGP Community String"),
table_output="__hyperglass_huawei_bgp_community_table__",
platforms=PLATFORMS,
)

Expand Down Expand Up @@ -111,3 +117,58 @@
field=Text(description="IP Address, Prefix, or Hostname"),
platforms=PLATFORMS,
)

# Table Output Directives

HuaweiBGPRouteTable = BuiltinDirective(
id="__hyperglass_huawei_bgp_route_table__",
name="BGP Route",
rules=[
RuleWithIPv4(
condition="0.0.0.0/0",
action="permit",
command="display bgp routing-table {target} | no-more",
),
RuleWithIPv6(
condition="::/0",
action="permit",
command="display bgp ipv6 routing-table {target} | no-more",
),
],
field=Text(description="IP Address, Prefix, or Hostname"),
platforms=PLATFORMS,
)

HuaweiBGPASPathTable = BuiltinDirective(
id="__hyperglass_huawei_bgp_aspath_table__",
name="BGP AS Path",
rules=[
RuleWithPattern(
condition="*",
action="permit",
commands=[
'display bgp routing-table regular-expression "{target}"',
'display bgp ipv6 routing-table regular-expression "{target}"',
],
)
],
field=Text(description="AS Path Regular Expression"),
platforms=PLATFORMS,
)

HuaweiBGPCommunityTable = BuiltinDirective(
id="__hyperglass_huawei_bgp_community_table__",
name="BGP Community",
rules=[
RuleWithPattern(
condition="*",
action="permit",
commands=[
'display bgp routing-table community "{target}"',
'display bgp ipv6 routing-table community "{target}"',
],
)
],
field=Text(description="BGP Community String"),
platforms=PLATFORMS,
)
62 changes: 44 additions & 18 deletions hyperglass/external/rpki.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,83 @@
# Standard Library
import typing as t

# Third Party
import httpx

# Project
from hyperglass.log import log
from hyperglass.state import use_state
from hyperglass.external._base import BaseExternal

if t.TYPE_CHECKING:
# Standard Library
from ipaddress import IPv4Address, IPv6Address

RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3}
RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()}
# Maps normalized (lower-case, separators stripped) backend state names to the
# integer states hyperglass uses internally.
RPKI_STATE_MAP = {
"invalid": 0,
"valid": 1,
"notfound": 2,
"unknown": 2,
"default": 3,
}
# Canonical integer -> display name, kept stable for logging.
RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"}
CACHE_KEY = "hyperglass.external.rpki"


def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int:
def _normalize_state(value: str) -> int:
"""Normalize a backend RPKI state string to an internal integer state."""
key = str(value).strip().lower().replace("-", "").replace("_", "")
return RPKI_STATE_MAP.get(key, 3)


def rpki_state(
prefix: t.Union["IPv4Address", "IPv6Address", str],
asn: t.Union[int, str],
backend: str = "cloudflare",
rpki_server_url: str = "",
) -> int:
"""Get RPKI state and map to expected integer."""
_log = log.bind(prefix=prefix, asn=asn)
_log.debug("Validating RPKI State")

cache = use_state("cache")

state = 3
ro = f"{prefix!s}@{asn!s}"

cached = cache.get_map(CACHE_KEY, ro)

if cached is not None:
state = cached
else:
ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}'
query = ql.format(prefix, asn)
_log.bind(query=query).debug("Cloudflare RPKI GraphQL Query")
try:
with BaseExternal(base_url="https://rpki.cloudflare.com") as client:
response = client._post("/api/graphql", data={"query": query})
try:
if backend == "cloudflare":
ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}'
query = ql.format(prefix, asn)
_log.bind(query=query).debug("Cloudflare RPKI GraphQL Query")
with BaseExternal(base_url="https://rpki.cloudflare.com") as client:
response = client._post("/api/graphql", data={"query": query})
validation_state = response["data"]["validation"]["state"]
except KeyError as missing:
_log.error("Response from Cloudflare missing key '{}': {!r}", missing, response)
validation_state = 3
elif backend == "routinator":
url = f"{rpki_server_url.rstrip('/')}/validity"
_log.bind(url=url).debug("Routinator RPKI HTTP Query")
response = httpx.get(
url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5
)
response.raise_for_status()
data = response.json()
validation_state = data["validated_route"]["validity"]["state"]
else:
raise ValueError(f"Unknown RPKI backend: {backend}")

state = RPKI_STATE_MAP[validation_state]
state = _normalize_state(validation_state)
cache.set_map_item(CACHE_KEY, ro, state)
except Exception as err:
log.error(err)
# Don't cache the state when an error produced it.
state = 3

msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state])
if cached is not None:
msg += " [CACHED]"

log.debug(msg)
return state
8 changes: 4 additions & 4 deletions hyperglass/external/tests/test_rpki.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def test_rpki():
result = rpki_state(prefix, asn)
result_name = RPKI_NAME_MAP.get(result, "No Name")
expected_name = RPKI_NAME_MAP.get(expected, "No Name")
assert result == expected, (
"RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format(
prefix, asn, result, result_name, expected, expected_name
)
assert (
result == expected
), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format(
prefix, asn, result, result_name, expected, expected_name
)
Loading