From 98b8295f4ab5e2ae733c43250ac7cc0ea5968144 Mon Sep 17 00:00:00 2001 From: benvin Date: Sat, 25 Jul 2026 23:18:24 +1000 Subject: [PATCH] Add global-tier long-tail resources: policy CRUD, blrules, conntrack - Wire policy CRUD (the policies table existed from 0001 but had no endpoint; policies already render in the compiler, so this closes that loop). - Add blrules (blacklist/whitelist) and conntrack resources: migration 0005, model types, id-keyed store CRUD (generation-bumping), and REST handlers. Compiler rendering of blrules/conntrack into device configs is a tracked follow-up (policy already renders). --- .../migrations/0005_global_longtail.sql | 32 ++++ internal/model/longtail.go | 31 +++ internal/server/longtail.go | 158 ++++++++++++++++ internal/server/resources.go | 1 + internal/store/longtail.go | 178 ++++++++++++++++++ internal/store/store.go | 5 + 6 files changed, 405 insertions(+) create mode 100644 internal/database/migrations/0005_global_longtail.sql create mode 100644 internal/model/longtail.go create mode 100644 internal/server/longtail.go create mode 100644 internal/store/longtail.go diff --git a/internal/database/migrations/0005_global_longtail.sql b/internal/database/migrations/0005_global_longtail.sql new file mode 100644 index 0000000..88390a9 --- /dev/null +++ b/internal/database/migrations/0005_global_longtail.sql @@ -0,0 +1,32 @@ +-- Global-compiled long-tail sections: blrules (blacklist/whitelist, processed +-- before normal rules) and conntrack (connection-tracking control). The policies +-- table already exists (migration 0001); this migration only adds the two new +-- tables — policy CRUD is wired on the existing table. + +CREATE TABLE blrules ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + priority INT NOT NULL DEFAULT 0, + 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, + log TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE conntrack ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + priority INT NOT NULL DEFAULT 0, + 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, + chain TEXT NOT NULL DEFAULT '', + helper TEXT NOT NULL DEFAULT '', + "user" TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' +); diff --git a/internal/model/longtail.go b/internal/model/longtail.go new file mode 100644 index 0000000..37d385e --- /dev/null +++ b/internal/model/longtail.go @@ -0,0 +1,31 @@ +package model + +// BlruleRule is a blacklist/whitelist rule, processed before normal rules. +type BlruleRule struct { + ID int64 `json:"id"` + Priority int `json:"priority"` + 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"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// ConntrackRule controls connection tracking (notrack, helper assignment). +type ConntrackRule struct { + ID int64 `json:"id"` + Priority int `json:"priority"` + 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"` + Chain string `json:"chain,omitempty"` + Helper string `json:"helper,omitempty"` + User string `json:"user,omitempty"` + Comment string `json:"comment,omitempty"` +} diff --git a/internal/server/longtail.go b/internal/server/longtail.go new file mode 100644 index 0000000..122c051 --- /dev/null +++ b/internal/server/longtail.go @@ -0,0 +1,158 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// mountLongtail wires the global-compiled long-tail resources: policies (default +// posture), blrules (blacklist/whitelist), and conntrack. +func (s *Server) mountLongtail(r chi.Router) { + r.Route("/policies", func(r chi.Router) { + r.Get("/", s.listPolicies) + r.Post("/", s.createPolicy) + r.Get("/{id}", s.getPolicy) + r.Delete("/{id}", s.deletePolicy) + }) + r.Route("/blrules", func(r chi.Router) { + r.Get("/", s.listBlrules) + r.Post("/", s.createBlrule) + r.Get("/{id}", s.getBlrule) + r.Delete("/{id}", s.deleteBlrule) + }) + r.Route("/conntrack", func(r chi.Router) { + r.Get("/", s.listConntrack) + r.Post("/", s.createConntrack) + r.Get("/{id}", s.getConntrack) + r.Delete("/{id}", s.deleteConntrack) + }) +} + +// ---- Policies -------------------------------------------------------------- + +func (s *Server) listPolicies(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListPolicies(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createPolicy(w http.ResponseWriter, r *http.Request) { + var v model.Policy + if !decode(w, r, &v) { + return + } + if v.Action == "" { + writeError(w, http.StatusBadRequest, "action is required") + return + } + id, err := s.store.CreatePolicy(r.Context(), v) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + v.ID = id + writeJSON(w, http.StatusCreated, v) +} + +func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetPolicy(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeletePolicy(r.Context(), id)) +} + +// ---- Blrules --------------------------------------------------------------- + +func (s *Server) listBlrules(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListBlrules(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createBlrule(w http.ResponseWriter, r *http.Request) { + var v model.BlruleRule + if !decode(w, r, &v) { + return + } + if v.Action == "" { + writeError(w, http.StatusBadRequest, "action is required") + return + } + id, err := s.store.CreateBlrule(r.Context(), v) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + v.ID = id + writeJSON(w, http.StatusCreated, v) +} + +func (s *Server) getBlrule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetBlrule(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteBlrule(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteBlrule(r.Context(), id)) +} + +// ---- Conntrack ------------------------------------------------------------- + +func (s *Server) listConntrack(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListConntrack(r.Context()) + respondList(w, list, err) +} + +func (s *Server) createConntrack(w http.ResponseWriter, r *http.Request) { + var v model.ConntrackRule + if !decode(w, r, &v) { + return + } + if v.Action == "" { + writeError(w, http.StatusBadRequest, "action is required") + return + } + id, err := s.store.CreateConntrack(r.Context(), v) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + v.ID = id + writeJSON(w, http.StatusCreated, v) +} + +func (s *Server) getConntrack(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + v, err := s.store.GetConntrack(r.Context(), id) + respondOne(w, v, err) +} + +func (s *Server) deleteConntrack(w http.ResponseWriter, r *http.Request) { + id, ok := idParam(w, r) + if !ok { + return + } + respondDelete(w, s.store.DeleteConntrack(r.Context(), id)) +} diff --git a/internal/server/resources.go b/internal/server/resources.go index 3e7e1a6..9fe7b32 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -60,6 +60,7 @@ func (s *Server) mountResources(r chi.Router) { r.Delete("/{id}", s.deleteRule) }) s.mountNAT(r) + s.mountLongtail(r) } // respondOne writes a single resource, mapping ErrNotFound to 404. diff --git a/internal/store/longtail.go b/internal/store/longtail.go new file mode 100644 index 0000000..7a2d400 --- /dev/null +++ b/internal/store/longtail.go @@ -0,0 +1,178 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "git.unkin.net/unkin/tomswallapi/internal/model" +) + +// ---- Policies (table exists from 0001; ListPolicies is in store.go) --------- + +func (s *Store) GetPolicy(ctx context.Context, id int64) (model.Policy, error) { + var p model.Policy + err := s.pool.QueryRow(ctx, + `SELECT id, priority, source, dest, action, log FROM policies WHERE id = $1`, id, + ).Scan(&p.ID, &p.Priority, &p.Source, &p.Dest, &p.Action, &p.Log) + if errors.Is(err, pgx.ErrNoRows) { + return p, ErrNotFound + } + return p, err +} + +func (s *Store) CreatePolicy(ctx context.Context, p model.Policy) (int64, error) { + var id int64 + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + INSERT INTO policies (priority, source, dest, action, log) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + p.Priority, p.Source, p.Dest, p.Action, p.Log, + ).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeletePolicy(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM policies WHERE id = $1`, id) +} + +// ---- Blrules --------------------------------------------------------------- + +func (s *Store) ListBlrules(ctx context.Context) ([]model.BlruleRule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, priority, action, source, dest, proto, dport, sport, log, comment + FROM blrules ORDER BY priority, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.BlruleRule + for rows.Next() { + r, err := scanBlrule(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetBlrule(ctx context.Context, id int64) (model.BlruleRule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, priority, action, source, dest, proto, dport, sport, log, comment + FROM blrules WHERE id = $1`, id) + if err != nil { + return model.BlruleRule{}, err + } + defer rows.Close() + if !rows.Next() { + return model.BlruleRule{}, ErrNotFound + } + return scanBlrule(rows) +} + +func scanBlrule(rows pgx.Rows) (model.BlruleRule, error) { + var r model.BlruleRule + var dport, sport []byte + if err := rows.Scan(&r.ID, &r.Priority, &r.Action, &r.Source, &r.Dest, &r.Proto, &dport, &sport, &r.Log, &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) CreateBlrule(ctx context.Context, r model.BlruleRule) (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 blrules (priority, action, source, dest, proto, dport, sport, log, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, + r.Priority, r.Action, r.Source, r.Dest, r.Proto, dport, sport, r.Log, r.Comment, + ).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteBlrule(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM blrules WHERE id = $1`, id) +} + +// ---- Conntrack ------------------------------------------------------------- + +func (s *Store) ListConntrack(ctx context.Context) ([]model.ConntrackRule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, priority, action, source, dest, proto, dport, sport, chain, helper, "user", comment + FROM conntrack ORDER BY priority, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.ConntrackRule + for rows.Next() { + r, err := scanConntrack(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GetConntrack(ctx context.Context, id int64) (model.ConntrackRule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, priority, action, source, dest, proto, dport, sport, chain, helper, "user", comment + FROM conntrack WHERE id = $1`, id) + if err != nil { + return model.ConntrackRule{}, err + } + defer rows.Close() + if !rows.Next() { + return model.ConntrackRule{}, ErrNotFound + } + return scanConntrack(rows) +} + +func scanConntrack(rows pgx.Rows) (model.ConntrackRule, error) { + var r model.ConntrackRule + var dport, sport []byte + if err := rows.Scan(&r.ID, &r.Priority, &r.Action, &r.Source, &r.Dest, &r.Proto, &dport, &sport, &r.Chain, &r.Helper, &r.User, &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) CreateConntrack(ctx context.Context, r model.ConntrackRule) (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 conntrack (priority, action, source, dest, proto, dport, sport, chain, helper, "user", comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, + r.Priority, r.Action, r.Source, r.Dest, r.Proto, dport, sport, r.Chain, r.Helper, r.User, r.Comment, + ).Scan(&id); err != nil { + return err + } + return bump(ctx, tx) + }) + return id, err +} + +func (s *Store) DeleteConntrack(ctx context.Context, id int64) error { + return s.deleteOne(ctx, `DELETE FROM conntrack WHERE id = $1`, id) +} diff --git a/internal/store/store.go b/internal/store/store.go index e5b1f1f..879b538 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -46,6 +46,11 @@ func jsonb(v any) ([]byte, error) { return json.Marshal(v) } +// unmarshalStrings decodes a JSONB string array into dst. +func unmarshalStrings(data []byte, dst *[]string) error { + return json.Unmarshal(data, dst) +} + // ---- Fabrics --------------------------------------------------------------- func (s *Store) ListFabrics(ctx context.Context) ([]model.Fabric, error) {