Merge pull request 'Add global-tier long-tail resources (policy CRUD, blrules, conntrack)' (#9) from benvin/longtail-global into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-07-26 15:07:59 +10:00
6 changed files with 405 additions and 0 deletions
@@ -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 ''
);
+31
View File
@@ -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"`
}
+158
View File
@@ -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))
}
+1
View File
@@ -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.
+178
View File
@@ -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)
}
+5
View File
@@ -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) {