feature: export control by neighbour AS and by location

The production router has the same neighbour AS at several exchanges, so
per-AS control alone cannot express "only at SwissIX". Every session now
has two coordinates, neighbour AS and location, and the scheme gains two
functions:
  210036:0:<as> / 210036:2:<location>   never announce      (wins)
  210036:1:<as> / 210036:3:<location>   announce
  210036:0:0                            announce nowhere
  nothing                               announce everywhere
docs/lab/prod/bird-ferrix.conf documents the functions, the per-session
call (export where peers_out(<as>, <location>)) and the gate that sessions
exporting everything need as well.

Status page: location presets, and AS210036 communities are decoded from
their structure so any value reads well in the bytes hover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-20 17:34:57 +02:00
1 parent 98f25a5847
commit 57099da73d
2 files changed
+83 -61

No files matched your search

+20 -7
View File
@@ -120,18 +120,21 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<script>
(() => {
// Large-community presets offered next to the field, grouped by who acts
// on them. "PEER" is replaced by an AS number the user is asked for. Both
// groups share one shape and precedence: 0:<as> never, 1:<as> send,
// 0:0 nobody, otherwise everyone.
// AS210036: docs/lab/prod/bird-ferrix.conf (lab_export_allowed)
// on them. "PEER" is replaced by an AS number the user is asked for.
// AS210036: docs/lab/prod/bird-ferrix.conf (lab_export_allowed). Every
// session there has a neighbour AS and a location; "never"
// (functions 0 and 2) beats "announce" (1 and 3) beats 0:0.
// SwissIX: https://www.swissix.ch/resources/action-bgp-communities/
const LOCATIONS = { 1: 'SwissIX', 2: 'NL-ix', 3: 'France-IX', 4: 'DE-CIX Frankfurt', 5: 'Free-IX Switzerland', 6: 'Community-IX.ch', 100: 'transit', 200: 'downstream customers' };
const LARGE_PRESET_GROUPS = [
['AS210036', 'AS210036, our upstream router', [
['210036:0:0', 'announce to nobody'],
['AS210036', 'AS210036: by neighbour', [
['210036:0:0', 'announce nowhere (then add where to)'],
['210036:1:42476', 'announce to the SwissIX route servers'],
['210036:1:PEER', 'announce to neighbour AS'],
['210036:0:PEER', 'do not announce to neighbour AS'],
['210036:0:PEER', 'never announce to neighbour AS'],
]],
['AS210036', 'AS210036: by location', Object.entries(LOCATIONS).filter(([id]) => id < 100).map(([id, name]) => [`210036:3:${id}`, `announce at ${name}`])
.concat([['210036:2:100', 'never announce to transit']])],
['SwissIX', 'SwissIX route servers (AS42476)', [
['42476:0:0', 'send to nobody'],
['42476:1:559', 'send to SWITCH (AS559)'],
@@ -273,6 +276,16 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
return out;
}
function largeLabel(c) {
// Our own scheme is decoded from its structure, so any value reads well,
// not only the ones in the menu.
const own = c.match(/^210036:([0-3]):(\d+)$/);
if (own) {
const [f, t] = [Number(own[1]), Number(own[2])], where = LOCATIONS[t] || `location ${t}`;
if (f === 0 && t === 0) return 'AS210036: announce nowhere, unless another community says where';
if (f === 0) return `AS210036: never announce to AS${t}`;
if (f === 1) return `AS210036: announce to AS${t}${t === 42476 ? ' (the SwissIX route servers)' : ''}`;
return `AS210036: ${f === 2 ? 'never announce' : 'announce'} ${t >= 100 ? 'to' : 'at'} ${where}`;
}
// Exact presets first, so 42476:1:559 reads "SWITCH" rather than "AS559".
for (const pass of [false, true]) for (const [who, , presets] of LARGE_PRESET_GROUPS) for (const [pattern, label] of presets) {
if (pattern.includes('PEER') !== pass) continue;
+63 -54
View File
@@ -39,12 +39,9 @@ filter ferrix_import
# (210036, *, *) space, so students cannot trigger anything else this
# network acts on. Add further namespaces here deliberately.
#
# And our own export-control communities, evaluated by
# lab_export_allowed() below:
# 210036:0:<peer-as> do not announce to that neighbour AS
# 210036:1:<peer-as> announce to that neighbour AS
# 210036:0:0 announce to nobody (unless 210036:1:<peer-as> says so)
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..1, *) ]);
# And our own export-control communities, functions 0 to 3, evaluated by
# lab_export_allowed() below.
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..3, *) ]);
bgp_ext_community.empty;
@@ -85,70 +82,82 @@ protocol bgp ferrix
# ---------------------------------------------------------------------------
# Export control for lab routes.
#
# Same shape and same precedence as the SwissIX route-server communities, so
# students apply one idea at two hops: first here, then at the exchange.
# BIRD export filters cannot see which neighbour they are exporting to, and
# filters take no arguments; functions do. So every eBGP session states who it
# talks to and where, by calling the shared export function with its neighbour
# AS and a location:
#
# 1. 210036:0:<peer-as> never announce to that neighbour
# 2. 210036:1:<peer-as> announce to that neighbour
# 3. 210036:0:0 announce to nobody
# 4. (nothing) announce to everyone <- default
# ipv6 { export where peers_out(42476, AT_SWISSIX); ... };
#
# <peer-as> is the AS a session's wrapper filter passes in (see below). For a
# route server that is the route server's AS, e.g. 42476 for SwissIX.
# Sessions without their own wrapper cannot be selected individually.
# Students steer their prefix with large communities, in this precedence:
#
# "Only to SwissIX": 210036:0:0 210036:1:42476
# "...and there only to SWITCH": add 42476:0:0 42476:1:559
# 1. 210036:0:<as> never announce to that neighbour AS
# 210036:2:<location> never announce at that location
# 2. 210036:1:<as> announce to that neighbour AS
# 210036:3:<location> announce at that location
# 3. 210036:0:0 announce nowhere (unless rule 2 says otherwise)
# 4. nothing announce everywhere <- default
#
# A "never" always wins, so the two dimensions combine. The shape and
# precedence match the SwissIX route-server communities on purpose, so one
# idea is applied at two hops.
#
# only the SwissIX route servers: 210036:0:0 210036:1:42476
# ...and there only SWITCH: add 42476:0:0 42476:1:559
# every session at SwissIX: 210036:0:0 210036:3:1
# Cloudflare, but not at DE-CIX: 210036:0:0 210036:1:13335 210036:2:4
# ---------------------------------------------------------------------------
function lab_export_allowed(int peer_as)
define AT_SWISSIX = 1;
define AT_NLIX = 2;
define AT_FRANCEIX = 3;
define AT_DECIX = 4;
define AT_FREEIX = 5;
define AT_COMMUNITYIX = 6;
define AT_TRANSIT = 100;
define AT_DOWNSTREAM = 200;
function lab_export_allowed(int peer_as; int location)
{
if (MY_ASN, 0, peer_as) ~ bgp_large_community then return false;
if (MY_ASN, 2, location) ~ bgp_large_community then return false;
if (MY_ASN, 1, peer_as) ~ bgp_large_community then return true;
if (MY_ASN, 3, location) ~ bgp_large_community then return true;
if (MY_ASN, 0, 0) ~ bgp_large_community then return false;
return true;
}
# Wiring it into a shared export filter
# -------------------------------------
# BIRD export filters cannot see which neighbour they are exporting to, and
# filters take no arguments; functions do. So the shared filter's body lives
# in a function taking the neighbour AS, and named filters are thin wrappers
# around it. Sessions that should be individually selectable get their own
# wrapper; every other session keeps using "peers_out" unchanged and is
# evaluated with AS 0, which means it obeys 210036:0:0 but cannot be singled
# out. Add a wrapper per neighbour as the need arises.
# Decides whether a lab route goes out on a session and, if so, makes it
# presentable: the origin must appear as AS210036 (matching the route6
# objects) and our action communities must not leak.
function lab_route_out(int peer_as; int location)
{
if !lab_export_allowed(peer_as, location) then return false;
bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
bgp_large_community.delete([ (MY_ASN, 0..3, *) ]);
return true;
}
# The shared export function then ends its list of accepted routes with
#
# function peers_out_fn(int peer_as)
# {
# bgp_med = 0;
# bgp_large_community.add((210036, 10, 2));
# if (net = 193.36.104.0/24) then return true;
# if (net = 2a0d:d740::/48) then return true;
# if (net = 2a0d:d740:105::/48) then return true;
# if proto = "delroth_v6" then return true;
# if proto = "pkern_v6" then return true;
# if proto = "home_v4" then return true;
# if proto = "ferrix" then return lab_route_out(peer_as, location);
# return false;
#
# and returns true for everything else it accepts. Sessions that export
# everything (downstream customers) need the same gate, or they receive lab
# routes regardless of what the student asked for:
#
# export filter {
# if proto = "ferrix" then {
# if !lab_export_allowed(peer_as) then return false;
# # Origin must appear as AS210036, matching the route6 objects.
# bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
# # Our action communities have done their job; do not leak them.
# bgp_large_community.delete([ (MY_ASN, 0..1, *) ]);
# return true;
# if lab_route_out(210400, AT_DOWNSTREAM) then accept;
# reject;
# }
# return false;
# }
#
# filter peers_out { if peers_out_fn(0) then accept; reject; }
# filter peers_out_swissix { if peers_out_fn(42476) then accept; reject; }
#
# Then only the SwissIX route-server sessions change, to
# export filter peers_out_swissix;
#
# lab_export_allowed() must be defined above peers_out_fn in the file.
# accept;
# };
#
# The ferrix branch only ever applies to routes learned from ferrix, so these
# communities cannot be used by anyone else to steer your other exports.
# communities cannot be used by anyone else to steer other exports.
#
# If this BIRD rejects "int peer_as; int location", it wants a comma there.
#
# Check what a session would send, without waiting for a looking glass:
# birdc show route 2a0d:d741:1::/48 export <protocol-name> all