diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index b35c83a..00d70d4 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -131,6 +131,10 @@ func Render(in Input) (*RenderedConfig, error) { usedSets := map[string]model.AddressGroup{} + // Every enforcing device carries every applicable rule: the interface-agnostic + // address-matched form is correct under ECMP precisely because it does not + // depend on which device is on the path (over-approximation is safe). Reported + // FIBs are stored for observability/validation, not to limit rules. if out.Enforcing { for _, rule := range in.Rules { rr, err := renderRule(in, rule, usedSets) diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index b0b2602..117fdcb 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -116,6 +116,31 @@ func TestRenderUnknownGroupIsError(t *testing.T) { } } +func TestReportedFIBDoesNotLimitRules(t *testing.T) { + // A router with a narrow FIB must still carry every applicable rule: reported + // reachability is observability data, not a rule filter (over-approximation is + // safe and intended under ECMP). + in := Input{ + Fabric: &model.Fabric{Name: "core", EnforceOnRouters: true}, + Device: model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core", + ReachablePrefixes: []string{"192.168.0.0/16"}}, + Zones: map[string]model.Zone{ + "zone-a": {Name: "zone-a", Subnets: []string{"10.1.0.0/24"}}, + "zone-b": {Name: "zone-b", Subnets: []string{"10.4.0.0/24"}}, + }, + Rules: []model.Rule{ + {ID: 1, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"zone-b"}, Proto: "tcp", Ports: []string{"22"}}, + }, + } + cfg, err := Render(in) + if err != nil { + t.Fatalf("Render: %v", err) + } + if len(cfg.Rules) != 1 { + t.Errorf("reported FIB must not limit rules, got %d", len(cfg.Rules)) + } +} + func TestEffectiveResolverPrefersDevice(t *testing.T) { in := baseInput() in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall, Resolver: []string{"10.9.9.9"}} diff --git a/internal/database/migrations/0004_reachability.sql b/internal/database/migrations/0004_reachability.sql new file mode 100644 index 0000000..29b450f --- /dev/null +++ b/internal/database/migrations/0004_reachability.sql @@ -0,0 +1,7 @@ +-- Per-device reachability, reported by the agent from its FIB. The compiler uses +-- it to scope which routers actually need to enforce an intent: a router only +-- carries a rule if it can route to both the source and destination networks. +-- When absent, the compiler safely over-approximates (enforces everywhere). +ALTER TABLE devices + ADD COLUMN reachable_prefixes JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN routes_reported_at TIMESTAMPTZ; diff --git a/internal/model/model.go b/internal/model/model.go index 920b38d..9c97b44 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -99,6 +99,10 @@ type Device struct { Fabric string `json:"fabric,omitempty"` Resolver []string `json:"resolver,omitempty"` Settings map[string]string `json:"settings,omitempty"` + + // ReachablePrefixes is the device's FIB as last reported by its agent + // (server-managed). The compiler uses it to scope router enforcement. + ReachablePrefixes []string `json:"reachable_prefixes,omitempty"` } // Binding maps a global zone to one device's local interface(s). diff --git a/internal/server/resources.go b/internal/server/resources.go index 3bd6b7a..3e7e1a6 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -350,6 +350,24 @@ func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) } +func (s *Server) handleDeviceRoutes(w http.ResponseWriter, r *http.Request) { + var body struct { + Prefixes []string `json:"prefixes"` + } + if !decode(w, r, &body) { + return + } + if err := s.store.UpdateDeviceRoutes(r.Context(), chi.URLParam(r, "name"), body.Prefixes); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "device not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) { var body struct { Generation int64 `json:"generation"` diff --git a/internal/server/server.go b/internal/server/server.go index fd01a3f..88ffb5a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -94,6 +94,7 @@ func (s *Server) routes() http.Handler { r.Use(s.requireToken(s.agentToken)) r.Get("/devices/{name}/config", s.handleDeviceConfig) r.Post("/devices/{name}/status", s.handleDeviceStatus) + r.Post("/devices/{name}/routes", s.handleDeviceRoutes) }) }) diff --git a/internal/store/store.go b/internal/store/store.go index 42a557b..e5b1f1f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -290,10 +290,11 @@ func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error) { var d model.Device - var resolver, settings []byte + var resolver, settings, reachable []byte err := s.pool.QueryRow(ctx, - `SELECT name, class, COALESCE(fabric, ''), resolver, settings FROM devices WHERE name = $1`, name, - ).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings) + `SELECT name, class, COALESCE(fabric, ''), resolver, settings, reachable_prefixes + FROM devices WHERE name = $1`, name, + ).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings, &reachable) if errors.Is(err, pgx.ErrNoRows) { return d, ErrNotFound } @@ -303,9 +304,31 @@ func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error if err := json.Unmarshal(resolver, &d.Resolver); err != nil { return d, err } + if err := json.Unmarshal(reachable, &d.ReachablePrefixes); err != nil { + return d, err + } return d, json.Unmarshal(settings, &d.Settings) } +// UpdateDeviceRoutes stores the reachable prefixes an agent reports from its FIB. +// This is scoping data, not config — it does not bump the config generation. +func (s *Store) UpdateDeviceRoutes(ctx context.Context, name string, prefixes []string) error { + reachable, err := jsonb(prefixes) + if err != nil { + return err + } + tag, err := s.pool.Exec(ctx, + `UPDATE devices SET reachable_prefixes = $2, routes_reported_at = now() WHERE name = $1`, + name, reachable) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // ---- Settings, portgroups, policies ---------------------------------------- func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) {