From d9d192757bc5e8b1925e9e171d7c2003aa31cfea Mon Sep 17 00:00:00 2001 From: benvin Date: Sun, 26 Jul 2026 15:16:13 +1000 Subject: [PATCH] Add per-device L2/misc long-tail: tunnels/stopped_rules/proxy_arp/proxy_ndp/arp_rules/maclist Storage + CRUD (migration 0007, model, id-keyed store, REST handlers) + compiler rendering, each owned by a device. proxy_arp/proxy_ndp share the ProxyEntry shape via table-parameterized store helpers. --- internal/compiler/compiler.go | 31 ++ internal/compiler/perdevice_l2.go | 111 ++++++ .../database/migrations/0007_perdevice_l2.sql | 70 ++++ internal/model/perdevice_l2.go | 61 ++++ internal/server/perdevice_l2.go | 234 +++++++++++++ internal/server/resources.go | 1 + internal/store/perdevice_l2.go | 320 ++++++++++++++++++ 7 files changed, 828 insertions(+) create mode 100644 internal/compiler/perdevice_l2.go create mode 100644 internal/database/migrations/0007_perdevice_l2.sql create mode 100644 internal/model/perdevice_l2.go create mode 100644 internal/server/perdevice_l2.go create mode 100644 internal/store/perdevice_l2.go diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 925426d..153b23a 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -39,6 +39,12 @@ type Input struct { Providers []model.Provider Routes []model.Route RoutingRules []model.RoutingRule + Tunnels []model.Tunnel + StoppedRules []model.StoppedRule + ProxyARP []model.ProxyEntry + ProxyNDP []model.ProxyEntry + ArpRules []model.ArpRule + Maclist []model.MaclistEntry } // RenderedConfig is the per-device output served to the agent. @@ -60,6 +66,12 @@ type RenderedConfig struct { 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"` + Tunnels []RenderedTunnel `yaml:"tunnels,omitempty" json:"tunnels,omitempty"` + StoppedRules []RenderedStoppedRule `yaml:"stopped_rules,omitempty" json:"stopped_rules,omitempty"` + ProxyARP []RenderedProxy `yaml:"proxy_arp,omitempty" json:"proxy_arp,omitempty"` + ProxyNDP []RenderedProxy `yaml:"proxy_ndp,omitempty" json:"proxy_ndp,omitempty"` + ArpRules []RenderedArpRule `yaml:"arp_rules,omitempty" json:"arp_rules,omitempty"` + Maclist []RenderedMaclist `yaml:"maclist,omitempty" json:"maclist,omitempty"` } // RenderedSNAT is a resolved SNAT/masquerade rule: source addresses masqueraded @@ -207,6 +219,7 @@ func Render(in Input) (*RenderedConfig, error) { // Per-device long-tail sections owned by this device. renderPerDevice(in, out) + renderPerDeviceL2(in, out) return out, nil } @@ -449,5 +462,23 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi if in.RoutingRules, err = s.ListRoutingRules(ctx); err != nil { return nil, err } + if in.Tunnels, err = s.ListTunnels(ctx); err != nil { + return nil, err + } + if in.StoppedRules, err = s.ListStoppedRules(ctx); err != nil { + return nil, err + } + if in.ProxyARP, err = s.ListProxyARP(ctx); err != nil { + return nil, err + } + if in.ProxyNDP, err = s.ListProxyNDP(ctx); err != nil { + return nil, err + } + if in.ArpRules, err = s.ListArpRules(ctx); err != nil { + return nil, err + } + if in.Maclist, err = s.ListMaclist(ctx); err != nil { + return nil, err + } return Render(in) } diff --git a/internal/compiler/perdevice_l2.go b/internal/compiler/perdevice_l2.go new file mode 100644 index 0000000..59c285a --- /dev/null +++ b/internal/compiler/perdevice_l2.go @@ -0,0 +1,111 @@ +package compiler + +import "git.unkin.net/unkin/tomswallapi/internal/model" + +// Per-device L2/misc long-tail sections rendered into a device's config. + +type RenderedTunnel struct { + Type string `yaml:"type" json:"type"` + Zone string `yaml:"zone" json:"zone"` + Gateways []string `yaml:"gateways,omitempty" json:"gateways,omitempty"` + GatewayZones []string `yaml:"gateway_zones,omitempty" json:"gateway_zones,omitempty"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedStoppedRule struct { + Action string `yaml:"action" json:"action"` + Source string `yaml:"source,omitempty" json:"source,omitempty"` + Dest string `yaml:"dest,omitempty" json:"dest,omitempty"` + Proto string `yaml:"proto,omitempty" json:"proto,omitempty"` + DPort []string `yaml:"dport,omitempty" json:"dport,omitempty"` + SPort []string `yaml:"sport,omitempty" json:"sport,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedProxy struct { + Address string `yaml:"address" json:"address"` + Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` + External string `yaml:"external" json:"external"` + HaveRoute bool `yaml:"haveroute,omitempty" json:"haveroute,omitempty"` + Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedArpRule struct { + Action string `yaml:"action" json:"action"` + ActionAddress string `yaml:"action_address,omitempty" json:"action_address,omitempty"` + ActionMAC string `yaml:"action_mac,omitempty" json:"action_mac,omitempty"` + Source string `yaml:"source,omitempty" json:"source,omitempty"` + Dest string `yaml:"dest,omitempty" json:"dest,omitempty"` + Opcode int `yaml:"opcode,omitempty" json:"opcode,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +type RenderedMaclist struct { + Action string `yaml:"action" json:"action"` + Interface string `yaml:"interface" json:"interface"` + MAC string `yaml:"mac,omitempty" json:"mac,omitempty"` + Addresses []string `yaml:"addresses,omitempty" json:"addresses,omitempty"` + Log string `yaml:"log,omitempty" json:"log,omitempty"` + Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` +} + +func renderPerDeviceL2(in Input, out *RenderedConfig) { + dev := in.Device.Name + for _, t := range in.Tunnels { + if t.Device != dev { + continue + } + out.Tunnels = append(out.Tunnels, RenderedTunnel{ + Type: t.Type, Zone: t.Zone, Gateways: t.Gateways, + GatewayZones: t.GatewayZones, Port: t.Port, Comment: t.Comment, + }) + } + for _, r := range in.StoppedRules { + if r.Device != dev { + continue + } + out.StoppedRules = append(out.StoppedRules, RenderedStoppedRule{ + Action: r.Action, Source: r.Source, Dest: r.Dest, Proto: r.Proto, + DPort: r.DPort, SPort: r.SPort, Comment: r.Comment, + }) + } + for _, p := range in.ProxyARP { + if p.Device != dev { + continue + } + out.ProxyARP = append(out.ProxyARP, renderProxy(p)) + } + for _, p := range in.ProxyNDP { + if p.Device != dev { + continue + } + out.ProxyNDP = append(out.ProxyNDP, renderProxy(p)) + } + for _, a := range in.ArpRules { + if a.Device != dev { + continue + } + out.ArpRules = append(out.ArpRules, RenderedArpRule{ + Action: a.Action, ActionAddress: a.ActionAddress, ActionMAC: a.ActionMAC, + Source: a.Source, Dest: a.Dest, Opcode: a.Opcode, Comment: a.Comment, + }) + } + for _, m := range in.Maclist { + if m.Device != dev { + continue + } + out.Maclist = append(out.Maclist, RenderedMaclist{ + Action: m.Action, Interface: m.Interface, MAC: m.MAC, + Addresses: m.Addresses, Log: m.Log, Comment: m.Comment, + }) + } +} + +func renderProxy(p model.ProxyEntry) RenderedProxy { + return RenderedProxy{ + Address: p.Address, Interface: p.Interface, External: p.External, + HaveRoute: p.HaveRoute, Persistent: p.Persistent, Comment: p.Comment, + } +} diff --git a/internal/database/migrations/0007_perdevice_l2.sql b/internal/database/migrations/0007_perdevice_l2.sql new file mode 100644 index 0000000..24b4059 --- /dev/null +++ b/internal/database/migrations/0007_perdevice_l2.sql @@ -0,0 +1,70 @@ +-- Per-device L2/misc long-tail sections: tunnels, stopped_rules, proxy_arp, +-- proxy_ndp, arp_rules, maclist. Each is owned by a device. + +CREATE TABLE tunnels ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + type TEXT NOT NULL, + zone TEXT NOT NULL, + gateways JSONB NOT NULL DEFAULT '[]'::jsonb, + gateway_zones JSONB NOT NULL DEFAULT '[]'::jsonb, + port INT NOT NULL DEFAULT 0, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE stopped_rules ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + action TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + dest TEXT NOT NULL DEFAULT '', + proto TEXT NOT NULL DEFAULT '', + dport JSONB NOT NULL DEFAULT '[]'::jsonb, + sport JSONB NOT NULL DEFAULT '[]'::jsonb, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE proxy_arp ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + address TEXT NOT NULL, + interface TEXT NOT NULL DEFAULT '', + external TEXT NOT NULL, + haveroute BOOLEAN NOT NULL DEFAULT false, + persistent BOOLEAN NOT NULL DEFAULT false, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE proxy_ndp ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + address TEXT NOT NULL, + interface TEXT NOT NULL DEFAULT '', + external TEXT NOT NULL, + haveroute BOOLEAN NOT NULL DEFAULT false, + persistent BOOLEAN NOT NULL DEFAULT false, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE arp_rules ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + action TEXT NOT NULL, + action_address TEXT NOT NULL DEFAULT '', + action_mac TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + dest TEXT NOT NULL DEFAULT '', + opcode INT NOT NULL DEFAULT 0, + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE maclist ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE, + action TEXT NOT NULL, + interface TEXT NOT NULL, + mac TEXT NOT NULL DEFAULT '', + addresses JSONB NOT NULL DEFAULT '[]'::jsonb, + log TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' +); diff --git a/internal/model/perdevice_l2.go b/internal/model/perdevice_l2.go new file mode 100644 index 0000000..cace0e2 --- /dev/null +++ b/internal/model/perdevice_l2.go @@ -0,0 +1,61 @@ +package model + +// Per-device L2/misc long-tail sections. + +type Tunnel struct { + ID int64 `json:"id"` + Device string `json:"device"` + Type string `json:"type"` + Zone string `json:"zone"` + Gateways []string `json:"gateways,omitempty"` + GatewayZones []string `json:"gateway_zones,omitempty"` + Port int `json:"port,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type StoppedRule struct { + ID int64 `json:"id"` + Device string `json:"device"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// ProxyEntry backs both proxy_arp and proxy_ndp (identical shape). +type ProxyEntry struct { + ID int64 `json:"id"` + Device string `json:"device"` + Address string `json:"address"` + Interface string `json:"interface,omitempty"` + External string `json:"external"` + HaveRoute bool `json:"haveroute,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type ArpRule struct { + ID int64 `json:"id"` + Device string `json:"device"` + Action string `json:"action"` + ActionAddress string `json:"action_address,omitempty"` + ActionMAC string `json:"action_mac,omitempty"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Opcode int `json:"opcode,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type MaclistEntry struct { + ID int64 `json:"id"` + Device string `json:"device"` + Action string `json:"action"` + Interface string `json:"interface"` + MAC string `json:"mac,omitempty"` + Addresses []string `json:"addresses,omitempty"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} diff --git a/internal/server/perdevice_l2.go b/internal/server/perdevice_l2.go new file mode 100644 index 0000000..1dfee9f --- /dev/null +++ b/internal/server/perdevice_l2.go @@ -0,0 +1,234 @@ +package server + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// mountPerDeviceL2 wires the per-device L2/misc sections: tunnels, stopped_rules, +// proxy_arp, proxy_ndp, arp_rules, maclist. +func (s *Server) mountPerDeviceL2(r chi.Router) { + idCRUD(r, "/tunnels", s.listTunnels, s.createTunnel, s.getTunnel, s.deleteTunnel) + idCRUD(r, "/stopped-rules", s.listStoppedRules, s.createStoppedRule, s.getStoppedRule, s.deleteStoppedRule) + idCRUD(r, "/proxy-arp", s.listProxyARP, s.createProxyARP, s.getProxyARP, s.deleteProxyARP) + idCRUD(r, "/proxy-ndp", s.listProxyNDP, s.createProxyNDP, s.getProxyNDP, s.deleteProxyNDP) + idCRUD(r, "/arp-rules", s.listArpRules, s.createArpRule, s.getArpRule, s.deleteArpRule) + idCRUD(r, "/maclist", s.listMaclist, s.createMaclist, s.getMaclist, s.deleteMaclist) +} + +// idCRUD wires the four standard id-keyed handlers for a collection. +func idCRUD(r chi.Router, path string, list, create, get, del http.HandlerFunc) { + r.Route(path, func(r chi.Router) { + r.Get("/", list) + r.Post("/", create) + r.Get("/{id}", get) + r.Delete("/{id}", del) + }) +} + +func (s *Server) listTunnels(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListTunnels(r.Context()) + respondList(w, list, err) +} +func (s *Server) createTunnel(w http.ResponseWriter, r *http.Request) { + var v model.Tunnel + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Type == "" || v.Zone == "" { + writeError(w, http.StatusBadRequest, "device, type, and zone are required") + return + } + id, err := s.store.CreateTunnel(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} +func (s *Server) getTunnel(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetTunnel(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteTunnel(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteTunnel(r.Context(), id)) +} + +func (s *Server) listStoppedRules(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListStoppedRules(r.Context()) + respondList(w, list, err) +} +func (s *Server) createStoppedRule(w http.ResponseWriter, r *http.Request) { + var v model.StoppedRule + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Action == "" { + writeError(w, http.StatusBadRequest, "device and action are required") + return + } + id, err := s.store.CreateStoppedRule(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} +func (s *Server) getStoppedRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetStoppedRule(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteStoppedRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteStoppedRule(r.Context(), id)) +} + +func (s *Server) listProxyARP(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListProxyARP(r.Context()) + respondList(w, list, err) +} +func (s *Server) createProxyARP(w http.ResponseWriter, r *http.Request) { + s.createProxy(w, r, s.store.CreateProxyARP) +} +func (s *Server) getProxyARP(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetProxyARP(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteProxyARP(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteProxyARP(r.Context(), id)) +} + +func (s *Server) listProxyNDP(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListProxyNDP(r.Context()) + respondList(w, list, err) +} +func (s *Server) createProxyNDP(w http.ResponseWriter, r *http.Request) { + s.createProxy(w, r, s.store.CreateProxyNDP) +} +func (s *Server) getProxyNDP(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetProxyNDP(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteProxyNDP(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteProxyNDP(r.Context(), id)) +} + +// createProxy is shared by proxy_arp/ndp (identical shape). +func (s *Server) createProxy(w http.ResponseWriter, r *http.Request, create func(context.Context, model.ProxyEntry) (int64, error)) { + var v model.ProxyEntry + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Address == "" || v.External == "" { + writeError(w, http.StatusBadRequest, "device, address, and external are required") + return + } + id, err := create(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} + +func (s *Server) listArpRules(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListArpRules(r.Context()) + respondList(w, list, err) +} +func (s *Server) createArpRule(w http.ResponseWriter, r *http.Request) { + var v model.ArpRule + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Action == "" { + writeError(w, http.StatusBadRequest, "device and action are required") + return + } + id, err := s.store.CreateArpRule(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} +func (s *Server) getArpRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetArpRule(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteArpRule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteArpRule(r.Context(), id)) +} + +func (s *Server) listMaclist(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListMaclist(r.Context()) + respondList(w, list, err) +} +func (s *Server) createMaclist(w http.ResponseWriter, r *http.Request) { + var v model.MaclistEntry + if !decode(w, r, &v) { + return + } + if v.Device == "" || v.Action == "" || v.Interface == "" { + writeError(w, http.StatusBadRequest, "device, action, and interface are required") + return + } + id, err := s.store.CreateMaclist(r.Context(), v) + if err == nil { + v.ID = id + } + respondCreated(w, v, err) +} +func (s *Server) getMaclist(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetMaclist(r.Context(), id) + respondOne(w, v, err) +} +func (s *Server) deleteMaclist(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteMaclist(r.Context(), id)) +} diff --git a/internal/server/resources.go b/internal/server/resources.go index db4877f..8384baf 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -62,6 +62,7 @@ func (s *Server) mountResources(r chi.Router) { s.mountNAT(r) s.mountLongtail(r) s.mountPerDevice(r) + s.mountPerDeviceL2(r) } // respondOne writes a single resource, mapping ErrNotFound to 404. diff --git a/internal/store/perdevice_l2.go b/internal/store/perdevice_l2.go new file mode 100644 index 0000000..14272d2 --- /dev/null +++ b/internal/store/perdevice_l2.go @@ -0,0 +1,320 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// ---- Tunnels --------------------------------------------------------------- + +func (s *Store) ListTunnels(ctx context.Context) ([]model.Tunnel, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, type, zone, gateways, gateway_zones, port, comment FROM tunnels ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Tunnel + for rows.Next() { + t, err := scanTunnel(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) GetTunnel(ctx context.Context, id int64) (model.Tunnel, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, type, zone, gateways, gateway_zones, port, comment FROM tunnels WHERE id = $1`, id) + if err != nil { + return model.Tunnel{}, err + } + defer rows.Close() + if !rows.Next() { + return model.Tunnel{}, ErrNotFound + } + return scanTunnel(rows) +} + +func scanTunnel(rows pgx.Rows) (model.Tunnel, error) { + var t model.Tunnel + var gw, gz []byte + if err := rows.Scan(&t.ID, &t.Device, &t.Type, &t.Zone, &gw, &gz, &t.Port, &t.Comment); err != nil { + return t, err + } + if err := unmarshalStrings(gw, &t.Gateways); err != nil { + return t, err + } + return t, unmarshalStrings(gz, &t.GatewayZones) +} + +func (s *Store) CreateTunnel(ctx context.Context, t model.Tunnel) (int64, error) { + gw, _ := jsonb(t.Gateways) + gz, _ := jsonb(t.GatewayZones) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO tunnels (device, type, zone, gateways, gateway_zones, port, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + t.Device, t.Type, t.Zone, gw, gz, t.Port, t.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteTunnel(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM tunnels WHERE id = $1`, id) +} + +// ---- Stopped rules --------------------------------------------------------- + +func (s *Store) ListStoppedRules(ctx context.Context) ([]model.StoppedRule, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, action, source, dest, proto, dport, sport, comment FROM stopped_rules ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.StoppedRule + for rows.Next() { + r, err := scanStopped(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetStoppedRule(ctx context.Context, id int64) (model.StoppedRule, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, action, source, dest, proto, dport, sport, comment FROM stopped_rules WHERE id = $1`, id) + if err != nil { + return model.StoppedRule{}, err + } + defer rows.Close() + if !rows.Next() { + return model.StoppedRule{}, ErrNotFound + } + return scanStopped(rows) +} + +func scanStopped(rows pgx.Rows) (model.StoppedRule, error) { + var r model.StoppedRule + var dport, sport []byte + if err := rows.Scan(&r.ID, &r.Device, &r.Action, &r.Source, &r.Dest, &r.Proto, &dport, &sport, &r.Comment); err != nil { + return r, err + } + if err := unmarshalStrings(dport, &r.DPort); err != nil { + return r, err + } + return r, unmarshalStrings(sport, &r.SPort) +} + +func (s *Store) CreateStoppedRule(ctx context.Context, r model.StoppedRule) (int64, error) { + dport, _ := jsonb(r.DPort) + sport, _ := jsonb(r.SPort) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO stopped_rules (device, action, source, dest, proto, dport, sport, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + r.Device, r.Action, r.Source, r.Dest, r.Proto, dport, sport, r.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteStoppedRule(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM stopped_rules WHERE id = $1`, id) +} + +// ---- Proxy ARP / NDP (identical shape, table-parameterized) ---------------- + +func (s *Store) listProxy(ctx context.Context, table string) ([]model.ProxyEntry, error) { + q := fmt.Sprintf(`SELECT id, device, address, interface, external, haveroute, persistent, comment FROM %s ORDER BY id`, table) + rows, err := s.pool.Query(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.ProxyEntry + for rows.Next() { + var p model.ProxyEntry + if err := rows.Scan(&p.ID, &p.Device, &p.Address, &p.Interface, &p.External, &p.HaveRoute, &p.Persistent, &p.Comment); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) getProxy(ctx context.Context, table string, id int64) (model.ProxyEntry, error) { + q := fmt.Sprintf(`SELECT id, device, address, interface, external, haveroute, persistent, comment FROM %s WHERE id = $1`, table) + var p model.ProxyEntry + err := s.pool.QueryRow(ctx, q, id).Scan(&p.ID, &p.Device, &p.Address, &p.Interface, &p.External, &p.HaveRoute, &p.Persistent, &p.Comment) + if errors.Is(err, pgx.ErrNoRows) { + return p, ErrNotFound + } + return p, err +} + +func (s *Store) createProxy(ctx context.Context, table string, p model.ProxyEntry) (int64, error) { + q := fmt.Sprintf(`INSERT INTO %s (device, address, interface, external, haveroute, persistent, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, table) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, q, p.Device, p.Address, p.Interface, p.External, p.HaveRoute, p.Persistent, p.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) ListProxyARP(ctx context.Context) ([]model.ProxyEntry, error) { + return s.listProxy(ctx, "proxy_arp") +} +func (s *Store) GetProxyARP(ctx context.Context, id int64) (model.ProxyEntry, error) { + return s.getProxy(ctx, "proxy_arp", id) +} +func (s *Store) CreateProxyARP(ctx context.Context, p model.ProxyEntry) (int64, error) { + return s.createProxy(ctx, "proxy_arp", p) +} +func (s *Store) DeleteProxyARP(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM proxy_arp WHERE id = $1`, id) +} + +func (s *Store) ListProxyNDP(ctx context.Context) ([]model.ProxyEntry, error) { + return s.listProxy(ctx, "proxy_ndp") +} +func (s *Store) GetProxyNDP(ctx context.Context, id int64) (model.ProxyEntry, error) { + return s.getProxy(ctx, "proxy_ndp", id) +} +func (s *Store) CreateProxyNDP(ctx context.Context, p model.ProxyEntry) (int64, error) { + return s.createProxy(ctx, "proxy_ndp", p) +} +func (s *Store) DeleteProxyNDP(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM proxy_ndp WHERE id = $1`, id) +} + +// ---- ARP rules ------------------------------------------------------------- + +func (s *Store) ListArpRules(ctx context.Context) ([]model.ArpRule, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, action, action_address, action_mac, source, dest, opcode, comment FROM arp_rules ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.ArpRule + for rows.Next() { + var r model.ArpRule + if err := rows.Scan(&r.ID, &r.Device, &r.Action, &r.ActionAddress, &r.ActionMAC, &r.Source, &r.Dest, &r.Opcode, &r.Comment); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetArpRule(ctx context.Context, id int64) (model.ArpRule, error) { + var r model.ArpRule + err := s.pool.QueryRow(ctx, + `SELECT id, device, action, action_address, action_mac, source, dest, opcode, comment FROM arp_rules WHERE id = $1`, id, + ).Scan(&r.ID, &r.Device, &r.Action, &r.ActionAddress, &r.ActionMAC, &r.Source, &r.Dest, &r.Opcode, &r.Comment) + if errors.Is(err, pgx.ErrNoRows) { + return r, ErrNotFound + } + return r, err +} + +func (s *Store) CreateArpRule(ctx context.Context, r model.ArpRule) (int64, error) { + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO arp_rules (device, action, action_address, action_mac, source, dest, opcode, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + r.Device, r.Action, r.ActionAddress, r.ActionMAC, r.Source, r.Dest, r.Opcode, r.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteArpRule(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM arp_rules WHERE id = $1`, id) +} + +// ---- Maclist --------------------------------------------------------------- + +func (s *Store) ListMaclist(ctx context.Context) ([]model.MaclistEntry, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, action, interface, mac, addresses, log, comment FROM maclist ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.MaclistEntry + for rows.Next() { + m, err := scanMaclist(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) GetMaclist(ctx context.Context, id int64) (model.MaclistEntry, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, device, action, interface, mac, addresses, log, comment FROM maclist WHERE id = $1`, id) + if err != nil { + return model.MaclistEntry{}, err + } + defer rows.Close() + if !rows.Next() { + return model.MaclistEntry{}, ErrNotFound + } + return scanMaclist(rows) +} + +func scanMaclist(rows pgx.Rows) (model.MaclistEntry, error) { + var m model.MaclistEntry + var addrs []byte + if err := rows.Scan(&m.ID, &m.Device, &m.Action, &m.Interface, &m.MAC, &addrs, &m.Log, &m.Comment); err != nil { + return m, err + } + return m, unmarshalStrings(addrs, &m.Addresses) +} + +func (s *Store) CreateMaclist(ctx context.Context, m model.MaclistEntry) (int64, error) { + addrs, _ := jsonb(m.Addresses) + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO maclist (device, action, interface, mac, addresses, log, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + m.Device, m.Action, m.Interface, m.MAC, addrs, m.Log, m.Comment).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteMaclist(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM maclist WHERE id = $1`, id) +}