From b31264538700037279af8b19d40b93ef736e7c71 Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 26 Jul 2026 00:12:19 +1000 Subject: [PATCH] Add per-device routing long-tail: hosts, providers, routes, routing_rules Storage + CRUD (migration 0006, model, id-keyed store, REST handlers) plus compiler rendering: each section is owned by a device and projected into that device's rendered config (hosts/providers/routes/routing_rules). --- internal/compiler/compiler.go | 75 ++++-- internal/compiler/perdevice.go | 82 ++++++ .../migrations/0006_perdevice_routing.sql | 48 ++++ internal/model/perdevice.go | 54 ++++ internal/server/perdevice.go | 190 ++++++++++++++ internal/server/resources.go | 10 + internal/store/perdevice.go | 234 ++++++++++++++++++ 7 files changed, 667 insertions(+), 26 deletions(-) create mode 100644 internal/compiler/perdevice.go create mode 100644 internal/database/migrations/0006_perdevice_routing.sql create mode 100644 internal/model/perdevice.go create mode 100644 internal/server/perdevice.go create mode 100644 internal/store/perdevice.go diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 18053fe..925426d 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -22,36 +22,44 @@ import ( // 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 - SNAT []model.SNATRule - Netmap []model.NetmapRule - NAT []model.NATRule + 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 + SNAT []model.SNATRule + Netmap []model.NetmapRule + NAT []model.NATRule + Hosts []model.Host + Providers []model.Provider + Routes []model.Route + RoutingRules []model.RoutingRule } // 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"` - SNAT []RenderedSNAT `yaml:"snat,omitempty" json:"snat,omitempty"` - Netmap []RenderedNetmap `yaml:"netmap,omitempty" json:"netmap,omitempty"` - NAT []RenderedNAT `yaml:"nat,omitempty" json:"nat,omitempty"` + 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"` + SNAT []RenderedSNAT `yaml:"snat,omitempty" json:"snat,omitempty"` + Netmap []RenderedNetmap `yaml:"netmap,omitempty" json:"netmap,omitempty"` + NAT []RenderedNAT `yaml:"nat,omitempty" json:"nat,omitempty"` + Hosts []RenderedHost `yaml:"hosts,omitempty" json:"hosts,omitempty"` + Providers []RenderedProvider `yaml:"providers,omitempty" json:"providers,omitempty"` + Routes []RenderedRoute `yaml:"routes,omitempty" json:"routes,omitempty"` + RoutingRules []RenderedRoutingRule `yaml:"routing_rules,omitempty" json:"routing_rules,omitempty"` } // RenderedSNAT is a resolved SNAT/masquerade rule: source addresses masqueraded @@ -197,6 +205,9 @@ func Render(in Input) (*RenderedConfig, error) { out.Netmap = renderNetmapRules(in, out.Bindings) out.NAT = renderNATRules(in) + // Per-device long-tail sections owned by this device. + renderPerDevice(in, out) + return out, nil } @@ -426,5 +437,17 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi if in.NAT, err = s.ListNAT(ctx); err != nil { return nil, err } + if in.Hosts, err = s.ListHosts(ctx); err != nil { + return nil, err + } + if in.Providers, err = s.ListProviders(ctx); err != nil { + return nil, err + } + if in.Routes, err = s.ListRoutes(ctx); err != nil { + return nil, err + } + if in.RoutingRules, err = s.ListRoutingRules(ctx); err != nil { + return nil, err + } return Render(in) } diff --git a/internal/compiler/perdevice.go b/internal/compiler/perdevice.go new file mode 100644 index 0000000..ae25e69 --- /dev/null +++ b/internal/compiler/perdevice.go @@ -0,0 +1,82 @@ +package compiler + +// Per-device long-tail sections rendered into a device's config. The compiler +// filters each global list to the entries owned by the device. + +type RenderedHost struct { + Zone string `yaml:"zone" json:"zone"` + Interface string `yaml:"interface" json:"interface"` + Addresses []string `yaml:"addresses,omitempty" json:"addresses,omitempty"` + Exclusions []string `yaml:"exclusions,omitempty" json:"exclusions,omitempty"` + Dynamic bool `yaml:"dynamic,omitempty" json:"dynamic,omitempty"` +} + +type RenderedProvider struct { + Name string `yaml:"name" json:"name"` + Number int `yaml:"number" json:"number"` + Mark int `yaml:"mark,omitempty" json:"mark,omitempty"` + Duplicate string `yaml:"duplicate,omitempty" json:"duplicate,omitempty"` + Interface string `yaml:"interface" json:"interface"` + Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` + Copy []string `yaml:"copy,omitempty" json:"copy,omitempty"` +} + +type RenderedRoute struct { + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + Dest string `yaml:"dest" json:"dest"` + Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` + Oif string `yaml:"oif,omitempty" json:"oif,omitempty"` + Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedRoutingRule struct { + Source string `yaml:"source,omitempty" json:"source,omitempty"` + Dest string `yaml:"dest,omitempty" json:"dest,omitempty"` + Provider string `yaml:"provider" json:"provider"` + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"` + Mark string `yaml:"mark,omitempty" json:"mark,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +// renderPerDevice projects the per-device long-tail sections owned by this device. +func renderPerDevice(in Input, out *RenderedConfig) { + dev := in.Device.Name + for _, h := range in.Hosts { + if h.Device != dev { + continue + } + out.Hosts = append(out.Hosts, RenderedHost{ + Zone: h.Zone, Interface: h.Interface, Addresses: h.Addresses, + Exclusions: h.Exclusions, Dynamic: h.Dynamic, + }) + } + for _, p := range in.Providers { + if p.Device != dev { + continue + } + out.Providers = append(out.Providers, RenderedProvider{ + Name: p.Name, Number: p.Number, Mark: p.Mark, Duplicate: p.Duplicate, + Interface: p.Interface, Gateway: p.Gateway, Copy: p.Copy, + }) + } + for _, r := range in.Routes { + if r.Device != dev { + continue + } + out.Routes = append(out.Routes, RenderedRoute{ + Provider: r.Provider, Dest: r.Dest, Gateway: r.Gateway, + Oif: r.Oif, Persistent: r.Persistent, Comment: r.Comment, + }) + } + for _, r := range in.RoutingRules { + if r.Device != dev { + continue + } + out.RoutingRules = append(out.RoutingRules, RenderedRoutingRule{ + Source: r.Source, Dest: r.Dest, Provider: r.Provider, Priority: r.Priority, + Persistent: r.Persistent, Mark: r.Mark, Comment: r.Comment, + }) + } +} diff --git a/internal/database/migrations/0006_perdevice_routing.sql b/internal/database/migrations/0006_perdevice_routing.sql new file mode 100644 index 0000000..a1f2db5 --- /dev/null +++ b/internal/database/migrations/0006_perdevice_routing.sql @@ -0,0 +1,48 @@ +-- Per-device long-tail sections: hosts, providers (multi-ISP), static routes, +-- and routing rules (rtrules). Each is owned by a device and renders into that +-- device's config. (Nested option structs on host/provider are deferred.) + +CREATE TABLE hosts ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + zone TEXT NOT NULL, + interface TEXT NOT NULL, + addresses JSONB NOT NULL DEFAULT '[]'::jsonb, + exclusions JSONB NOT NULL DEFAULT '[]'::jsonb, + dynamic BOOLEAN NOT NULL DEFAULT false +); + +CREATE TABLE providers ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + name TEXT NOT NULL, + number INT NOT NULL, + mark INT NOT NULL DEFAULT 0, + duplicate TEXT NOT NULL DEFAULT '', + interface TEXT NOT NULL, + gateway TEXT NOT NULL DEFAULT '', + copy JSONB NOT NULL DEFAULT '[]'::jsonb +); + +CREATE TABLE routes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT '', + dest TEXT NOT NULL, + gateway TEXT NOT NULL DEFAULT '', + oif TEXT NOT NULL DEFAULT '', -- egress interface (tomswall route "device") + persistent BOOLEAN NOT NULL DEFAULT false, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE routing_rules ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT '', + dest TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + priority INT NOT NULL DEFAULT 0, + persistent BOOLEAN NOT NULL DEFAULT false, + mark TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' +); diff --git a/internal/model/perdevice.go b/internal/model/perdevice.go new file mode 100644 index 0000000..ce9e0bf --- /dev/null +++ b/internal/model/perdevice.go @@ -0,0 +1,54 @@ +package model + +// Per-device long-tail sections. Each is owned by a device (the fleet member it +// renders on) and maps to the corresponding tomswall config section. + +// Host constrains a zone to specific addresses on a device's interface. +type Host struct { + ID int64 `json:"id"` + Device string `json:"device"` + Zone string `json:"zone"` + Interface string `json:"interface"` + Addresses []string `json:"addresses,omitempty"` + Exclusions []string `json:"exclusions,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` +} + +// Provider is a multi-ISP routing provider on a device. +type Provider struct { + ID int64 `json:"id"` + Device string `json:"device"` + Name string `json:"name"` + Number int `json:"number"` + Mark int `json:"mark,omitempty"` + Duplicate string `json:"duplicate,omitempty"` + Interface string `json:"interface"` + Gateway string `json:"gateway,omitempty"` + Copy []string `json:"copy,omitempty"` +} + +// Route is a static route on a device. Oif is the egress interface (tomswall's +// route "device" field, renamed to avoid colliding with the fleet device). +type Route struct { + ID int64 `json:"id"` + Device string `json:"device"` + Provider string `json:"provider,omitempty"` + Dest string `json:"dest"` + Gateway string `json:"gateway,omitempty"` + Oif string `json:"oif,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// RoutingRule directs traffic to a provider's routing table on a device. +type RoutingRule struct { + ID int64 `json:"id"` + Device string `json:"device"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Provider string `json:"provider"` + Priority int `json:"priority,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Mark string `json:"mark,omitempty"` + Comment string `json:"comment,omitempty"` +} diff --git a/internal/server/perdevice.go b/internal/server/perdevice.go new file mode 100644 index 0000000..9f87311 --- /dev/null +++ b/internal/server/perdevice.go @@ -0,0 +1,190 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// mountPerDevice wires the per-device long-tail sections: hosts, providers, +// routes, and routing-rules. +func (s *Server) mountPerDevice(r chi.Router) { + r.Route("/hosts", func(r chi.Router) { + r.Get("/", s.listHosts) + r.Post("/", s.createHost) + r.Get("/{id}", s.getHost) + r.Delete("/{id}", s.deleteHost) + }) + r.Route("/providers", func(r chi.Router) { + r.Get("/", s.listProviders) + r.Post("/", s.createProvider) + r.Get("/{id}", s.getProvider) + r.Delete("/{id}", s.deleteProvider) + }) + r.Route("/routes", func(r chi.Router) { + r.Get("/", s.listRoutes) + r.Post("/", s.createRoute) + r.Get("/{id}", s.getRoute) + r.Delete("/{id}", s.deleteRoute) + }) + r.Route("/routing-rules", func(r chi.Router) { + r.Get("/", s.listRoutingRules) + r.Post("/", s.createRoutingRule) + r.Get("/{id}", s.getRoutingRule) + r.Delete("/{id}", s.deleteRoutingRule) + }) +} + +func (s *Server) listHosts(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListHosts(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createHost(w http.ResponseWriter, r *http.Request) { + var v model.Host + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Zone == "" || v.Interface == "" { + writeError(w, http.StatusBadRequest, "device, zone, and interface are required") + return + } + id, err := s.store.CreateHost(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} + +func (s *Server) getHost(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetHost(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteHost(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteHost(r.Context(), id)) +} + +func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListProviders(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createProvider(w http.ResponseWriter, r *http.Request) { + var v model.Provider + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Name == "" || v.Interface == "" { + writeError(w, http.StatusBadRequest, "device, name, and interface are required") + return + } + id, err := s.store.CreateProvider(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} + +func (s *Server) getProvider(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetProvider(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteProvider(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteProvider(r.Context(), id)) +} + +func (s *Server) listRoutes(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListRoutes(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createRoute(w http.ResponseWriter, r *http.Request) { + var v model.Route + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Dest == "" { + writeError(w, http.StatusBadRequest, "device and dest are required") + return + } + id, err := s.store.CreateRoute(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} + +func (s *Server) getRoute(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetRoute(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteRoute(r.Context(), id)) +} + +func (s *Server) listRoutingRules(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListRoutingRules(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createRoutingRule(w http.ResponseWriter, r *http.Request) { + var v model.RoutingRule + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Provider == "" { + writeError(w, http.StatusBadRequest, "device and provider are required") + return + } + id, err := s.store.CreateRoutingRule(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} + +func (s *Server) getRoutingRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetRoutingRule(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteRoutingRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteRoutingRule(r.Context(), id)) +} diff --git a/internal/server/resources.go b/internal/server/resources.go index 9fe7b32..db4877f 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -61,6 +61,7 @@ func (s *Server) mountResources(r chi.Router) { }) s.mountNAT(r) s.mountLongtail(r) + s.mountPerDevice(r) } // respondOne writes a single resource, mapping ErrNotFound to 404. @@ -76,6 +77,15 @@ func respondOne(w http.ResponseWriter, v any, err error) { writeJSON(w, http.StatusOK, v) } +// respondCreated writes a 201 with the created resource, or 500 on error. +func respondCreated(w http.ResponseWriter, v any, err error) { + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, v) +} + // respondDelete maps a delete result to 204/404/500. func respondDelete(w http.ResponseWriter, err error) { if err != nil { diff --git a/internal/store/perdevice.go b/internal/store/perdevice.go new file mode 100644 index 0000000..256ba19 --- /dev/null +++ b/internal/store/perdevice.go @@ -0,0 +1,234 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// ---- Hosts ----------------------------------------------------------------- + +func (s *Store) ListHosts(ctx context.Context) ([]model.Host, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, zone, interface, addresses, exclusions, dynamic FROM hosts ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Host + for rows.Next() { + h, err := scanHost(rows) + if err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + +func (s *Store) GetHost(ctx context.Context, id int64) (model.Host, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, zone, interface, addresses, exclusions, dynamic FROM hosts WHERE id = $1`, id) + if err != nil { + return model.Host{}, err + } + defer rows.Close() + if !rows.Next() { + return model.Host{}, ErrNotFound + } + return scanHost(rows) +} + +func scanHost(rows pgx.Rows) (model.Host, error) { + var h model.Host + var addrs, excl []byte + if err := rows.Scan(&h.ID, &h.Device, &h.Zone, &h.Interface, &addrs, &excl, &h.Dynamic); err != nil { + return h, err + } + if err := unmarshalStrings(addrs, &h.Addresses); err != nil { + return h, err + } + return h, unmarshalStrings(excl, &h.Exclusions) +} + +func (s *Store) CreateHost(ctx context.Context, h model.Host) (int64, error) { + addrs, _ := jsonb(h.Addresses) + excl, _ := jsonb(h.Exclusions) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO hosts (device, zone, interface, addresses, exclusions, dynamic) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + h.Device, h.Zone, h.Interface, addrs, excl, h.Dynamic).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteHost(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM hosts WHERE id = $1`, id) +} + +// ---- Providers ------------------------------------------------------------- + +func (s *Store) ListProviders(ctx context.Context) ([]model.Provider, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, name, number, mark, duplicate, interface, gateway, copy FROM providers ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Provider + for rows.Next() { + p, err := scanProvider(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) GetProvider(ctx context.Context, id int64) (model.Provider, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, name, number, mark, duplicate, interface, gateway, copy FROM providers WHERE id = $1`, id) + if err != nil { + return model.Provider{}, err + } + defer rows.Close() + if !rows.Next() { + return model.Provider{}, ErrNotFound + } + return scanProvider(rows) +} + +func scanProvider(rows pgx.Rows) (model.Provider, error) { + var p model.Provider + var cp []byte + if err := rows.Scan(&p.ID, &p.Device, &p.Name, &p.Number, &p.Mark, &p.Duplicate, &p.Interface, &p.Gateway, &cp); err != nil { + return p, err + } + return p, unmarshalStrings(cp, &p.Copy) +} + +func (s *Store) CreateProvider(ctx context.Context, p model.Provider) (int64, error) { + cp, _ := jsonb(p.Copy) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO providers (device, name, number, mark, duplicate, interface, gateway, copy) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + p.Device, p.Name, p.Number, p.Mark, p.Duplicate, p.Interface, p.Gateway, cp).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteProvider(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM providers WHERE id = $1`, id) +} + +// ---- Routes ---------------------------------------------------------------- + +func (s *Store) ListRoutes(ctx context.Context) ([]model.Route, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, provider, dest, gateway, oif, persistent, comment FROM routes ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Route + for rows.Next() { + var r model.Route + if err := rows.Scan(&r.ID, &r.Device, &r.Provider, &r.Dest, &r.Gateway, &r.Oif, &r.Persistent, &r.Comment); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetRoute(ctx context.Context, id int64) (model.Route, error) { + var r model.Route + err := s.pool.QueryRow(ctx, + `SELECT id, device, provider, dest, gateway, oif, persistent, comment FROM routes WHERE id = $1`, id, + ).Scan(&r.ID, &r.Device, &r.Provider, &r.Dest, &r.Gateway, &r.Oif, &r.Persistent, &r.Comment) + if errors.Is(err, pgx.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +func (s *Store) CreateRoute(ctx context.Context, r model.Route) (int64, error) { + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO routes (device, provider, dest, gateway, oif, persistent, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + r.Device, r.Provider, r.Dest, r.Gateway, r.Oif, r.Persistent, r.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteRoute(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM routes WHERE id = $1`, id) +} + +// ---- Routing rules --------------------------------------------------------- + +func (s *Store) ListRoutingRules(ctx context.Context) ([]model.RoutingRule, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, source, dest, provider, priority, persistent, mark, comment FROM routing_rules ORDER BY priority, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.RoutingRule + for rows.Next() { + var r model.RoutingRule + if err := rows.Scan(&r.ID, &r.Device, &r.Source, &r.Dest, &r.Provider, &r.Priority, &r.Persistent, &r.Mark, &r.Comment); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetRoutingRule(ctx context.Context, id int64) (model.RoutingRule, error) { + var r model.RoutingRule + err := s.pool.QueryRow(ctx, + `SELECT id, device, source, dest, provider, priority, persistent, mark, comment FROM routing_rules WHERE id = $1`, id, + ).Scan(&r.ID, &r.Device, &r.Source, &r.Dest, &r.Provider, &r.Priority, &r.Persistent, &r.Mark, &r.Comment) + if errors.Is(err, pgx.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +func (s *Store) CreateRoutingRule(ctx context.Context, r model.RoutingRule) (int64, error) { + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO routing_rules (device, source, dest, provider, priority, persistent, mark, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + r.Device, r.Source, r.Dest, r.Provider, r.Priority, r.Persistent, r.Mark, r.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteRoutingRule(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM routing_rules WHERE id = $1`, id) +}