From eabd98b606b03f0e3b8882cb8feaa5a260d50a78 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Fri, 26 Sep 2025 11:32:29 +0200 Subject: [PATCH] feat: Add BGP community friendly names and enhance RPKI configuration - Add new 'name' mode for BGP communities to append friendly names - New configuration option `structured.communities.mode: name` - Community mappings via `structured.communities.names` dictionary - Communities display as "65000:1000 - Upstream Any" in UI - Backward compatible with existing permit/deny modes - Enhance RPKI configuration documentation - Document both Cloudflare and Routinator backend options - Add `structured.rpki.backend` and `structured.rpki.rpki_server_url` parameters - Clarify Routinator web API endpoint usage vs RTR port - Add comprehensive configuration examples - Update structured output platform support - Document all supported platforms: Arista EOS, FRRouting, Huawei VRP, Juniper Junos, Mikrotik RouterOS/SwitchOS - Frontend enhancements - Parse comma-separated community format in UI components - Display friendly names alongside community codes - Maintain existing functionality for communities without names - Add validation and examples - Validate that 'name' mode has community mappings configured - Include example configuration and test cases - Generic examples using ASN 65000 instead of specific networks --- .../config/structured-output.mdx | 63 ++++++-- example_community_names_config.yaml | 21 +++ hyperglass/models/config/structured.py | 13 +- hyperglass/models/data/bgp_route.py | 18 ++- hyperglass/ui/components/output/fields.tsx | 14 +- test_community_names.py | 135 ++++++++++++++++++ 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 example_community_names_config.yaml create mode 100644 test_community_names.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df93625..1053023 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -1,22 +1,34 @@ +import { Callout } from "nextra/components"; + ## Structured Devices that support responding to a query with structured or easily parsable data can have their response data placed into an easier to read table (or JSON, when using the REST API). Currently, the following platforms have structured data supported in hyperglass: - Arista EOS +- FRRouting +- Huawei VRP - Juniper Junos +- Mikrotik RouterOS/SwitchOS 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/) +1. **From the router's perspective** - Uses the RPKI validation state as determined by the router itself +2. **From an external RPKI validation service** - Queries an external service to determine RPKI state independently + +For external validation, hyperglass supports two backends: +- **Cloudflare**: Uses [Cloudflare's public RPKI service](https://rpki.cloudflare.com/) via GraphQL API +- **Routinator**: Connects to your own [Routinator](https://github.com/NLnetLabs/routinator) RPKI validator instance 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. | +| Parameter | Type | Default Value | Description | +| :----------------------------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------- | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state, or `external` to use an external validation service. | +| `structured.rpki.backend` | String | cloudflare | When using `external` mode, choose `cloudflare` or `routinator` as the validation backend. | +| `structured.rpki.rpki_server_url` | String | | When using `routinator` backend, specify the base URL of your Routinator server (e.g., `http://rpki.example.com:3323`). | +| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed, `permit` to _only_ permit communities listed, or `name` to append friendly names. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny` and `permit` modes). | +| `structured.communities.names` | Dict | | Dictionary mapping BGP community codes to friendly names (used by `name` mode). | ### RPKI Examples @@ -28,14 +40,31 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from Cloudflare's Public Service -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-4} structured: rpki: mode: external + backend: cloudflare ``` +#### Show RPKI State from a Custom Routinator Server + +```yaml filename="config.yaml" copy {2-5} +structured: + rpki: + mode: external + backend: routinator + rpki_server_url: "http://rpki.example.com:8080" +``` + + + **Routinator URL Format** + + The `rpki_server_url` should be the base URL of your Routinator HTTP web API endpoint. This is typically different from the RTR port (3323). The URL should not include the `/validity` path as hyperglass will append this automatically. + + ### Community Filtering Examples #### Deny Listed Communities by Regex pattern @@ -59,3 +88,19 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Append Friendly Names to Communities + +```yaml filename="config.yaml" {2-10} +structured: + communities: + mode: name + names: + "65000:1000": "Upstream Any" + "65000:1001": "Upstream A (all locations)" + "65000:1101": "Upstream A Location 1" + "65000:1201": "Upstream A Location 2" + "65000:1002": "Upstream B (all locations)" + "65000:1102": "Upstream B Location 1" + "65000:2000": "IXP Any" +``` diff --git a/example_community_names_config.yaml b/example_community_names_config.yaml new file mode 100644 index 0000000..a9eacc4 --- /dev/null +++ b/example_community_names_config.yaml @@ -0,0 +1,21 @@ +# Example configuration using the new 'name' mode for BGP communities +# This would typically go in your main config.yaml file + +structured: + communities: + mode: name + names: + "65000:1000:0": "Upstream Any" + "65000:1001:0": "Upstream A (all locations)" + "65000:1101:0": "Upstream A Location 1" + "65000:1201:0": "Upstream A Location 2" + "65000:1002:0": "Upstream B (all locations)" + "65000:1102:0": "Upstream B Location 1" + "65000:2000:0": "IXP Any" + +# With this configuration: +# - BGP communities that appear in the output and match the keys in 'names' +# will be displayed with friendly names appended +# - For example: "65000:1000:0" becomes "65000:1000:0,Upstream Any" +# - Communities without mappings remain unchanged +# - The frontend will display them as "65000:1000:0 - Upstream Any" \ No newline at end of file diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 420f445..88e8ecb 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,10 +3,13 @@ # Standard Library import typing as t +# Third Party +from pydantic import field_validator, ValidationInfo + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] @@ -15,6 +18,14 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Validate that names are provided when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError("When using mode 'name', at least one community mapping must be provided in 'names'") + return value class StructuredRpki(HyperglassModel): diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index 905c1b7..9187f4f 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -42,6 +42,7 @@ class BGPRoute(HyperglassModel): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,10 +65,21 @@ class BGPRoute(HyperglassModel): break return valid - func_map = {"permit": _permit, "deny": _deny} - func = func_map[structured.communities.mode] + def _name(comm): + """Append friendly names to matching communities.""" + # Check if this community has a friendly name mapping + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm - return [c for c in value if func(c)] + if structured.communities.mode == "name": + # For name mode, transform communities to include friendly names + return [_name(c) for c in value] + else: + # For permit/deny modes, use existing filtering logic + func_map = {"permit": _permit, "deny": _deny} + func = func_map[structured.communities.mode] + return [c for c in value if func(c)] @field_validator("rpki_state") def validate_rpki_state(cls, value, info: ValidationInfo): diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index a24857a..58c9102 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -149,6 +149,16 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { const { web } = useConfig(); const bg = useColorValue('white', 'gray.900'); const color = useOpposingColor(bg); + + // Parse communities to separate code and name if present + const parsedCommunities = communities.map(community => { + if (community.includes(',')) { + const [code, name] = community.split(',', 2); + return { code, name, display: `${code} - ${name}` }; + } + return { code: community, name: null, display: community }; + }); + return ( @@ -175,7 +185,9 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { fontWeight="normal" whiteSpace="pre-wrap" > - {communities.join('\n')} + {parsedCommunities.map(({ display }, index) => ( + {display} + ))} diff --git a/test_community_names.py b/test_community_names.py new file mode 100644 index 0000000..7da1237 --- /dev/null +++ b/test_community_names.py @@ -0,0 +1,135 @@ +"""Test the new name mode for community validation.""" + +# Standard Library +import typing as t +from unittest.mock import Mock + +# Third Party +from pydantic import ValidationError + +# Project +from hyperglass.models.data.bgp_route import BGPRoute +from hyperglass.models.config.structured import StructuredCommunities + + +def test_community_validation_name_mode(): + """Test that name mode correctly appends friendly names to communities.""" + + # Mock the state to return our test configuration + from hyperglass import state + + # Create a mock structured config with name mode + mock_structured = Mock() + mock_structured.communities = StructuredCommunities( + mode="name", + names={ + "65000:1000:0": "Upstream Any", + "65000:1001:0": "Upstream A (all locations)", + "65000:1": "Test Community" + } + ) + + # Mock the params with our structured config + mock_params = Mock() + mock_params.structured = mock_structured + + # Mock the use_state function to return our mock params + original_use_state = getattr(state, 'use_state', None) + state.use_state = Mock(return_value=mock_params) + + try: + # Test data for BGP route + test_data = { + "prefix": "192.0.2.0/24", + "active": True, + "age": 3600, + "weight": 100, + "med": 0, + "local_preference": 100, + "as_path": [65000, 65001], + "communities": [ + "65000:1000:0", # Should get friendly name + "65000:1001:0", # Should get friendly name + "65000:9999:0", # Should remain unchanged (no mapping) + "65000:1", # Should get friendly name + ], + "next_hop": "192.0.2.1", + "source_as": 65001, + "source_rid": "192.0.2.1", + "peer_rid": "192.0.2.2", + "rpki_state": 1 + } + + # Create BGPRoute instance + route = BGPRoute(**test_data) + + # Check that communities have been transformed correctly + expected_communities = [ + "65000:1000:0,Upstream Any", + "65000:1001:0,Upstream A (all locations)", + "65000:9999:0", # No friendly name, stays unchanged + "65000:1,Test Community" + ] + + assert route.communities == expected_communities + + finally: + # Restore original use_state function + if original_use_state: + state.use_state = original_use_state + + +def test_community_validation_permit_mode_unchanged(): + """Test that permit mode still works as before.""" + + from hyperglass import state + + # Create a mock structured config with permit mode + mock_structured = Mock() + mock_structured.communities = StructuredCommunities( + mode="permit", + items=["^65000:.*$", "1234:1"] + ) + + mock_params = Mock() + mock_params.structured = mock_structured + + original_use_state = getattr(state, 'use_state', None) + state.use_state = Mock(return_value=mock_params) + + try: + test_data = { + "prefix": "192.0.2.0/24", + "active": True, + "age": 3600, + "weight": 100, + "med": 0, + "local_preference": 100, + "as_path": [65000, 65001], + "communities": [ + "65000:100", # Should be permitted (matches ^65000:.*$) + "65001:200", # Should be denied (doesn't match patterns) + "1234:1", # Should be permitted (exact match) + ], + "next_hop": "192.0.2.1", + "source_as": 65001, + "source_rid": "192.0.2.1", + "peer_rid": "192.0.2.2", + "rpki_state": 1 + } + + route = BGPRoute(**test_data) + + # Should only include permitted communities + expected_communities = ["65000:100", "1234:1"] + assert route.communities == expected_communities + + finally: + if original_use_state: + state.use_state = original_use_state + + +if __name__ == "__main__": + test_community_validation_name_mode() + test_community_validation_permit_mode_unchanged() + print("All tests passed!") \ No newline at end of file