From f80cc2cc30ef01fe6893fd95743725a35e82dc6b Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 19 Jul 2026 18:38:52 +1000 Subject: [PATCH] Add per-device config compiler and agent config endpoint Project the fleet-global model through a device's bindings into a rendered, interface-agnostic config: rules compile to saddr/daddr forward matches with no iif/oif so they are correct under FRR/ECMP. Firewalls always enforce; routers enforce only when their fabric opts into defense-in-depth. Referenced address groups are emitted as named sets carrying their source (static CIDRs, dns FQDNs, or asn numbers) so membership churns out-of-band without a rule reload. Wire GET /devices/{name}/config to compile and serve YAML, generation-stamped. Add portgroups/policies/settings store methods and portgroup CRUD. Pure Render is unit-tested for enforcement gating, ASN set emission, and resolver precedence. --- go.mod | 3 + go.sum | 9 + internal/compiler/compiler.go | 313 +++++++++++++++++++++++++++++ internal/compiler/compiler_test.go | 129 ++++++++++++ internal/model/model.go | 17 ++ internal/server/resources.go | 47 ++++- internal/store/store.go | 88 ++++++++ 7 files changed, 600 insertions(+), 6 deletions(-) create mode 100644 internal/compiler/compiler.go create mode 100644 internal/compiler/compiler_test.go diff --git a/go.mod b/go.mod index 8741e89..d9f0f7a 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,15 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.0 github.com/jackc/pgx/v5 v5.10.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index f80318d..df514e9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,8 +12,14 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -23,6 +30,8 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go new file mode 100644 index 0000000..da439fd --- /dev/null +++ b/internal/compiler/compiler.go @@ -0,0 +1,313 @@ +// Package compiler projects the fleet-global model through a device's binding +// table into a rendered, interface-agnostic config the tomswall agent applies. +// +// Rules are compiled to address-matched (saddr/daddr) forward rules with no +// iif/oif, which is what makes them correct under FRR/ECMP: any device on any +// path permits the 5-tuple and each device's own conntrack handles the return. +// Firewalls always enforce; routers enforce only when their fabric opts in. +package compiler + +import ( + "context" + "fmt" + "sort" + + "gopkg.in/yaml.v3" + + "git.unkin.net/unkin/tomswallapi/internal/model" + "git.unkin.net/unkin/tomswallapi/internal/store" +) + +// Input is the fully-resolved model needed to render one device. Keeping Render +// pure (no store access) makes it unit-testable without a database. +type Input struct { + Generation int64 + Settings model.Settings + Device model.Device + Fabric *model.Fabric + Zones map[string]model.Zone + Groups map[string]model.AddressGroup + PortGroups map[string]model.PortGroup + Rules []model.Rule + Policies []model.Policy + Bindings []model.Binding +} + +// RenderedConfig is the per-device output served to the agent. +type RenderedConfig struct { + Generation int64 `yaml:"generation" json:"generation"` + Device string `yaml:"device" json:"device"` + Class model.DeviceClass `yaml:"class" json:"class"` + Enforcing bool `yaml:"enforcing" json:"enforcing"` + Settings RenderedSettings `yaml:"settings" json:"settings"` + Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"` + Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces + Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"` + Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"` + Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"` +} + +// RenderedSettings is the effective settings after per-device overrides. +type RenderedSettings struct { + AddressFamily string `yaml:"address_family" json:"address_family"` + LogLevel string `yaml:"log_level" json:"log_level"` + IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"` + TableName string `yaml:"table_name" json:"table_name"` +} + +// RenderedSet is an nftables named set the agent must materialize. Members carry +// the concrete elements when the API knows them (static, or asn once expanded); +// dns and unexpanded asn sets carry their source so the agent/expander can +// populate them out-of-band without a rule reload. +type RenderedSet struct { + Name string `yaml:"name" json:"name"` + Kind model.AddressGroupType `yaml:"kind" json:"kind"` + Members []string `yaml:"members,omitempty" json:"members,omitempty"` // static CIDRs / expanded prefixes + FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"` // dns: names to resolve on-device + ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"` // asn: source ASNs + Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"` +} + +// RenderedMatch is one OR'd element of a rule direction: the zone's subnets +// AND, optionally, a named set to intersect with. +type RenderedMatch struct { + Zone string `yaml:"zone" json:"zone"` + Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"` + Set string `yaml:"set,omitempty" json:"set,omitempty"` +} + +// RenderedRule is an interface-agnostic forward rule. +type RenderedRule struct { + Action string `yaml:"action" json:"action"` + Source []RenderedMatch `yaml:"source" json:"source"` + Dest []RenderedMatch `yaml:"dest" json:"dest"` + Proto string `yaml:"proto,omitempty" json:"proto,omitempty"` + Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"` + Log string `yaml:"log,omitempty" json:"log,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +// Marshal serializes the rendered config to YAML. +func (c *RenderedConfig) Marshal() ([]byte, error) { return yaml.Marshal(c) } + +// enforces reports whether the device applies rules: firewalls always do; routers +// only when their fabric opts into defense-in-depth. +func enforces(dev model.Device, fabric *model.Fabric) bool { + if dev.Class == model.ClassFirewall { + return true + } + return dev.Class == model.ClassRouter && fabric != nil && fabric.EnforceOnRouters +} + +// setNameFor resolves a rule's selector reference (as written after + or &) to a +// concrete nft set name. A reference may be a group's bare name or its computed +// set name (e.g. an asn group "cloudflare" whose set is "asn_cloudflare"). +func setNameFor(groups map[string]model.AddressGroup, ref string) (model.AddressGroup, bool) { + if g, ok := groups[ref]; ok { + return g, true + } + for _, g := range groups { + if g.SetName() == ref { + return g, true + } + } + return model.AddressGroup{}, false +} + +// Render projects the model into a device config. It is pure and deterministic. +func Render(in Input) (*RenderedConfig, error) { + out := &RenderedConfig{ + Generation: in.Generation, + Device: in.Device.Name, + Class: in.Device.Class, + Enforcing: enforces(in.Device, in.Fabric), + Settings: renderSettings(in), + Resolver: effectiveResolver(in), + Bindings: map[string][]string{}, + } + for _, b := range in.Bindings { + out.Bindings[b.Zone] = b.Interfaces + } + + usedSets := map[string]model.AddressGroup{} + + if out.Enforcing { + for _, rule := range in.Rules { + rr, err := renderRule(in, rule, usedSets) + if err != nil { + return nil, fmt.Errorf("rule %d: %w", rule.ID, err) + } + out.Rules = append(out.Rules, rr) + } + out.Policies = in.Policies + } + + // Emit a set definition for every address group any rule referenced. + names := make([]string, 0, len(usedSets)) + for n := range usedSets { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + out.Sets = append(out.Sets, renderSet(usedSets[n])) + } + return out, nil +} + +func renderSettings(in Input) RenderedSettings { + s := RenderedSettings{ + AddressFamily: in.Settings.AddressFamily, + LogLevel: in.Settings.LogLevel, + IPForwarding: in.Settings.IPForwarding, + TableName: in.Settings.TableName, + } + // Per-device string overrides. + if v, ok := in.Device.Settings["address_family"]; ok { + s.AddressFamily = v + } + if v, ok := in.Device.Settings["log_level"]; ok { + s.LogLevel = v + } + if v, ok := in.Device.Settings["table_name"]; ok { + s.TableName = v + } + return s +} + +func effectiveResolver(in Input) []string { + if len(in.Device.Resolver) > 0 { + return in.Device.Resolver + } + return in.Settings.DefaultResolver +} + +func renderRule(in Input, rule model.Rule, usedSets map[string]model.AddressGroup) (RenderedRule, error) { + src, err := renderMatches(in, rule.Source, usedSets) + if err != nil { + return RenderedRule{}, fmt.Errorf("source: %w", err) + } + dst, err := renderMatches(in, rule.Dest, usedSets) + if err != nil { + return RenderedRule{}, fmt.Errorf("dest: %w", err) + } + proto, ports := resolvePorts(in, rule) + return RenderedRule{ + Action: rule.Action, + Source: src, + Dest: dst, + Proto: proto, + Ports: ports, + Log: rule.Log, + Comment: rule.Comment, + }, nil +} + +func renderMatches(in Input, list []string, usedSets map[string]model.AddressGroup) ([]RenderedMatch, error) { + elems, err := model.ParseElements(list) + if err != nil { + return nil, err + } + out := make([]RenderedMatch, 0, len(elems)) + for _, e := range elems { + m := RenderedMatch{Zone: e.Zone} + if z, ok := in.Zones[e.Zone]; ok { + m.Subnets = z.Subnets + } + if e.Selector != model.SelNone { + g, ok := setNameFor(in.Groups, e.Ref) + if !ok { + return nil, fmt.Errorf("unknown address group %q", e.Ref) + } + m.Set = g.SetName() + usedSets[g.SetName()] = g + } + out = append(out, m) + } + return out, nil +} + +func resolvePorts(in Input, rule model.Rule) (proto string, ports []string) { + if rule.PortGroup != "" { + if pg, ok := in.PortGroups[rule.PortGroup]; ok { + return pg.Proto, pg.Ports + } + } + return rule.Proto, rule.Ports +} + +func renderSet(g model.AddressGroup) RenderedSet { + rs := RenderedSet{Name: g.SetName(), Kind: g.Type, Refresh: g.Refresh} + switch g.Type { + case model.GroupStatic: + rs.Members = g.Members + case model.GroupDNS: + rs.FQDNs = g.Members + case model.GroupASN: + rs.ASNs = g.Members // expanded prefixes are attached out-of-band by the ASN expander + } + return rs +} + +// Compile fetches the model for a device from the store and renders its config. +func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfig, error) { + dev, err := s.GetDevice(ctx, device) + if err != nil { + return nil, err + } + gen, err := s.Generation(ctx) + if err != nil { + return nil, err + } + settings, err := s.GetSettings(ctx) + if err != nil { + return nil, err + } + in := Input{Generation: gen, Settings: settings, Device: dev} + + if dev.Fabric != "" { + f, err := s.GetFabric(ctx, dev.Fabric) + if err == nil { + in.Fabric = &f + } else if err != store.ErrNotFound { + return nil, err + } + } + + zones, err := s.ListZones(ctx) + if err != nil { + return nil, err + } + in.Zones = make(map[string]model.Zone, len(zones)) + for _, z := range zones { + in.Zones[z.Name] = z + } + + groups, err := s.ListAddressGroups(ctx) + if err != nil { + return nil, err + } + in.Groups = make(map[string]model.AddressGroup, len(groups)) + for _, g := range groups { + in.Groups[g.Name] = g + } + + pgs, err := s.ListPortGroups(ctx) + if err != nil { + return nil, err + } + in.PortGroups = make(map[string]model.PortGroup, len(pgs)) + for _, p := range pgs { + in.PortGroups[p.Name] = p + } + + if in.Rules, err = s.ListRules(ctx); err != nil { + return nil, err + } + if in.Policies, err = s.ListPolicies(ctx); err != nil { + return nil, err + } + if in.Bindings, err = s.ListBindings(ctx, device); err != nil { + return nil, err + } + return Render(in) +} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go new file mode 100644 index 0000000..b0b2602 --- /dev/null +++ b/internal/compiler/compiler_test.go @@ -0,0 +1,129 @@ +package compiler + +import "testing" + +import "git.unkin.net/unkin/tomswallapi/internal/model" + +func baseInput() Input { + return Input{ + Generation: 7, + Settings: model.Settings{AddressFamily: "inet", LogLevel: "info", IPForwarding: true, TableName: "tomswall", DefaultResolver: []string{"10.0.0.53"}}, + Zones: map[string]model.Zone{ + "zone-a": {Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}}, + "net": {Name: "net", Type: "ip"}, // no subnets: internet-facing + }, + Groups: map[string]model.AddressGroup{ + "cloudflare": {Name: "cloudflare", Type: model.GroupASN, Members: []string{"13335"}, Refresh: "24h"}, + }, + PortGroups: map[string]model.PortGroup{ + "https": {Name: "https", Proto: "tcp", Ports: []string{"443"}}, + }, + Rules: []model.Rule{ + {ID: 1, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"net:+asn_cloudflare"}, PortGroup: "https"}, + }, + } +} + +func TestRenderFirewallEnforcesAndEmitsSet(t *testing.T) { + in := baseInput() + in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall} + in.Bindings = []model.Binding{{Device: "fw-a", Zone: "zone-a", Interfaces: []string{"eth1"}}} + + cfg, err := Render(in) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !cfg.Enforcing { + t.Fatal("firewall should enforce") + } + if cfg.Generation != 7 { + t.Errorf("generation = %d, want 7", cfg.Generation) + } + if len(cfg.Rules) != 1 { + t.Fatalf("want 1 rule, got %d", len(cfg.Rules)) + } + r := cfg.Rules[0] + + // Interface-agnostic: source resolves to zone-a's subnets, no iif/oif. + if len(r.Source) != 1 || r.Source[0].Zone != "zone-a" || len(r.Source[0].Subnets) != 1 || r.Source[0].Subnets[0] != "10.1.0.0/24" { + t.Errorf("unexpected source match: %+v", r.Source) + } + // Dest is the no-subnet `net` zone gated by the asn set. + if len(r.Dest) != 1 || r.Dest[0].Zone != "net" || r.Dest[0].Set != "asn_cloudflare" { + t.Errorf("unexpected dest match: %+v", r.Dest) + } + if len(r.Dest[0].Subnets) != 0 { + t.Errorf("net should carry no subnets, got %v", r.Dest[0].Subnets) + } + if r.Proto != "tcp" || len(r.Ports) != 1 || r.Ports[0] != "443" { + t.Errorf("portgroup not resolved: proto=%q ports=%v", r.Proto, r.Ports) + } + + // The referenced asn group must be emitted as a set carrying its source ASNs. + if len(cfg.Sets) != 1 { + t.Fatalf("want 1 set, got %d", len(cfg.Sets)) + } + set := cfg.Sets[0] + if set.Name != "asn_cloudflare" || set.Kind != model.GroupASN || len(set.ASNs) != 1 || set.ASNs[0] != "13335" { + t.Errorf("unexpected set: %+v", set) + } + if set.Members != nil { + t.Errorf("asn set should not carry inline members before expansion, got %v", set.Members) + } + // Binding surfaced for the agent. + if got := cfg.Bindings["zone-a"]; len(got) != 1 || got[0] != "eth1" { + t.Errorf("binding not surfaced: %v", cfg.Bindings) + } +} + +func TestRenderTransparentRouterHasNoRules(t *testing.T) { + in := baseInput() + in.Device = model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core"} + in.Fabric = &model.Fabric{Name: "core", EnforceOnRouters: false} + + cfg, err := Render(in) + if err != nil { + t.Fatalf("Render: %v", err) + } + if cfg.Enforcing { + t.Fatal("transparent router should not enforce") + } + if len(cfg.Rules) != 0 || len(cfg.Sets) != 0 { + t.Errorf("transparent router should emit no rules/sets, got %d rules %d sets", len(cfg.Rules), len(cfg.Sets)) + } +} + +func TestRenderEnforcingRouter(t *testing.T) { + in := baseInput() + in.Device = model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core"} + in.Fabric = &model.Fabric{Name: "core", EnforceOnRouters: true} + + cfg, err := Render(in) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !cfg.Enforcing || len(cfg.Rules) != 1 { + t.Errorf("defense-in-depth router should enforce the rule: enforcing=%v rules=%d", cfg.Enforcing, len(cfg.Rules)) + } +} + +func TestRenderUnknownGroupIsError(t *testing.T) { + in := baseInput() + in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall} + in.Rules = []model.Rule{{ID: 9, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"net:+nope"}}} + if _, err := Render(in); err == nil { + t.Fatal("expected error for unknown address group") + } +} + +func TestEffectiveResolverPrefersDevice(t *testing.T) { + in := baseInput() + in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall, Resolver: []string{"10.9.9.9"}} + cfg, err := Render(in) + if err != nil { + t.Fatalf("Render: %v", err) + } + if len(cfg.Resolver) != 1 || cfg.Resolver[0] != "10.9.9.9" { + t.Errorf("device resolver should win: %v", cfg.Resolver) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 51334c7..d32e462 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -24,6 +24,23 @@ const ( GroupASN AddressGroupType = "asn" // ASNs, expanded centrally via iplocate ) +// Settings holds fleet-wide defaults. Individual devices may override a subset +// via their per-device settings. +type Settings struct { + AddressFamily string `json:"address_family"` + LogLevel string `json:"log_level"` + IPForwarding bool `json:"ip_forwarding"` + TableName string `json:"table_name"` + DefaultResolver []string `json:"default_resolver"` +} + +// PortGroup is a reusable proto+ports combo referenced by rules. +type PortGroup struct { + Name string `json:"name"` + Proto string `json:"proto"` + Ports []string `json:"ports"` +} + // Fabric is a routing domain. EnforceOnRouters toggles defense-in-depth (every // router carries the intent) vs transparent transit (only boundary firewalls do). type Fabric struct { diff --git a/internal/server/resources.go b/internal/server/resources.go index bdeb795..8b1c38e 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" + "git.unkin.net/unkin/tomswallapi/internal/compiler" "git.unkin.net/unkin/tomswallapi/internal/model" "git.unkin.net/unkin/tomswallapi/internal/store" ) @@ -36,6 +37,10 @@ func (s *Server) mountResources(r chi.Router) { r.Get("/{name}/bindings", s.listBindings) r.Put("/{name}/bindings/{zone}", s.putBinding) }) + r.Route("/portgroups", func(r chi.Router) { + r.Get("/", s.listPortGroups) + r.Put("/{name}", s.putPortGroup) + }) r.Route("/rules", func(r chi.Router) { r.Get("/", s.listRules) r.Post("/", s.createRule) @@ -43,6 +48,24 @@ func (s *Server) mountResources(r chi.Router) { }) } +func (s *Server) listPortGroups(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListPortGroups(r.Context()) + respondList(w, list, err) +} + +func (s *Server) putPortGroup(w http.ResponseWriter, r *http.Request) { + var p model.PortGroup + if !decode(w, r, &p) { + return + } + p.Name = chi.URLParam(r, "name") + if err := s.store.UpsertPortGroup(r.Context(), p); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, p) +} + func (s *Server) handleGeneration(w http.ResponseWriter, r *http.Request) { g, err := s.store.Generation(r.Context()) if err != nil { @@ -201,8 +224,24 @@ func (s *Server) deleteRule(w http.ResponseWriter, r *http.Request) { // ---- Agent endpoints ------------------------------------------------------- func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) { - // Rendering is implemented by the compiler (see task: compiler + agent endpoint). - notImplemented(w) + cfg, err := compiler.Compile(r.Context(), s.store, chi.URLParam(r, "name")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "device not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + body, err := cfg.Marshal() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", "application/yaml") + w.Header().Set("X-Tomswall-Generation", strconv.FormatInt(cfg.Generation, 10)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) } func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) { @@ -248,7 +287,3 @@ func respondList[T any](w http.ResponseWriter, list []T, err error) { } writeJSON(w, http.StatusOK, list) } - -func notImplemented(w http.ResponseWriter) { - writeError(w, http.StatusNotImplemented, "not implemented yet") -} diff --git a/internal/store/store.go b/internal/store/store.go index a01355d..e8bb6f0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -262,6 +262,94 @@ func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation return nil } +func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error) { + var d model.Device + var resolver, settings []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) + if errors.Is(err, pgx.ErrNoRows) { + return d, ErrNotFound + } + if err != nil { + return d, err + } + if err := json.Unmarshal(resolver, &d.Resolver); err != nil { + return d, err + } + return d, json.Unmarshal(settings, &d.Settings) +} + +// ---- Settings, portgroups, policies ---------------------------------------- + +func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) { + var st model.Settings + var resolver []byte + err := s.pool.QueryRow(ctx, ` + SELECT address_family, log_level, ip_forwarding, table_name, default_resolver + FROM settings WHERE id = true`, + ).Scan(&st.AddressFamily, &st.LogLevel, &st.IPForwarding, &st.TableName, &resolver) + if err != nil { + return st, err + } + return st, json.Unmarshal(resolver, &st.DefaultResolver) +} + +func (s *Store) ListPortGroups(ctx context.Context) ([]model.PortGroup, error) { + rows, err := s.pool.Query(ctx, `SELECT name, proto, ports FROM portgroups ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.PortGroup + for rows.Next() { + var p model.PortGroup + var ports []byte + if err := rows.Scan(&p.Name, &p.Proto, &ports); err != nil { + return nil, err + } + if err := json.Unmarshal(ports, &p.Ports); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) UpsertPortGroup(ctx context.Context, p model.PortGroup) error { + ports, err := jsonb(p.Ports) + if err != nil { + return err + } + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + INSERT INTO portgroups (name, proto, ports) VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE SET proto = EXCLUDED.proto, ports = EXCLUDED.ports`, + p.Name, p.Proto, ports); err != nil { + return err + } + return bump(ctx, tx) + }) +} + +func (s *Store) ListPolicies(ctx context.Context) ([]model.Policy, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, priority, source, dest, action, log FROM policies ORDER BY priority, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Policy + for rows.Next() { + var p model.Policy + if err := rows.Scan(&p.ID, &p.Priority, &p.Source, &p.Dest, &p.Action, &p.Log); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + // ---- Bindings -------------------------------------------------------------- func (s *Store) ListBindings(ctx context.Context, device string) ([]model.Binding, error) {