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.
This commit is contained in:
benvin
2026-07-19 18:38:52 +10:00
parent c32fe8bd76
commit f80cc2cc30
7 changed files with 600 additions and 6 deletions
+3
View File
@@ -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
)
+9
View File
@@ -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=
+313
View File
@@ -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)
}
+129
View File
@@ -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)
}
}
+17
View File
@@ -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 {
+41 -6
View File
@@ -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")
}
+88
View File
@@ -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) {