Compare commits

16 Commits

Author SHA1 Message Date
benvin 6ee47ab1c4 Merge pull request 'Accept reverted/failed device status reports' (#26) from benvin/status-reverted into main
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #26
2026-10-05 21:44:15 +11:00
unkin-agent 7245ff6643 Ignore status reports older than the stored generation
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-10-05 13:56:23 +11:00
unkin-agent f2197c2189 Accept applied/reverted/failed in device status reports
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-10-05 13:52:07 +11:00
benvin 23357b953f Merge pull request 'ci: switch Go toolchain steps to gobuilder + shared S3 cache' (#25) from benvin/gocache into main
Reviewed-on: #25
2026-10-03 00:15:39 +10:00
unkin-agent 38e6074650 ci: switch Go toolchain steps to gobuilder + shared S3 cache
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Cut CI time/network by caching go build/vet/test artifacts in S3.
- bump pre-commit/lint/test steps to gobuilder:0.1.2-alma9
- wire GOCACHEPROG via go-cache-plugin with an absolute cache dir
- add GOCACHE_* env (ci-tomswallapi prefix) and AWS creds from org secrets
- leave docker-build/docker buildx steps untouched (compile is in Dockerfile)
2026-10-02 23:54:07 +10:00
benvin 25d0f178f9 Merge pull request 'ci: add buildkit_config CA trust for artifactapi push' (#24) from benvin/buildx-ca-config into main
Reviewed-on: #24
2026-08-15 18:46:23 +10:00
unkin-agent 7d47bf2148 ci: add buildkit_config CA trust for artifactapi push
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-08-15 18:31:51 +10:00
benvin 3d57692cf3 Merge pull request 'ci: use CA-baked plugin-docker-buildx image for artifactapi push' (#23) from benvin/buildx-ca-plugin-image into main
Reviewed-on: #23
2026-08-15 18:20:13 +10:00
unkin-agent a922f2f958 ci: use CA-baked plugin-docker-buildx image for artifactapi push
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-08-15 18:04:32 +10:00
benvin 6b36a33f2f Merge pull request 'ci: push images to artifactapi registry instead of gitea' (#22) from benvin/push-artifactapi into main
Reviewed-on: #22
2026-07-30 20:56:03 +10:00
unkinben df2a4ac3b8 ci: push images to artifactapi registry instead of gitea
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Hard switch of the docker push target from the Gitea registry to the
artifactapi local docker registry (docker-internal); the Gitea VM and its
registry are being retired. Drops the droneci/DRONECI_PASSWORD creds since
artifactapi accepts unauthenticated in-cluster pushes.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-30 00:34:59 +10:00
benvin 73ddcf651e 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
2026-07-26 16:57:12 +10:00
benvin c46b4ab5c5 Merge pull request 'Long-tail batch 4 (API): traffic control (mangle/accounting/tc_*)' (#12) from benvin/longtail-tc into main
Reviewed-on: #12
2026-07-26 16:56:59 +10:00
benvin 4693b7093c Add secmark/var + render blrules/conntrack/secmark/vars
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
- Add secmarks (id-keyed) and vars (key-keyed) resources: migration 0009, model,
  store CRUD, REST handlers.
- Compiler rendering for the global-compiled tail: blrules, conntrack, secmarks
  render on enforcing devices; vars render on every device. This closes the
  rendering gap left by batch 1 (blrules/conntrack were stored but not rendered).
2026-07-26 16:26:36 +10:00
benvin 6b600f8c8d Add traffic-control long-tail: mangle/accounting/tc_*
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Storage + CRUD (migration 0008, model, id-keyed store, REST handlers) + compiler
rendering for mangle, accounting, and tc_devices/tc_classes/tc_filters/
tc_interfaces/tc_priorities, each owned by a device. (Nested tc option structs
deferred.)
2026-07-26 16:26:14 +10:00
benvin d54325c685 Merge pull request 'Long-tail batch 3 (API): per-device L2/misc (tunnels/stopped_rules/proxy_arp/proxy_ndp/arp_rules/maclist)' (#11) from benvin/longtail-l2 into main
Reviewed-on: #11
2026-07-26 16:23:35 +10:00
21 changed files with 1641 additions and 22 deletions
+2 -2
View File
@@ -3,9 +3,9 @@ when:
steps:
- name: docker-build
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
repo: git.unkin.net/unkin/tomswallapi
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/tomswallapi
dry_run: true
backend_options:
kubernetes:
+6 -6
View File
@@ -4,15 +4,15 @@ when:
steps:
- name: docker-tomswallapi
image: woodpeckerci/plugin-docker-buildx
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
settings:
registry: git.unkin.net
repo: git.unkin.net/unkin/tomswallapi
registry: artifactapi.k8s.syd1.au.unkin.net
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/tomswallapi
buildkit_config: |
[registry."artifactapi.k8s.syd1.au.unkin.net"]
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci
password:
from_secret: DRONECI_PASSWORD
tags:
- ${CI_COMMIT_TAG}
- latest
+13 -1
View File
@@ -3,9 +3,21 @@ when:
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
commands:
- uvx pre-commit run --all-files
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-tomswallapi
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
backend_options:
kubernetes:
serviceAccountName: default
+26 -2
View File
@@ -3,9 +3,21 @@ when:
steps:
- name: lint
image: golang:1.25
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
commands:
- make lint
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-tomswallapi
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
backend_options:
kubernetes:
serviceAccountName: default
@@ -18,10 +30,22 @@ steps:
cpu: 2
- name: test
image: golang:1.25
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/gobuilder:0.1.2-alma9
commands:
# Container-backed DB tests self-skip when Docker is unavailable in CI.
- make test-short
environment:
GOCACHE_S3_BUCKET: gocache
GOCACHE_S3_REGION: us-east-1
GOCACHE_S3_ENDPOINT_URL: https://s3.ceph.unkin.net
GOCACHE_S3_PATH_STYLE: "true"
GOCACHE_KEY_PREFIX: ci-tomswallapi
GOCACHE_METRICS: "true"
GOCACHEPROG: go-cache-plugin --cache-dir=/tmp/gocache
AWS_ACCESS_KEY_ID:
from_secret: GOCACHE_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY:
from_secret: GOCACHE_AWS_SECRET_ACCESS_KEY
backend_options:
kubernetes:
serviceAccountName: default
+57
View File
@@ -45,6 +45,17 @@ type Input struct {
ProxyNDP []model.ProxyEntry
ArpRules []model.ArpRule
Maclist []model.MaclistEntry
Mangle []model.MangleRule
Accounting []model.AccountingRule
TCDevices []model.TCDevice
TCClasses []model.TCClass
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.
@@ -72,6 +83,17 @@ type RenderedConfig struct {
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"`
Mangle []RenderedMangle `yaml:"mangle,omitempty" json:"mangle,omitempty"`
Accounting []RenderedAccounting `yaml:"accounting,omitempty" json:"accounting,omitempty"`
TCDevices []RenderedTCDevice `yaml:"tc_devices,omitempty" json:"tc_devices,omitempty"`
TCClasses []RenderedTCClass `yaml:"tc_classes,omitempty" json:"tc_classes,omitempty"`
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
@@ -220,6 +242,8 @@ func Render(in Input) (*RenderedConfig, error) {
// Per-device long-tail sections owned by this device.
renderPerDevice(in, out)
renderPerDeviceL2(in, out)
renderTraffic(in, out)
renderGlobal2(in, out)
return out, nil
}
@@ -480,5 +504,38 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi
if in.Maclist, err = s.ListMaclist(ctx); err != nil {
return nil, err
}
if in.Mangle, err = s.ListMangle(ctx); err != nil {
return nil, err
}
if in.Accounting, err = s.ListAccounting(ctx); err != nil {
return nil, err
}
if in.TCDevices, err = s.ListTCDevices(ctx); err != nil {
return nil, err
}
if in.TCClasses, err = s.ListTCClasses(ctx); err != nil {
return nil, err
}
if in.TCFilters, err = s.ListTCFilters(ctx); err != nil {
return nil, err
}
if in.TCInterfaces, err = s.ListTCInterfaces(ctx); err != nil {
return nil, err
}
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,
})
}
}
+147
View File
@@ -0,0 +1,147 @@
package compiler
// Traffic-control tier rendered into a device's config.
type RenderedMangle struct {
Action string `yaml:"action" json:"action"`
Chain string `yaml:"chain,omitempty" json:"chain,omitempty"`
MarkValue string `yaml:"mark_value,omitempty" json:"mark_value,omitempty"`
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"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
Mark string `yaml:"mark,omitempty" json:"mark,omitempty"`
Length string `yaml:"length,omitempty" json:"length,omitempty"`
TOS string `yaml:"tos,omitempty" json:"tos,omitempty"`
Helper string `yaml:"helper,omitempty" json:"helper,omitempty"`
Probability *float64 `yaml:"probability,omitempty" json:"probability,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedAccounting struct {
Action string `yaml:"action" json:"action"`
Section string `yaml:"section,omitempty" json:"section,omitempty"`
Chain string `yaml:"chain,omitempty" json:"chain,omitempty"`
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"`
Mark string `yaml:"mark,omitempty" json:"mark,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedTCDevice struct {
Interface string `yaml:"interface" json:"interface"`
InBandwidth string `yaml:"in_bandwidth,omitempty" json:"in_bandwidth,omitempty"`
OutBandwidth string `yaml:"out_bandwidth,omitempty" json:"out_bandwidth,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedTCClass struct {
Interface string `yaml:"interface" json:"interface"`
Mark int `yaml:"mark,omitempty" json:"mark,omitempty"`
Rate string `yaml:"rate,omitempty" json:"rate,omitempty"`
Ceil string `yaml:"ceil,omitempty" json:"ceil,omitempty"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedTCFilter struct {
Class string `yaml:"class" json:"class"`
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"`
TOS string `yaml:"tos,omitempty" json:"tos,omitempty"`
Length int `yaml:"length,omitempty" json:"length,omitempty"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedTCInterface struct {
Interface string `yaml:"interface" json:"interface"`
Type string `yaml:"type,omitempty" json:"type,omitempty"`
InBandwidth string `yaml:"in_bandwidth,omitempty" json:"in_bandwidth,omitempty"`
OutBandwidth string `yaml:"out_bandwidth,omitempty" json:"out_bandwidth,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedTCPriority struct {
Band int `yaml:"band" json:"band"`
Proto string `yaml:"proto,omitempty" json:"proto,omitempty"`
DPort []string `yaml:"dport,omitempty" json:"dport,omitempty"`
SPort []string `yaml:"sport,omitempty" json:"sport,omitempty"`
Address string `yaml:"address,omitempty" json:"address,omitempty"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
Helper string `yaml:"helper,omitempty" json:"helper,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
func renderTraffic(in Input, out *RenderedConfig) {
dev := in.Device.Name
for _, m := range in.Mangle {
if m.Device != dev {
continue
}
out.Mangle = append(out.Mangle, RenderedMangle{
Action: m.Action, Chain: m.Chain, MarkValue: m.MarkValue, Source: m.Source, Dest: m.Dest,
Proto: m.Proto, DPort: m.DPort, SPort: m.SPort, User: m.User, Mark: m.Mark,
Length: m.Length, TOS: m.TOS, Helper: m.Helper, Probability: m.Probability, Comment: m.Comment,
})
}
for _, a := range in.Accounting {
if a.Device != dev {
continue
}
out.Accounting = append(out.Accounting, RenderedAccounting{
Action: a.Action, Section: a.Section, Chain: a.Chain, Source: a.Source, Dest: a.Dest,
Proto: a.Proto, DPort: a.DPort, SPort: a.SPort, Mark: a.Mark, Comment: a.Comment,
})
}
for _, t := range in.TCDevices {
if t.Device != dev {
continue
}
out.TCDevices = append(out.TCDevices, RenderedTCDevice{
Interface: t.Interface, InBandwidth: t.InBandwidth, OutBandwidth: t.OutBandwidth, Comment: t.Comment,
})
}
for _, t := range in.TCClasses {
if t.Device != dev {
continue
}
out.TCClasses = append(out.TCClasses, RenderedTCClass{
Interface: t.Interface, Mark: t.Mark, Rate: t.Rate, Ceil: t.Ceil, Priority: t.Priority, Comment: t.Comment,
})
}
for _, t := range in.TCFilters {
if t.Device != dev {
continue
}
out.TCFilters = append(out.TCFilters, RenderedTCFilter{
Class: t.Class, Source: t.Source, Dest: t.Dest, Proto: t.Proto, DPort: t.DPort, SPort: t.SPort,
TOS: t.TOS, Length: t.Length, Priority: t.Priority, Comment: t.Comment,
})
}
for _, t := range in.TCInterfaces {
if t.Device != dev {
continue
}
out.TCInterfaces = append(out.TCInterfaces, RenderedTCInterface{
Interface: t.Interface, Type: t.Type, InBandwidth: t.InBandwidth, OutBandwidth: t.OutBandwidth, Comment: t.Comment,
})
}
for _, t := range in.TCPriorities {
if t.Device != dev {
continue
}
out.TCPriorities = append(out.TCPriorities, RenderedTCPriority{
Band: t.Band, Proto: t.Proto, DPort: t.DPort, SPort: t.SPort,
Address: t.Address, Interface: t.Interface, Helper: t.Helper, Comment: t.Comment,
})
}
}
@@ -0,0 +1,96 @@
-- Traffic-control tier: mangle, accounting, and tc_* (device/class/filter/
-- interface/priority). Each is owned by a device. (Nested tc option structs on
-- tc_device/tc_class are deferred.)
CREATE TABLE mangle (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
action TEXT NOT NULL,
chain TEXT NOT NULL DEFAULT '',
mark_value TEXT NOT NULL DEFAULT '',
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,
"user" TEXT NOT NULL DEFAULT '',
mark TEXT NOT NULL DEFAULT '',
length TEXT NOT NULL DEFAULT '',
tos TEXT NOT NULL DEFAULT '',
helper TEXT NOT NULL DEFAULT '',
probability REAL,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE accounting (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
action TEXT NOT NULL,
section TEXT NOT NULL DEFAULT '',
chain TEXT NOT NULL DEFAULT '',
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,
mark TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tc_devices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
interface TEXT NOT NULL,
in_bandwidth TEXT NOT NULL DEFAULT '',
out_bandwidth TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tc_classes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
interface TEXT NOT NULL,
mark INT NOT NULL DEFAULT 0,
rate TEXT NOT NULL DEFAULT '',
ceil TEXT NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tc_filters (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
class 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,
tos TEXT NOT NULL DEFAULT '',
length INT NOT NULL DEFAULT 0,
priority INT NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tc_interfaces (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
interface TEXT NOT NULL,
type TEXT NOT NULL DEFAULT '',
in_bandwidth TEXT NOT NULL DEFAULT '',
out_bandwidth TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE tc_priorities (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
band INT NOT NULL,
proto TEXT NOT NULL DEFAULT '',
dport JSONB NOT NULL DEFAULT '[]'::jsonb,
sport JSONB NOT NULL DEFAULT '[]'::jsonb,
address TEXT NOT NULL DEFAULT '',
interface TEXT NOT NULL DEFAULT '',
helper TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
@@ -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 ''
);
@@ -0,0 +1,7 @@
-- Last agent apply report per device. reported_generation keeps its meaning
-- (last applied generation); these columns hold the latest report of any kind.
ALTER TABLE devices
ADD COLUMN status TEXT NOT NULL DEFAULT '' CHECK (status IN ('', 'applied', 'reverted', 'failed')),
ADD COLUMN status_generation BIGINT NOT NULL DEFAULT 0,
ADD COLUMN status_error TEXT NOT NULL DEFAULT '',
ADD COLUMN status_at TIMESTAMPTZ;
+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"`
}
+42
View File
@@ -103,6 +103,48 @@ type Device struct {
// ReachablePrefixes is the device's FIB as last reported by its agent
// (server-managed). The compiler uses it to scope router enforcement.
ReachablePrefixes []string `json:"reachable_prefixes,omitempty"`
// AgentStatus is the agent's last apply report (server-managed).
AgentStatus *AgentStatus `json:"agent_status,omitempty"`
}
// ApplyStatus is the outcome an agent reports for a generation.
type ApplyStatus string
const (
StatusApplied ApplyStatus = "applied"
StatusReverted ApplyStatus = "reverted"
StatusFailed ApplyStatus = "failed"
)
// StatusReport is the agent's POST /devices/{name}/status body. An empty
// Status (pre-status agents sending only generation) means applied.
type StatusReport struct {
Status ApplyStatus `json:"status,omitempty"`
Generation int64 `json:"generation"`
Error string `json:"error,omitempty"`
}
// Normalize defaults an empty Status to applied and rejects unknown values.
func (r *StatusReport) Normalize() error {
switch r.Status {
case "":
r.Status = StatusApplied
case StatusApplied, StatusReverted, StatusFailed:
default:
return fmt.Errorf("invalid status %q: want applied, reverted or failed", r.Status)
}
return nil
}
// AgentStatus is a device's stored apply state. AppliedGeneration only advances
// on applied reports; Status/Generation/Error/ReportedAt mirror the last report.
type AgentStatus struct {
AppliedGeneration int64 `json:"applied_generation"`
Status ApplyStatus `json:"status"`
Generation int64 `json:"generation"`
Error string `json:"error,omitempty"`
ReportedAt time.Time `json:"reported_at"`
}
// Binding maps a global zone to one device's local interface(s).
+96
View File
@@ -0,0 +1,96 @@
package model
// Traffic-control tier: mangle, accounting, and tc_* sections (per-device).
type MangleRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
Action string `json:"action"`
Chain string `json:"chain,omitempty"`
MarkValue string `json:"mark_value,omitempty"`
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"`
User string `json:"user,omitempty"`
Mark string `json:"mark,omitempty"`
Length string `json:"length,omitempty"`
TOS string `json:"tos,omitempty"`
Helper string `json:"helper,omitempty"`
Probability *float64 `json:"probability,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AccountingRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
Action string `json:"action"`
Section string `json:"section,omitempty"`
Chain string `json:"chain,omitempty"`
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"`
Mark string `json:"mark,omitempty"`
Comment string `json:"comment,omitempty"`
}
type TCDevice struct {
ID int64 `json:"id"`
Device string `json:"device"`
Interface string `json:"interface"`
InBandwidth string `json:"in_bandwidth,omitempty"`
OutBandwidth string `json:"out_bandwidth,omitempty"`
Comment string `json:"comment,omitempty"`
}
type TCClass struct {
ID int64 `json:"id"`
Device string `json:"device"`
Interface string `json:"interface"`
Mark int `json:"mark,omitempty"`
Rate string `json:"rate,omitempty"`
Ceil string `json:"ceil,omitempty"`
Priority int `json:"priority,omitempty"`
Comment string `json:"comment,omitempty"`
}
type TCFilter struct {
ID int64 `json:"id"`
Device string `json:"device"`
Class string `json:"class"`
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"`
TOS string `json:"tos,omitempty"`
Length int `json:"length,omitempty"`
Priority int `json:"priority,omitempty"`
Comment string `json:"comment,omitempty"`
}
type TCInterface struct {
ID int64 `json:"id"`
Device string `json:"device"`
Interface string `json:"interface"`
Type string `json:"type,omitempty"`
InBandwidth string `json:"in_bandwidth,omitempty"`
OutBandwidth string `json:"out_bandwidth,omitempty"`
Comment string `json:"comment,omitempty"`
}
type TCPriority struct {
ID int64 `json:"id"`
Device string `json:"device"`
Band int `json:"band"`
Proto string `json:"proto,omitempty"`
DPort []string `json:"dport,omitempty"`
SPort []string `json:"sport,omitempty"`
Address string `json:"address,omitempty"`
Interface string `json:"interface,omitempty"`
Helper string `json:"helper,omitempty"`
Comment string `json:"comment,omitempty"`
}
+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")))
}
+8 -4
View File
@@ -63,6 +63,8 @@ func (s *Server) mountResources(r chi.Router) {
s.mountLongtail(r)
s.mountPerDevice(r)
s.mountPerDeviceL2(r)
s.mountTraffic(r)
s.mountGlobal2(r)
}
// respondOne writes a single resource, mapping ErrNotFound to 404.
@@ -381,13 +383,15 @@ func (s *Server) handleDeviceRoutes(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
var body struct {
Generation int64 `json:"generation"`
}
var body model.StatusReport
if !decode(w, r, &body) {
return
}
if err := s.store.RecordDeviceStatus(r.Context(), chi.URLParam(r, "name"), body.Generation); err != nil {
if err := body.Normalize(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.store.RecordDeviceStatus(r.Context(), chi.URLParam(r, "name"), body); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
+45
View File
@@ -0,0 +1,45 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
func TestStatusReportNormalize(t *testing.T) {
tests := []struct {
in model.ApplyStatus
want model.ApplyStatus
wantErr bool
}{
{in: "", want: model.StatusApplied},
{in: model.StatusApplied, want: model.StatusApplied},
{in: model.StatusReverted, want: model.StatusReverted},
{in: model.StatusFailed, want: model.StatusFailed},
{in: "rolledback", wantErr: true},
}
for _, tt := range tests {
r := model.StatusReport{Status: tt.in, Generation: 3}
err := r.Normalize()
if (err != nil) != tt.wantErr || (!tt.wantErr && r.Status != tt.want) {
t.Errorf("Normalize(%q) = %q, %v", tt.in, r.Status, err)
}
}
}
func TestHandleDeviceStatusRejectsBadPayload(t *testing.T) {
s := &Server{} // nil store: a 400 must return before any DB access
for _, body := range []string{
`{"status":"rolledback","generation":3}`,
`{"generation":3,"bogus":1}`,
} {
rec := httptest.NewRecorder()
s.handleDeviceStatus(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)))
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: got %d, want 400", body, rec.Code)
}
}
}
+265
View File
@@ -0,0 +1,265 @@
package server
import (
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountTraffic wires the traffic-control tier: mangle, accounting, tc_*.
func (s *Server) mountTraffic(r chi.Router) {
idCRUD(r, "/mangle", s.listMangle, s.createMangle, s.getMangle, s.deleteMangle)
idCRUD(r, "/accounting", s.listAccounting, s.createAccounting, s.getAccounting, s.deleteAccounting)
idCRUD(r, "/tc-devices", s.listTCDevices, s.createTCDevice, s.getTCDevice, s.deleteTCDevice)
idCRUD(r, "/tc-classes", s.listTCClasses, s.createTCClass, s.getTCClass, s.deleteTCClass)
idCRUD(r, "/tc-filters", s.listTCFilters, s.createTCFilter, s.getTCFilter, s.deleteTCFilter)
idCRUD(r, "/tc-interfaces", s.listTCInterfaces, s.createTCInterface, s.getTCInterface, s.deleteTCInterface)
idCRUD(r, "/tc-priorities", s.listTCPriorities, s.createTCPriority, s.getTCPriority, s.deleteTCPriority)
}
func (s *Server) listMangle(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListMangle(r.Context())
respondList(w, list, err)
}
func (s *Server) createMangle(w http.ResponseWriter, r *http.Request) {
var v model.MangleRule
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.CreateMangle(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getMangle(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetMangle(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteMangle(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteMangle(r.Context(), id))
}
func (s *Server) listAccounting(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAccounting(r.Context())
respondList(w, list, err)
}
func (s *Server) createAccounting(w http.ResponseWriter, r *http.Request) {
var v model.AccountingRule
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.CreateAccounting(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getAccounting(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetAccounting(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteAccounting(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteAccounting(r.Context(), id))
}
func (s *Server) listTCDevices(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTCDevices(r.Context())
respondList(w, list, err)
}
func (s *Server) createTCDevice(w http.ResponseWriter, r *http.Request) {
var v model.TCDevice
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device and interface are required")
return
}
id, err := s.store.CreateTCDevice(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTCDevice(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTCDevice(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTCDevice(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTCDevice(r.Context(), id))
}
func (s *Server) listTCClasses(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTCClasses(r.Context())
respondList(w, list, err)
}
func (s *Server) createTCClass(w http.ResponseWriter, r *http.Request) {
var v model.TCClass
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device and interface are required")
return
}
id, err := s.store.CreateTCClass(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTCClass(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTCClass(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTCClass(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTCClass(r.Context(), id))
}
func (s *Server) listTCFilters(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTCFilters(r.Context())
respondList(w, list, err)
}
func (s *Server) createTCFilter(w http.ResponseWriter, r *http.Request) {
var v model.TCFilter
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Class == "" {
writeError(w, http.StatusBadRequest, "device and class are required")
return
}
id, err := s.store.CreateTCFilter(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTCFilter(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTCFilter(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTCFilter(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTCFilter(r.Context(), id))
}
func (s *Server) listTCInterfaces(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTCInterfaces(r.Context())
respondList(w, list, err)
}
func (s *Server) createTCInterface(w http.ResponseWriter, r *http.Request) {
var v model.TCInterface
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device and interface are required")
return
}
id, err := s.store.CreateTCInterface(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTCInterface(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTCInterface(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTCInterface(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTCInterface(r.Context(), id))
}
func (s *Server) listTCPriorities(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTCPriorities(r.Context())
respondList(w, list, err)
}
func (s *Server) createTCPriority(w http.ResponseWriter, r *http.Request) {
var v model.TCPriority
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Band == 0 {
writeError(w, http.StatusBadRequest, "device and band are required")
return
}
id, err := s.store.CreateTCPriority(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTCPriority(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTCPriority(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTCPriority(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTCPriority(r.Context(), id))
}
+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)
}
+25 -7
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -279,11 +280,20 @@ func (s *Store) UpsertDevice(ctx context.Context, d model.Device) error {
})
}
// RecordDeviceStatus stores the generation an agent reports as applied.
func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation int64) error {
tag, err := s.pool.Exec(ctx,
`UPDATE devices SET reported_generation = $2, last_seen = now() WHERE name = $1`,
name, generation)
// RecordDeviceStatus stores an agent's apply report. reported_generation only
// advances on applied; the last-status columns only take reports at or above
// the stored status generation, so late or replayed reports never clobber newer state.
func (s *Store) RecordDeviceStatus(ctx context.Context, name string, r model.StatusReport) error {
tag, err := s.pool.Exec(ctx, `
UPDATE devices SET
reported_generation = CASE WHEN $2 = 'applied' THEN GREATEST(reported_generation, $3) ELSE reported_generation END,
status = CASE WHEN $3 >= status_generation THEN $2 ELSE status END,
status_error = CASE WHEN $3 >= status_generation THEN $4 ELSE status_error END,
status_at = CASE WHEN $3 >= status_generation THEN now() ELSE status_at END,
status_generation = GREATEST(status_generation, $3),
last_seen = now()
WHERE name = $1`,
name, string(r.Status), r.Generation, r.Error)
if err != nil {
return err
}
@@ -296,10 +306,14 @@ func (s *Store) RecordDeviceStatus(ctx context.Context, name string, generation
func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error) {
var d model.Device
var resolver, settings, reachable []byte
var st model.AgentStatus
var statusAt *time.Time
err := s.pool.QueryRow(ctx,
`SELECT name, class, COALESCE(fabric, ''), resolver, settings, reachable_prefixes
`SELECT name, class, COALESCE(fabric, ''), resolver, settings, reachable_prefixes,
reported_generation, status, status_generation, status_error, status_at
FROM devices WHERE name = $1`, name,
).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings, &reachable)
).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings, &reachable,
&st.AppliedGeneration, &st.Status, &st.Generation, &st.Error, &statusAt)
if errors.Is(err, pgx.ErrNoRows) {
return d, ErrNotFound
}
@@ -312,6 +326,10 @@ func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error
if err := json.Unmarshal(reachable, &d.ReachablePrefixes); err != nil {
return d, err
}
if statusAt != nil {
st.ReportedAt = *statusAt
d.AgentStatus = &st
}
return d, json.Unmarshal(settings, &d.Settings)
}
+74
View File
@@ -149,3 +149,77 @@ func TestNATTierRoundTrip(t *testing.T) {
t.Errorf("expected nat rows to cascade-delete with device, got %d", len(nats))
}
}
func TestRecordDeviceStatus(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
if err := s.UpsertDevice(ctx, model.Device{Name: "fw1", Class: model.ClassFirewall}); err != nil {
t.Fatalf("upsert device: %v", err)
}
if d, _ := s.GetDevice(ctx, "fw1"); d.AgentStatus != nil {
t.Fatalf("unreported device should have no agent_status: %+v", d.AgentStatus)
}
report := func(r model.StatusReport) model.AgentStatus {
t.Helper()
if err := r.Normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if err := s.RecordDeviceStatus(ctx, "fw1", r); err != nil {
t.Fatalf("record %+v: %v", r, err)
}
d, err := s.GetDevice(ctx, "fw1")
if err != nil || d.AgentStatus == nil {
t.Fatalf("get device: %v %+v", err, d.AgentStatus)
}
return *d.AgentStatus
}
// Old agent payload: generation only.
got := report(model.StatusReport{Generation: 5})
if got.AppliedGeneration != 5 || got.Status != model.StatusApplied || got.Generation != 5 || got.ReportedAt.IsZero() {
t.Errorf("old payload: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 6})
if got.AppliedGeneration != 6 || got.Status != model.StatusApplied {
t.Errorf("applied: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 7, Error: "api unreachable"})
if got.AppliedGeneration != 6 || got.Status != model.StatusReverted || got.Generation != 7 || got.Error != "api unreachable" {
t.Errorf("reverted must not advance applied generation: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusFailed, Generation: 8, Error: "nft: syntax error"})
if got.AppliedGeneration != 6 || got.Status != model.StatusFailed || got.Generation != 8 || got.Error != "nft: syntax error" {
t.Errorf("failed must not advance applied generation: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 9})
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 {
t.Errorf("applied 9: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 4})
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 {
t.Errorf("older applied must not regress: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 8, Error: "stale"})
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 || got.Error != "" {
t.Errorf("older reverted must not overwrite newer status: %+v", got)
}
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 9, Error: "lost api"})
if got.AppliedGeneration != 9 || got.Status != model.StatusReverted || got.Generation != 9 || got.Error != "lost api" {
t.Errorf("same-generation reverted after applied must update: %+v", got)
}
if err := s.RecordDeviceStatus(ctx, "fw1", model.StatusReport{Status: "bogus", Generation: 10}); err == nil {
t.Error("db should reject unknown status")
}
if err := s.RecordDeviceStatus(ctx, "nope", model.StatusReport{Status: model.StatusApplied}); err != store.ErrNotFound {
t.Errorf("unknown device: want ErrNotFound, got %v", err)
}
}
+424
View File
@@ -0,0 +1,424 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- Mangle ----------------------------------------------------------------
func (s *Store) ListMangle(ctx context.Context) ([]model.MangleRule, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, device, action, chain, mark_value, source, dest, proto, dport, sport,
"user", mark, length, tos, helper, probability, comment
FROM mangle ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.MangleRule
for rows.Next() {
m, err := scanMangle(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) GetMangle(ctx context.Context, id int64) (model.MangleRule, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, device, action, chain, mark_value, source, dest, proto, dport, sport,
"user", mark, length, tos, helper, probability, comment
FROM mangle WHERE id = $1`, id)
if err != nil {
return model.MangleRule{}, err
}
defer rows.Close()
if !rows.Next() {
return model.MangleRule{}, ErrNotFound
}
return scanMangle(rows)
}
func scanMangle(rows pgx.Rows) (model.MangleRule, error) {
var m model.MangleRule
var dport, sport []byte
if err := rows.Scan(&m.ID, &m.Device, &m.Action, &m.Chain, &m.MarkValue, &m.Source, &m.Dest, &m.Proto,
&dport, &sport, &m.User, &m.Mark, &m.Length, &m.TOS, &m.Helper, &m.Probability, &m.Comment); err != nil {
return m, err
}
if err := unmarshalStrings(dport, &m.DPort); err != nil {
return m, err
}
return m, unmarshalStrings(sport, &m.SPort)
}
func (s *Store) CreateMangle(ctx context.Context, m model.MangleRule) (int64, error) {
dport, _ := jsonb(m.DPort)
sport, _ := jsonb(m.SPort)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO mangle (device, action, chain, mark_value, source, dest, proto, dport, sport,
"user", mark, length, tos, helper, probability, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING id`,
m.Device, m.Action, m.Chain, m.MarkValue, m.Source, m.Dest, m.Proto, dport, sport,
m.User, m.Mark, m.Length, m.TOS, m.Helper, m.Probability, m.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteMangle(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM mangle WHERE id = $1`, id)
}
// ---- Accounting ------------------------------------------------------------
func (s *Store) ListAccounting(ctx context.Context) ([]model.AccountingRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, section, chain, source, dest, proto, dport, sport, mark, comment FROM accounting ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.AccountingRule
for rows.Next() {
a, err := scanAccounting(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) GetAccounting(ctx context.Context, id int64) (model.AccountingRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, section, chain, source, dest, proto, dport, sport, mark, comment FROM accounting WHERE id = $1`, id)
if err != nil {
return model.AccountingRule{}, err
}
defer rows.Close()
if !rows.Next() {
return model.AccountingRule{}, ErrNotFound
}
return scanAccounting(rows)
}
func scanAccounting(rows pgx.Rows) (model.AccountingRule, error) {
var a model.AccountingRule
var dport, sport []byte
if err := rows.Scan(&a.ID, &a.Device, &a.Action, &a.Section, &a.Chain, &a.Source, &a.Dest, &a.Proto, &dport, &sport, &a.Mark, &a.Comment); err != nil {
return a, err
}
if err := unmarshalStrings(dport, &a.DPort); err != nil {
return a, err
}
return a, unmarshalStrings(sport, &a.SPort)
}
func (s *Store) CreateAccounting(ctx context.Context, a model.AccountingRule) (int64, error) {
dport, _ := jsonb(a.DPort)
sport, _ := jsonb(a.SPort)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO accounting (device, action, section, chain, source, dest, proto, dport, sport, mark, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
a.Device, a.Action, a.Section, a.Chain, a.Source, a.Dest, a.Proto, dport, sport, a.Mark, a.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteAccounting(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM accounting WHERE id = $1`, id)
}
// ---- TC devices ------------------------------------------------------------
func (s *Store) ListTCDevices(ctx context.Context) ([]model.TCDevice, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, interface, in_bandwidth, out_bandwidth, comment FROM tc_devices ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.TCDevice
for rows.Next() {
var t model.TCDevice
if err := rows.Scan(&t.ID, &t.Device, &t.Interface, &t.InBandwidth, &t.OutBandwidth, &t.Comment); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) GetTCDevice(ctx context.Context, id int64) (model.TCDevice, error) {
var t model.TCDevice
err := s.pool.QueryRow(ctx,
`SELECT id, device, interface, in_bandwidth, out_bandwidth, comment FROM tc_devices WHERE id = $1`, id,
).Scan(&t.ID, &t.Device, &t.Interface, &t.InBandwidth, &t.OutBandwidth, &t.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CreateTCDevice(ctx context.Context, t model.TCDevice) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tc_devices (device, interface, in_bandwidth, out_bandwidth, comment)
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
t.Device, t.Interface, t.InBandwidth, t.OutBandwidth, t.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTCDevice(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tc_devices WHERE id = $1`, id)
}
// ---- TC classes ------------------------------------------------------------
func (s *Store) ListTCClasses(ctx context.Context) ([]model.TCClass, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, interface, mark, rate, ceil, priority, comment FROM tc_classes ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.TCClass
for rows.Next() {
var t model.TCClass
if err := rows.Scan(&t.ID, &t.Device, &t.Interface, &t.Mark, &t.Rate, &t.Ceil, &t.Priority, &t.Comment); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) GetTCClass(ctx context.Context, id int64) (model.TCClass, error) {
var t model.TCClass
err := s.pool.QueryRow(ctx,
`SELECT id, device, interface, mark, rate, ceil, priority, comment FROM tc_classes WHERE id = $1`, id,
).Scan(&t.ID, &t.Device, &t.Interface, &t.Mark, &t.Rate, &t.Ceil, &t.Priority, &t.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CreateTCClass(ctx context.Context, t model.TCClass) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tc_classes (device, interface, mark, rate, ceil, priority, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
t.Device, t.Interface, t.Mark, t.Rate, t.Ceil, t.Priority, t.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTCClass(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tc_classes WHERE id = $1`, id)
}
// ---- TC filters ------------------------------------------------------------
func (s *Store) ListTCFilters(ctx context.Context) ([]model.TCFilter, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, class, source, dest, proto, dport, sport, tos, length, priority, comment FROM tc_filters ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.TCFilter
for rows.Next() {
f, err := scanTCFilter(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) GetTCFilter(ctx context.Context, id int64) (model.TCFilter, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, class, source, dest, proto, dport, sport, tos, length, priority, comment FROM tc_filters WHERE id = $1`, id)
if err != nil {
return model.TCFilter{}, err
}
defer rows.Close()
if !rows.Next() {
return model.TCFilter{}, ErrNotFound
}
return scanTCFilter(rows)
}
func scanTCFilter(rows pgx.Rows) (model.TCFilter, error) {
var f model.TCFilter
var dport, sport []byte
if err := rows.Scan(&f.ID, &f.Device, &f.Class, &f.Source, &f.Dest, &f.Proto, &dport, &sport, &f.TOS, &f.Length, &f.Priority, &f.Comment); err != nil {
return f, err
}
if err := unmarshalStrings(dport, &f.DPort); err != nil {
return f, err
}
return f, unmarshalStrings(sport, &f.SPort)
}
func (s *Store) CreateTCFilter(ctx context.Context, f model.TCFilter) (int64, error) {
dport, _ := jsonb(f.DPort)
sport, _ := jsonb(f.SPort)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tc_filters (device, class, source, dest, proto, dport, sport, tos, length, priority, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
f.Device, f.Class, f.Source, f.Dest, f.Proto, dport, sport, f.TOS, f.Length, f.Priority, f.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTCFilter(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tc_filters WHERE id = $1`, id)
}
// ---- TC interfaces ---------------------------------------------------------
func (s *Store) ListTCInterfaces(ctx context.Context) ([]model.TCInterface, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, interface, type, in_bandwidth, out_bandwidth, comment FROM tc_interfaces ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.TCInterface
for rows.Next() {
var t model.TCInterface
if err := rows.Scan(&t.ID, &t.Device, &t.Interface, &t.Type, &t.InBandwidth, &t.OutBandwidth, &t.Comment); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) GetTCInterface(ctx context.Context, id int64) (model.TCInterface, error) {
var t model.TCInterface
err := s.pool.QueryRow(ctx,
`SELECT id, device, interface, type, in_bandwidth, out_bandwidth, comment FROM tc_interfaces WHERE id = $1`, id,
).Scan(&t.ID, &t.Device, &t.Interface, &t.Type, &t.InBandwidth, &t.OutBandwidth, &t.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CreateTCInterface(ctx context.Context, t model.TCInterface) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tc_interfaces (device, interface, type, in_bandwidth, out_bandwidth, comment)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
t.Device, t.Interface, t.Type, t.InBandwidth, t.OutBandwidth, t.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTCInterface(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tc_interfaces WHERE id = $1`, id)
}
// ---- TC priorities ---------------------------------------------------------
func (s *Store) ListTCPriorities(ctx context.Context) ([]model.TCPriority, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, band, proto, dport, sport, address, interface, helper, comment FROM tc_priorities ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.TCPriority
for rows.Next() {
p, err := scanTCPriority(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetTCPriority(ctx context.Context, id int64) (model.TCPriority, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, band, proto, dport, sport, address, interface, helper, comment FROM tc_priorities WHERE id = $1`, id)
if err != nil {
return model.TCPriority{}, err
}
defer rows.Close()
if !rows.Next() {
return model.TCPriority{}, ErrNotFound
}
return scanTCPriority(rows)
}
func scanTCPriority(rows pgx.Rows) (model.TCPriority, error) {
var p model.TCPriority
var dport, sport []byte
if err := rows.Scan(&p.ID, &p.Device, &p.Band, &p.Proto, &dport, &sport, &p.Address, &p.Interface, &p.Helper, &p.Comment); err != nil {
return p, err
}
if err := unmarshalStrings(dport, &p.DPort); err != nil {
return p, err
}
return p, unmarshalStrings(sport, &p.SPort)
}
func (s *Store) CreateTCPriority(ctx context.Context, p model.TCPriority) (int64, error) {
dport, _ := jsonb(p.DPort)
sport, _ := jsonb(p.SPort)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tc_priorities (device, band, proto, dport, sport, address, interface, helper, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
p.Device, p.Band, p.Proto, dport, sport, p.Address, p.Interface, p.Helper, p.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTCPriority(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tc_priorities WHERE id = $1`, id)
}