diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index e19ba00..e8ac90b 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -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) } diff --git a/internal/compiler/global2.go b/internal/compiler/global2.go new file mode 100644 index 0000000..50ec341 --- /dev/null +++ b/internal/compiler/global2.go @@ -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, + }) + } +} diff --git a/internal/database/migrations/0009_secmark_var.sql b/internal/database/migrations/0009_secmark_var.sql new file mode 100644 index 0000000..7c90b75 --- /dev/null +++ b/internal/database/migrations/0009_secmark_var.sql @@ -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 '' +); diff --git a/internal/model/global2.go b/internal/model/global2.go new file mode 100644 index 0000000..ebe4011 --- /dev/null +++ b/internal/model/global2.go @@ -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"` +} diff --git a/internal/server/global2.go b/internal/server/global2.go new file mode 100644 index 0000000..09771f7 --- /dev/null +++ b/internal/server/global2.go @@ -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"))) +} diff --git a/internal/server/resources.go b/internal/server/resources.go index 748b4ff..d49085c 100644 --- a/internal/server/resources.go +++ b/internal/server/resources.go @@ -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. diff --git a/internal/store/global2.go b/internal/store/global2.go new file mode 100644 index 0000000..9efd69d --- /dev/null +++ b/internal/store/global2.go @@ -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) +}