Agent: report the FIB for reachability scoping
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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.
This commit is contained in:
benvin
2026-07-20 22:37:24 +10:00
parent 5116fd83b4
commit 66265764df
4 changed files with 161 additions and 0 deletions
+6
View File
@@ -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 { if err := a.Client.ReportStatus(ctx, rc.Generation); err != nil {
slog.Warn("agent: reporting status failed", "err", err) 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 return nil
} }
+24
View File
@@ -70,6 +70,30 @@ func ParseRendered(body []byte) (*RenderedConfig, error) {
return &cfg, nil 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. // ReportStatus tells the control plane which generation this device has applied.
func (c *Client) ReportStatus(ctx context.Context, generation int64) error { func (c *Client) ReportStatus(ctx context.Context, generation int64) error {
url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device) url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device)
+76
View File
@@ -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"
}
+55
View File
@@ -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)
}
}
}