Merge pull request 'Long-tail batch 5 (API): secmark/var + render blrules/conntrack' (#13) from benvin/longtail-global2 into main
ci/woodpecker/tag/docker Pipeline was successful

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-07-26 16:57:12 +10:00
7 changed files with 330 additions and 0 deletions
+21
View File
@@ -52,6 +52,10 @@ type Input struct {
TCFilters []model.TCFilter
TCInterfaces []model.TCInterface
TCPriorities []model.TCPriority
Blrules []model.BlruleRule
Conntrack []model.ConntrackRule
Secmarks []model.SecmarkRule
Vars []model.Var
}
// RenderedConfig is the per-device output served to the agent.
@@ -86,6 +90,10 @@ type RenderedConfig struct {
TCFilters []RenderedTCFilter `yaml:"tc_filters,omitempty" json:"tc_filters,omitempty"`
TCInterfaces []RenderedTCInterface `yaml:"tc_interfaces,omitempty" json:"tc_interfaces,omitempty"`
TCPriorities []RenderedTCPriority `yaml:"tc_priorities,omitempty" json:"tc_priorities,omitempty"`
Blrules []RenderedBlrule `yaml:"blrules,omitempty" json:"blrules,omitempty"`
Conntrack []RenderedConntrack `yaml:"conntrack,omitempty" json:"conntrack,omitempty"`
Secmarks []RenderedSecmark `yaml:"secmarks,omitempty" json:"secmarks,omitempty"`
Vars map[string]string `yaml:"vars,omitempty" json:"vars,omitempty"`
}
// RenderedSNAT is a resolved SNAT/masquerade rule: source addresses masqueraded
@@ -235,6 +243,7 @@ func Render(in Input) (*RenderedConfig, error) {
renderPerDevice(in, out)
renderPerDeviceL2(in, out)
renderTraffic(in, out)
renderGlobal2(in, out)
return out, nil
}
@@ -516,5 +525,17 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi
if in.TCPriorities, err = s.ListTCPriorities(ctx); err != nil {
return nil, err
}
if in.Blrules, err = s.ListBlrules(ctx); err != nil {
return nil, err
}
if in.Conntrack, err = s.ListConntrack(ctx); err != nil {
return nil, err
}
if in.Secmarks, err = s.ListSecmarks(ctx); err != nil {
return nil, err
}
if in.Vars, err = s.ListVars(ctx); err != nil {
return nil, err
}
return Render(in)
}
+72
View File
@@ -0,0 +1,72 @@
package compiler
// Global-compiled long-tail: blrules, conntrack, secmarks (rendered on enforcing
// devices), and vars (substitution variables, rendered on every device).
type RenderedBlrule struct {
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
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"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedConntrack 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"`
Chain string `yaml:"chain,omitempty" json:"chain,omitempty"`
Helper string `yaml:"helper,omitempty" json:"helper,omitempty"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedSecmark struct {
Secmark string `yaml:"secmark" json:"secmark"`
Chain string `yaml:"chain" json:"chain"`
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"`
}
// renderGlobal2 renders the global-compiled sections. blrules/conntrack/secmarks
// only apply on enforcing devices; vars are always emitted.
func renderGlobal2(in Input, out *RenderedConfig) {
if len(in.Vars) > 0 {
out.Vars = make(map[string]string, len(in.Vars))
for _, v := range in.Vars {
out.Vars[v.Key] = v.Value
}
}
if !out.Enforcing {
return
}
for _, b := range in.Blrules {
out.Blrules = append(out.Blrules, RenderedBlrule{
Priority: b.Priority, Action: b.Action, Source: b.Source, Dest: b.Dest, Proto: b.Proto,
DPort: b.DPort, SPort: b.SPort, Log: b.Log, Comment: b.Comment,
})
}
for _, c := range in.Conntrack {
out.Conntrack = append(out.Conntrack, RenderedConntrack{
Action: c.Action, Source: c.Source, Dest: c.Dest, Proto: c.Proto, DPort: c.DPort, SPort: c.SPort,
Chain: c.Chain, Helper: c.Helper, User: c.User, Comment: c.Comment,
})
}
for _, s := range in.Secmarks {
out.Secmarks = append(out.Secmarks, RenderedSecmark{
Secmark: s.Secmark, Chain: s.Chain, Source: s.Source, Dest: s.Dest, Proto: s.Proto,
DPort: s.DPort, SPort: s.SPort, Comment: s.Comment,
})
}
}
@@ -0,0 +1,19 @@
-- Final long-tail sections: secmarks (SELinux security marking, global-compiled)
-- and vars (global key-value substitution variables).
CREATE TABLE secmarks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
secmark TEXT NOT NULL,
chain 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 vars (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
+20
View File
@@ -0,0 +1,20 @@
package model
// SecmarkRule applies an SELinux security mark (global-compiled).
type SecmarkRule struct {
ID int64 `json:"id"`
Secmark string `json:"secmark"`
Chain string `json:"chain"`
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"`
}
// Var is a global key-value substitution variable.
type Var struct {
Key string `json:"key"`
Value string `json:"value"`
}
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountGlobal2 wires secmarks (id-keyed) and vars (key-keyed).
func (s *Server) mountGlobal2(r chi.Router) {
idCRUD(r, "/secmarks", s.listSecmarks, s.createSecmark, s.getSecmark, s.deleteSecmark)
r.Route("/vars", func(r chi.Router) {
r.Get("/", s.listVars)
r.Get("/{key}", s.getVar)
r.Put("/{key}", s.putVar)
r.Delete("/{key}", s.deleteVar)
})
}
func (s *Server) listSecmarks(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSecmarks(r.Context())
respondList(w, list, err)
}
func (s *Server) createSecmark(w http.ResponseWriter, r *http.Request) {
var v model.SecmarkRule
if !decode(w, r, &v) {
return
}
if v.Secmark == "" || v.Chain == "" {
writeError(w, http.StatusBadRequest, "secmark and chain are required")
return
}
id, err := s.store.CreateSecmark(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getSecmark(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetSecmark(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteSecmark(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteSecmark(r.Context(), id))
}
func (s *Server) listVars(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListVars(r.Context())
respondList(w, list, err)
}
func (s *Server) getVar(w http.ResponseWriter, r *http.Request) {
v, err := s.store.GetVar(r.Context(), chi.URLParam(r, "key"))
respondOne(w, v, err)
}
func (s *Server) putVar(w http.ResponseWriter, r *http.Request) {
var v model.Var
if !decode(w, r, &v) {
return
}
v.Key = chi.URLParam(r, "key")
if err := s.store.UpsertVar(r.Context(), v); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) deleteVar(w http.ResponseWriter, r *http.Request) {
respondDelete(w, s.store.DeleteVar(r.Context(), chi.URLParam(r, "key")))
}
+1
View File
@@ -64,6 +64,7 @@ func (s *Server) mountResources(r chi.Router) {
s.mountPerDevice(r)
s.mountPerDeviceL2(r)
s.mountTraffic(r)
s.mountGlobal2(r)
}
// respondOne writes a single resource, mapping ErrNotFound to 404.
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- Secmarks --------------------------------------------------------------
func (s *Store) ListSecmarks(ctx context.Context) ([]model.SecmarkRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, secmark, chain, source, dest, proto, dport, sport, comment FROM secmarks ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SecmarkRule
for rows.Next() {
r, err := scanSecmark(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetSecmark(ctx context.Context, id int64) (model.SecmarkRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, secmark, chain, source, dest, proto, dport, sport, comment FROM secmarks WHERE id = $1`, id)
if err != nil {
return model.SecmarkRule{}, err
}
defer rows.Close()
if !rows.Next() {
return model.SecmarkRule{}, ErrNotFound
}
return scanSecmark(rows)
}
func scanSecmark(rows pgx.Rows) (model.SecmarkRule, error) {
var r model.SecmarkRule
var dport, sport []byte
if err := rows.Scan(&r.ID, &r.Secmark, &r.Chain, &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) CreateSecmark(ctx context.Context, r model.SecmarkRule) (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 secmarks (secmark, chain, source, dest, proto, dport, sport, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
r.Secmark, r.Chain, 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) DeleteSecmark(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM secmarks WHERE id = $1`, id)
}
// ---- Vars (key-keyed) ------------------------------------------------------
func (s *Store) ListVars(ctx context.Context) ([]model.Var, error) {
rows, err := s.pool.Query(ctx, `SELECT key, value FROM vars ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Var
for rows.Next() {
var v model.Var
if err := rows.Scan(&v.Key, &v.Value); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Store) GetVar(ctx context.Context, key string) (model.Var, error) {
var v model.Var
err := s.pool.QueryRow(ctx, `SELECT key, value FROM vars WHERE key = $1`, key).Scan(&v.Key, &v.Value)
if errors.Is(err, pgx.ErrNoRows) {
return v, ErrNotFound
}
return v, err
}
func (s *Store) UpsertVar(ctx context.Context, v model.Var) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`INSERT INTO vars (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
v.Key, v.Value); err != nil {
return err
}
return bump(ctx, tx)
})
}
func (s *Store) DeleteVar(ctx context.Context, key string) error {
return s.deleteOne(ctx, `DELETE FROM vars WHERE key = $1`, key)
}