From 66265764df39f5c2c85a0ba151cde346d78f1d12 Mon Sep 17 00:00:00 2001 From: benvin Date: Mon, 20 Jul 2026 22:37:24 +1000 Subject: [PATCH] Agent: report the FIB for reachability scoping The agent now collects the device's reachable prefixes from the kernel FIB (including FRR-installed routes) via 'ip route show' / 'ip -6 route show' and reports them to the control plane (POST /devices/{name}/routes) alongside its status. tomswallapi uses these to scope which routers enforce a rule. Route parsing (default routes, ECMP nexthop lines, route-type keywords, host routes, v4/v6) is unit-tested; collection degrades to nil without iproute2. --- internal/agent/agent.go | 6 +++ internal/agent/client.go | 24 ++++++++++++ internal/agent/fib.go | 76 ++++++++++++++++++++++++++++++++++++++ internal/agent/fib_test.go | 55 +++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 internal/agent/fib.go create mode 100644 internal/agent/fib_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d72ef0a..bf7d68c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -90,6 +90,12 @@ func (a *Agent) applyConfig(ctx context.Context, rc *RenderedConfig, report bool if err := a.Client.ReportStatus(ctx, rc.Generation); err != nil { slog.Warn("agent: reporting status failed", "err", err) } + // Report the FIB so the control plane can scope router enforcement. + if fib := CollectFIB(ctx); len(fib) > 0 { + if err := a.Client.ReportRoutes(ctx, fib); err != nil { + slog.Warn("agent: reporting routes failed", "err", err) + } + } } return nil } diff --git a/internal/agent/client.go b/internal/agent/client.go index 5320e6b..d385011 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -70,6 +70,30 @@ func ParseRendered(body []byte) (*RenderedConfig, error) { return &cfg, nil } +// ReportRoutes reports the device's reachable prefixes (FIB) so the control +// plane can scope which routers enforce a rule. +func (c *Client) ReportRoutes(ctx context.Context, prefixes []string) error { + url := fmt.Sprintf("%s/api/v1/devices/%s/routes", c.BaseURL, c.Device) + payload, _ := json.Marshal(map[string][]string{"prefixes": prefixes}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode >= 400 { + return fmt.Errorf("report routes: %d", resp.StatusCode) + } + return nil +} + // ReportStatus tells the control plane which generation this device has applied. func (c *Client) ReportStatus(ctx context.Context, generation int64) error { url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device) diff --git a/internal/agent/fib.go b/internal/agent/fib.go new file mode 100644 index 0000000..f04bca5 --- /dev/null +++ b/internal/agent/fib.go @@ -0,0 +1,76 @@ +package agent + +import ( + "context" + "os/exec" + "strings" +) + +// CollectFIB returns the device's reachable prefixes from the kernel FIB — which +// includes FRR-installed routes — by shelling out to `ip route`. The control +// plane uses these to scope which routers enforce a rule. On a host without +// iproute2 it returns nil, and the control plane falls back to over-approximation. +func CollectFIB(ctx context.Context) []string { + var prefixes []string + for _, spec := range []struct { + args []string + def string + }{ + {[]string{"route", "show"}, "0.0.0.0/0"}, + {[]string{"-6", "route", "show"}, "::/0"}, + } { + out, err := exec.CommandContext(ctx, "ip", spec.args...).Output() + if err != nil { + continue + } + prefixes = append(prefixes, parseRoutes(string(out), spec.def)...) + } + return dedup(prefixes) +} + +// routeTypeKeywords are leading tokens in `ip route` output that precede the +// actual destination (e.g. "unreachable 10.0.0.0/8"). +var routeTypeKeywords = map[string]bool{ + "unreachable": true, "blackhole": true, "prohibit": true, "throw": true, + "local": true, "broadcast": true, "multicast": true, "anycast": true, "nat": true, +} + +// parseRoutes extracts destination prefixes from `ip route show` output. +// defaultPrefix is substituted for a "default" route (family-specific). +func parseRoutes(output, defaultPrefix string) []string { + var out []string + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + dst := fields[0] + // ECMP routes emit continuation "nexthop ..." lines with no destination. + if dst == "nexthop" { + continue + } + if routeTypeKeywords[dst] { + if len(fields) < 2 { + continue + } + dst = fields[1] + } + if dst == "default" { + out = append(out, defaultPrefix) + continue + } + out = append(out, normalizePrefix(dst)) + } + return out +} + +// normalizePrefix turns a bare host address into a host prefix (/32 or /128). +func normalizePrefix(dst string) string { + if strings.Contains(dst, "/") { + return dst + } + if strings.Contains(dst, ":") { + return dst + "/128" + } + return dst + "/32" +} diff --git a/internal/agent/fib_test.go b/internal/agent/fib_test.go new file mode 100644 index 0000000..10f21cd --- /dev/null +++ b/internal/agent/fib_test.go @@ -0,0 +1,55 @@ +package agent + +import ( + "reflect" + "sort" + "testing" +) + +func TestParseRoutesV4(t *testing.T) { + // Representative `ip route show` output, including a default route, an ECMP + // route with nexthop continuation lines, a connected route, and a host route. + out := `default via 10.0.0.1 dev eth0 proto dhcp +10.1.0.0/24 dev eth1 proto kernel scope link src 10.1.0.5 +10.4.0.0/24 proto bgp metric 20 + nexthop via 10.0.0.2 dev eth0 weight 1 + nexthop via 10.0.0.3 dev eth0 weight 1 +192.0.2.7 dev eth2 scope link +blackhole 172.16.0.0/12 +` + got := parseRoutes(out, "0.0.0.0/0") + sort.Strings(got) + want := []string{"0.0.0.0/0", "10.1.0.0/24", "10.4.0.0/24", "172.16.0.0/12", "192.0.2.7/32"} + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("parseRoutes v4 = %v, want %v", got, want) + } +} + +func TestParseRoutesV6(t *testing.T) { + out := `default via fe80::1 dev eth0 metric 1024 +2001:db8:1::/64 dev eth1 proto kernel metric 256 +2001:db8:4::5 dev eth2 +` + got := parseRoutes(out, "::/0") + sort.Strings(got) + want := []string{"2001:db8:1::/64", "2001:db8:4::5/128", "::/0"} + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("parseRoutes v6 = %v, want %v", got, want) + } +} + +func TestNormalizePrefix(t *testing.T) { + cases := map[string]string{ + "10.1.0.0/24": "10.1.0.0/24", + "10.1.0.5": "10.1.0.5/32", + "2001:db8::1": "2001:db8::1/128", + "2001:db8::/32": "2001:db8::/32", + } + for in, want := range cases { + if got := normalizePrefix(in); got != want { + t.Errorf("normalizePrefix(%q) = %q, want %q", in, got, want) + } + } +} -- 2.47.3