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.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user