Compare commits

..

20 Commits

Author SHA1 Message Date
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
benvin d9d192757b Add per-device L2/misc long-tail: tunnels/stopped_rules/proxy_arp/proxy_ndp/arp_rules/maclist
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 0007, model, id-keyed store, REST handlers) + compiler
rendering, each owned by a device. proxy_arp/proxy_ndp share the ProxyEntry
shape via table-parameterized store helpers.
2026-07-26 15:50:01 +10:00
benvin 22e0d07227 Merge pull request 'Long-tail batch 2 (API): per-device routing (hosts/providers/routes/routing_rules)' (#10) from benvin/longtail-routing into main
Reviewed-on: #10
2026-07-26 15:44:59 +10:00
benvin 721f4c1af3 Add per-device routing long-tail: hosts, providers, routes, routing_rules
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 0006, model, id-keyed store, REST handlers) plus
compiler rendering: each section is owned by a device and projected into that
device's rendered config (hosts/providers/routes/routing_rules).
2026-07-26 15:10:47 +10:00
benvin bd97b13ff7 Merge pull request 'Add global-tier long-tail resources (policy CRUD, blrules, conntrack)' (#9) from benvin/longtail-global into main
Reviewed-on: #9
2026-07-26 15:07:59 +10:00
benvin af7117faae Add global-tier long-tail resources: policy CRUD, blrules, conntrack
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
- Wire policy CRUD (the policies table existed from 0001 but had no endpoint;
  policies already render in the compiler, so this closes that loop).
- Add blrules (blacklist/whitelist) and conntrack resources: migration 0005,
  model types, id-keyed store CRUD (generation-bumping), and REST handlers.
Compiler rendering of blrules/conntrack into device configs is a tracked
follow-up (policy already renders).
2026-07-26 00:06:17 +10:00
benvin 2d1b317e0a Merge pull request 'Fix ASN DB URL to the remote-proxy scheme' (#8) from benvin/fix-asn-db-url into main
Reviewed-on: #8
2026-07-24 00:05:23 +10:00
benvin ef9a71bf0a Fix ASN DB URL to the remote-proxy scheme
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Remote proxies are served at /api/v1/remote/<name>/<path>, not the v2
/remotes/<name>/files/ path (which is the local-repo PUT scheme). Correct the
default iplocate DB URL accordingly. Verified the scheme against an existing
github-remote asset (returns 200).
2026-07-22 00:59:37 +10:00
benvin 86876687a7 Merge pull request 'Expand ASN groups from the iplocate ip-to-asn database' (#7) from benvin/asn-db into main
Reviewed-on: #7
2026-07-22 00:53:19 +10:00
benvin 92213090fe Expand ASN groups from the iplocate ip-to-asn database
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Replace the (unconfirmed) iplocate API expander with a database-backed one that
reads the iplocate ip-to-asn CSV (network,asn,...) proxied through the artifactapi
github remote. It downloads and indexes the whole DB once (ASN -> CIDRs), serves
every asn address group from the in-memory index, and rebuilds on a 24h TTL;
refresh failures keep the last-good index (fail-safe). No API key needed.

- Add IPLocateDB expander (zip + CSV parsing, ASN normalization).
- Wire it in main (TOMSWALLAPI_IPLOCATE_DB_URL overrides the default artifactapi
  URL); remove the dead API client.
- Unit tests: CSV indexing (incl. quoted org fields), zip extraction, missing
  columns, and ASN normalization.
2026-07-21 22:36:19 +10:00
benvin dc3b2ecdb9 Merge pull request 'Render the NAT tier into per-device configs' (#6) from benvin/render-nat into main
Reviewed-on: #6
2026-07-21 22:30:01 +10:00
benvin 335c61383a Render the NAT tier into per-device configs
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
The compiler now projects the global NAT intents through each device's bindings
into the rendered config:
- snat/masquerade: renders on a device that binds the egress zone (and, when the
  source is a zone, that zone too), resolving the egress interface — this
  auto-scopes masquerade to edge devices. A literal-CIDR source needs only the
  egress binding.
- netmap: renders on the device its anchor (device:zone|interface) names,
  resolving a zone anchor to its bound interface.
- 1:1 nat: renders on the device it is bound to.
Adds RenderedSNAT/RenderedNetmap/RenderedNAT to the rendered config, fetches the
tiers in Compile, and unit-tests binding-scoping (edge vs interior/other device).
2026-07-21 22:19:20 +10:00
benvin a5957d0e98 Merge pull request 'Store device FIB for observability (no rule limiting)' (#5) from benvin/device-fib into main
Reviewed-on: #5
2026-07-21 00:16:31 +10:00
benvin 9dbeb62414 Store device FIB for observability (no rule limiting)
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Devices carry a reachable_prefixes set (migration 0004) reported by their agent
from the kernel FIB, via a new agent-authed POST /devices/{name}/routes endpoint.
This is scoping/observability data, so it does not bump the config generation and
is exposed on the device resource.

It deliberately does NOT limit which rules a device enforces: the compiler still
emits every applicable rule on every enforcing device. The interface-agnostic,
address-matched rule form is correct under ECMP precisely because it does not
depend on which device is on the path, and most routers hold a default route
anyway — so reachability could not meaningfully narrow the rule set. The reported
FIB is kept for fleet visibility and future zone-origin validation.
2026-07-20 23:51:48 +10:00
benvin b350c8d198 Merge pull request 'Add NAT-tier resources and testcontainers integration tests' (#4) from benvin/longtail-resources into main
Reviewed-on: #4
2026-07-20 23:46:19 +10:00
benvin 5060804359 Add NAT-tier resources and testcontainers integration tests
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
- Add snat/masquerade, netmap, and 1:1 nat as stored, terraformable resources:
  migration 0003, model types, store CRUD (id-keyed, generation-bumping), and
  REST handlers. These are the global-intent/device-resolved NAT tier; compiler
  rendering of NAT into per-device configs is a tracked follow-up.
- Add a testcontainers-backed store integration suite exercising the CRUD
  lifecycle, generation bumping, source/dest grammar validation, and FK cascade
  against a real Postgres. It self-skips under 'go test -short' (the CI path) so
  a container runtime is only needed for the full run.
2026-07-20 22:25:36 +10:00
43 changed files with 4547 additions and 139 deletions
+8 -11
View File
@@ -54,18 +54,15 @@ func main() {
slog.Warn("TOMSWALLAPI_AGENT_TOKEN is not set; agent config endpoint is disabled")
}
// Start the central ASN expander when a key is configured. Without one, asn
// address groups simply stay unexpanded (their sets render empty and inert).
if cfg.IPLocateAPIKey != "" {
refresher := &asnexpand.Refresher{
Store: store.New(db.Pool),
Expander: asnexpand.NewIPLocate(cfg.IPLocateAPIKey),
}
go refresher.Run(ctx)
slog.Info("started ASN expander")
} else {
slog.Warn("TOMSWALLAPI_IPLOCATE_API_KEY is not set; asn address groups will not be expanded")
// Start the central ASN expander, which reads the iplocate ip-to-asn database
// (proxied via artifactapi) and expands asn address groups into prefixes.
expander := asnexpand.NewIPLocateDB(cfg.IPLocateDBURL)
refresher := &asnexpand.Refresher{
Store: store.New(db.Pool),
Expander: expander,
}
go refresher.Run(ctx)
slog.Info("started ASN expander", "source", "iplocate-db", "url", expander.URL)
srv := server.New(server.Options{
DB: db,
+53 -3
View File
@@ -5,15 +5,65 @@ go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/jackc/pgx/v5 v5.10.0
github.com/testcontainers/testcontainers-go v0.43.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+130 -7
View File
@@ -1,9 +1,54 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -12,26 +57,104 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs=
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
-94
View File
@@ -1,94 +0,0 @@
package asnexpand
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// IPLocate expands ASNs via the iplocate.io IP-intelligence API.
//
// NOTE: iplocate's documented ASN data type is returned as part of an IP lookup
// (it carries the single `route` for that IP), so the exact ASN->prefixes
// endpoint depends on the account/plan. This client is deliberately tolerant of
// response shape and endpoint-configurable so it can be pointed at the correct
// endpoint once the API key and plan are confirmed. It expects a JSON body
// containing a list of prefixes under any of: "prefixes", "routes", "cidrs".
type IPLocate struct {
// BaseURL is the API root, default https://iplocate.io/api. The ASN path is
// BaseURL + "/asn/" + asn.
BaseURL string
// APIKey is sent as the ?apikey= query parameter (iplocate's scheme).
APIKey string
HTTP *http.Client
}
// NewIPLocate builds a client with sane defaults.
func NewIPLocate(apiKey string) *IPLocate {
return &IPLocate{
BaseURL: "https://iplocate.io/api",
APIKey: apiKey,
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// asnResponse tolerates the several field names an ASN-prefix payload might use.
type asnResponse struct {
Prefixes []string `json:"prefixes"`
Routes []string `json:"routes"`
CIDRs []string `json:"cidrs"`
}
func (r asnResponse) list() []string {
switch {
case len(r.Prefixes) > 0:
return r.Prefixes
case len(r.Routes) > 0:
return r.Routes
default:
return r.CIDRs
}
}
// Prefixes implements Expander.
func (c *IPLocate) Prefixes(ctx context.Context, asn string) ([]string, error) {
if c.APIKey == "" {
return nil, fmt.Errorf("iplocate: no API key configured")
}
asn = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(asn)), "AS")
u, err := url.Parse(fmt.Sprintf("%s/asn/AS%s", strings.TrimRight(c.BaseURL, "/"), asn))
if err != nil {
return nil, err
}
q := u.Query()
q.Set("apikey", c.APIKey)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("iplocate: AS%s: status %d: %s", asn, resp.StatusCode, strings.TrimSpace(string(body)))
}
var parsed asnResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("iplocate: AS%s: decoding response: %w", asn, err)
}
return parsed.list(), nil
}
+174
View File
@@ -0,0 +1,174 @@
package asnexpand
import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// DefaultIPLocateDBURL is the iplocate ip-to-asn CSV database, proxied through
// the artifactapi github remote. Remote proxies are served at
// /api/v1/remote/<name>/<path>. The files are Git-LFS, so the /raw/ path
// redirects to the LFS media host — artifactapi follows that redirect.
const DefaultIPLocateDBURL = "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/github/iplocate/ip-address-databases/raw/main/ip-to-asn/ip-to-asn.csv.zip"
// IPLocateDB expands ASNs by reading iplocate's ip-to-asn CSV (CIDR,asn,...). It
// downloads and indexes the whole database once, then serves every ASN group
// from the in-memory index, rebuilding when the index goes stale. This is far
// cheaper than a per-ASN API call and needs no API key.
type IPLocateDB struct {
URL string
TTL time.Duration
HTTP *http.Client
mu sync.Mutex
index map[string][]string
loadedAt time.Time
}
// NewIPLocateDB builds a DB-backed expander. An empty url uses the default.
func NewIPLocateDB(url string) *IPLocateDB {
if url == "" {
url = DefaultIPLocateDBURL
}
return &IPLocateDB{
URL: url,
TTL: 24 * time.Hour,
HTTP: &http.Client{Timeout: 5 * time.Minute},
}
}
// Prefixes implements Expander: it returns the CIDRs announced by the ASN.
func (d *IPLocateDB) Prefixes(ctx context.Context, asn string) ([]string, error) {
if err := d.ensureIndex(ctx); err != nil {
return nil, err
}
d.mu.Lock()
defer d.mu.Unlock()
return d.index[normalizeASN(asn)], nil
}
// ensureIndex (re)builds the index when it is missing or stale. On a refresh
// failure it keeps the prior index (fail-safe) and only errors when there is
// nothing cached yet.
func (d *IPLocateDB) ensureIndex(ctx context.Context) error {
d.mu.Lock()
fresh := d.index != nil && time.Since(d.loadedAt) < d.TTL
hadIndex := d.index != nil
d.mu.Unlock()
if fresh {
return nil
}
idx, err := d.download(ctx)
if err != nil {
if hadIndex {
return nil
}
return err
}
d.mu.Lock()
d.index = idx
d.loadedAt = time.Now()
d.mu.Unlock()
return nil
}
func (d *IPLocateDB) download(ctx context.Context) (map[string][]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
if err != nil {
return nil, err
}
resp, err := d.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("iplocate db: status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return indexFromZip(data)
}
// indexFromZip finds the single .csv in the archive and indexes it.
func indexFromZip(data []byte) (map[string][]string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("iplocate db: opening zip: %w", err)
}
for _, f := range zr.File {
if !strings.HasSuffix(f.Name, ".csv") {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return buildIndex(rc)
}
return nil, fmt.Errorf("iplocate db: no .csv entry in archive")
}
// buildIndex reads the ip-to-asn CSV (header: network,asn,country_code,name,org,
// domain) and returns a map of ASN -> announced CIDRs.
func buildIndex(r io.Reader) (map[string][]string, error) {
cr := csv.NewReader(bufio.NewReaderSize(r, 1<<20))
cr.ReuseRecord = true
cr.FieldsPerRecord = -1 // tolerate quoted commas (org names)
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("iplocate db: reading header: %w", err)
}
netIdx := columnIndex(header, "network")
asnIdx := columnIndex(header, "asn")
if netIdx < 0 || asnIdx < 0 {
return nil, fmt.Errorf("iplocate db: header missing network/asn columns: %v", header)
}
index := map[string][]string{}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("iplocate db: reading row: %w", err)
}
if netIdx >= len(rec) || asnIdx >= len(rec) {
continue
}
// Records are reused across Read calls, so clone the retained fields.
asn := strings.Clone(rec[asnIdx])
index[asn] = append(index[asn], strings.Clone(rec[netIdx]))
}
return index, nil
}
func columnIndex(header []string, name string) int {
for i, h := range header {
if strings.EqualFold(strings.TrimSpace(h), name) {
return i
}
}
return -1
}
// normalizeASN strips an optional AS prefix so "AS13335" and "13335" both match
// the bare numbers in the database.
func normalizeASN(asn string) string {
return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(asn)), "AS")
}
+85
View File
@@ -0,0 +1,85 @@
package asnexpand
import (
"archive/zip"
"bytes"
"sort"
"strings"
"testing"
)
const sampleCSV = `network,asn,country_code,name,org,domain
1.0.0.0/24,13335,US,CLOUDFLARENET,"Cloudflare, Inc.",cloudflare.com
1.1.1.0/24,13335,US,CLOUDFLARENET,"Cloudflare, Inc.",cloudflare.com
1.0.4.0/24,38803,AU,GTELECOM-AS-AP,Gtelecom Pty Ltd,gtelecom.com.au
104.16.0.0/13,13335,US,CLOUDFLARENET,"Cloudflare, Inc.",cloudflare.com
`
func TestBuildIndex(t *testing.T) {
idx, err := buildIndex(strings.NewReader(sampleCSV))
if err != nil {
t.Fatalf("buildIndex: %v", err)
}
cf := idx["13335"]
sort.Strings(cf)
want := []string{"1.0.0.0/24", "1.1.1.0/24", "104.16.0.0/13"}
sort.Strings(want)
if strings.Join(cf, ",") != strings.Join(want, ",") {
t.Errorf("AS13335 prefixes = %v, want %v", cf, want)
}
if got := idx["38803"]; len(got) != 1 || got[0] != "1.0.4.0/24" {
t.Errorf("AS38803 prefixes = %v", got)
}
// The quoted org field with an embedded comma must not shift columns.
if len(idx) != 2 {
t.Errorf("expected 2 distinct ASNs, got %d", len(idx))
}
}
func TestBuildIndexMissingColumns(t *testing.T) {
if _, err := buildIndex(strings.NewReader("foo,bar\n1,2\n")); err == nil {
t.Fatal("expected error when network/asn columns are absent")
}
}
func TestIndexFromZip(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("ip-to-asn-20260721.csv")
_, _ = w.Write([]byte(sampleCSV))
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
idx, err := indexFromZip(buf.Bytes())
if err != nil {
t.Fatalf("indexFromZip: %v", err)
}
if len(idx["13335"]) != 3 {
t.Errorf("expected 3 cloudflare prefixes from zip, got %d", len(idx["13335"]))
}
}
func TestIndexFromZipNoCSV(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("readme.txt")
_, _ = w.Write([]byte("no csv here"))
_ = zw.Close()
if _, err := indexFromZip(buf.Bytes()); err == nil {
t.Fatal("expected error when archive has no .csv")
}
}
func TestNormalizeASN(t *testing.T) {
for in, want := range map[string]string{
"13335": "13335",
"AS13335": "13335",
"as13335": "13335",
" 13335 ": "13335",
} {
if got := normalizeASN(in); got != want {
t.Errorf("normalizeASN(%q) = %q, want %q", in, got, want)
}
}
}
+247 -20
View File
@@ -11,6 +11,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -21,30 +22,106 @@ import (
// Input is the fully-resolved model needed to render one device. Keeping Render
// pure (no store access) makes it unit-testable without a database.
type Input struct {
Generation int64
Settings model.Settings
Device model.Device
Fabric *model.Fabric
Zones map[string]model.Zone
Groups map[string]model.AddressGroup
PortGroups map[string]model.PortGroup
Rules []model.Rule
Policies []model.Policy
Bindings []model.Binding
Generation int64
Settings model.Settings
Device model.Device
Fabric *model.Fabric
Zones map[string]model.Zone
Groups map[string]model.AddressGroup
PortGroups map[string]model.PortGroup
Rules []model.Rule
Policies []model.Policy
Bindings []model.Binding
SNAT []model.SNATRule
Netmap []model.NetmapRule
NAT []model.NATRule
Hosts []model.Host
Providers []model.Provider
Routes []model.Route
RoutingRules []model.RoutingRule
Tunnels []model.Tunnel
StoppedRules []model.StoppedRule
ProxyARP []model.ProxyEntry
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.
type RenderedConfig struct {
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class model.DeviceClass `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"`
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class model.DeviceClass `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"`
SNAT []RenderedSNAT `yaml:"snat,omitempty" json:"snat,omitempty"`
Netmap []RenderedNetmap `yaml:"netmap,omitempty" json:"netmap,omitempty"`
NAT []RenderedNAT `yaml:"nat,omitempty" json:"nat,omitempty"`
Hosts []RenderedHost `yaml:"hosts,omitempty" json:"hosts,omitempty"`
Providers []RenderedProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
Routes []RenderedRoute `yaml:"routes,omitempty" json:"routes,omitempty"`
RoutingRules []RenderedRoutingRule `yaml:"routing_rules,omitempty" json:"routing_rules,omitempty"`
Tunnels []RenderedTunnel `yaml:"tunnels,omitempty" json:"tunnels,omitempty"`
StoppedRules []RenderedStoppedRule `yaml:"stopped_rules,omitempty" json:"stopped_rules,omitempty"`
ProxyARP []RenderedProxy `yaml:"proxy_arp,omitempty" json:"proxy_arp,omitempty"`
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
// (or SNATed to Address) as they leave via the resolved egress interfaces.
type RenderedSNAT struct {
Action string `yaml:"action" json:"action"`
Source []string `yaml:"source,omitempty" json:"source,omitempty"` // source CIDRs
Egress []string `yaml:"egress" json:"egress"` // egress interface names
Address string `yaml:"address,omitempty" json:"address,omitempty"`
Probability *float64 `yaml:"probability,omitempty" json:"probability,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// RenderedNetmap is a resolved network-to-network mapping on one interface.
type RenderedNetmap struct {
Type string `yaml:"type" json:"type"`
FromNet string `yaml:"from_net" json:"from_net"`
ToNet string `yaml:"to_net" json:"to_net"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// RenderedNAT is a resolved one-to-one static NAT on one interface.
type RenderedNAT struct {
External string `yaml:"external" json:"external"`
Internal string `yaml:"internal" json:"internal"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// RenderedSettings is the effective settings after per-device overrides.
@@ -131,6 +208,10 @@ func Render(in Input) (*RenderedConfig, error) {
usedSets := map[string]model.AddressGroup{}
// Every enforcing device carries every applicable rule: the interface-agnostic
// address-matched form is correct under ECMP precisely because it does not
// depend on which device is on the path (over-approximation is safe). Reported
// FIBs are stored for observability/validation, not to limit rules.
if out.Enforcing {
for _, rule := range in.Rules {
rr, err := renderRule(in, rule, usedSets)
@@ -151,9 +232,83 @@ func Render(in Input) (*RenderedConfig, error) {
for _, n := range names {
out.Sets = append(out.Sets, renderSet(usedSets[n]))
}
// NAT tier: resolve the global NAT intents against this device's bindings.
// These are binding-scoped, independent of the forward-rule enforce flag.
out.SNAT = renderSNATRules(in, out.Bindings)
out.Netmap = renderNetmapRules(in, out.Bindings)
out.NAT = renderNATRules(in)
// Per-device long-tail sections owned by this device.
renderPerDevice(in, out)
renderPerDeviceL2(in, out)
renderTraffic(in, out)
renderGlobal2(in, out)
return out, nil
}
// renderSNATRules resolves masquerade/SNAT intents that egress via this device.
// A SNAT lands here only if the device binds the egress zone (and, when the
// source is a zone, that zone too) — which auto-scopes masquerade to edges.
func renderSNATRules(in Input, bindings map[string][]string) []RenderedSNAT {
var out []RenderedSNAT
for _, s := range in.SNAT {
egress := bindings[s.Egress]
if len(egress) == 0 {
continue // device is not an egress for this SNAT
}
var source []string
if z, ok := in.Zones[s.Source]; ok {
if _, bound := bindings[s.Source]; !bound {
continue // device does not attach the source zone
}
source = z.Subnets
} else {
source = []string{s.Source} // literal CIDR
}
out = append(out, RenderedSNAT{
Action: s.Action, Source: source, Egress: egress,
Address: s.Address, Probability: s.Probability, Comment: s.Comment,
})
}
return out
}
// renderNetmapRules resolves netmaps anchored (device:zone or device:interface)
// at this device.
func renderNetmapRules(in Input, bindings map[string][]string) []RenderedNetmap {
var out []RenderedNetmap
for _, n := range in.Netmap {
dev, sel, ok := strings.Cut(n.Anchor, ":")
if !ok || dev != in.Device.Name {
continue
}
iface := sel
if ifaces, bound := bindings[sel]; bound && len(ifaces) > 0 {
iface = ifaces[0] // anchor named a zone: use its bound interface
}
out = append(out, RenderedNetmap{
Type: n.Type, FromNet: n.FromNet, ToNet: n.ToNet, Interface: iface, Comment: n.Comment,
})
}
return out
}
// renderNATRules resolves 1:1 static NATs bound to this device.
func renderNATRules(in Input) []RenderedNAT {
var out []RenderedNAT
for _, n := range in.NAT {
if n.Device != in.Device.Name {
continue
}
out = append(out, RenderedNAT{
External: n.External, Internal: n.Internal, Interface: n.Interface, Comment: n.Comment,
})
}
return out
}
func renderSettings(in Input) RenderedSettings {
s := RenderedSettings{
AddressFamily: in.Settings.AddressFamily,
@@ -310,5 +465,77 @@ func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfi
if in.Bindings, err = s.ListBindings(ctx, device); err != nil {
return nil, err
}
if in.SNAT, err = s.ListSNAT(ctx); err != nil {
return nil, err
}
if in.Netmap, err = s.ListNetmap(ctx); err != nil {
return nil, err
}
if in.NAT, err = s.ListNAT(ctx); err != nil {
return nil, err
}
if in.Hosts, err = s.ListHosts(ctx); err != nil {
return nil, err
}
if in.Providers, err = s.ListProviders(ctx); err != nil {
return nil, err
}
if in.Routes, err = s.ListRoutes(ctx); err != nil {
return nil, err
}
if in.RoutingRules, err = s.ListRoutingRules(ctx); err != nil {
return nil, err
}
if in.Tunnels, err = s.ListTunnels(ctx); err != nil {
return nil, err
}
if in.StoppedRules, err = s.ListStoppedRules(ctx); err != nil {
return nil, err
}
if in.ProxyARP, err = s.ListProxyARP(ctx); err != nil {
return nil, err
}
if in.ProxyNDP, err = s.ListProxyNDP(ctx); err != nil {
return nil, err
}
if in.ArpRules, err = s.ListArpRules(ctx); err != nil {
return nil, err
}
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)
}
+80
View File
@@ -116,6 +116,86 @@ func TestRenderUnknownGroupIsError(t *testing.T) {
}
}
func TestReportedFIBDoesNotLimitRules(t *testing.T) {
// A router with a narrow FIB must still carry every applicable rule: reported
// reachability is observability data, not a rule filter (over-approximation is
// safe and intended under ECMP).
in := Input{
Fabric: &model.Fabric{Name: "core", EnforceOnRouters: true},
Device: model.Device{Name: "rt1", Class: model.ClassRouter, Fabric: "core",
ReachablePrefixes: []string{"192.168.0.0/16"}},
Zones: map[string]model.Zone{
"zone-a": {Name: "zone-a", Subnets: []string{"10.1.0.0/24"}},
"zone-b": {Name: "zone-b", Subnets: []string{"10.4.0.0/24"}},
},
Rules: []model.Rule{
{ID: 1, Action: "accept", Source: []string{"zone-a"}, Dest: []string{"zone-b"}, Proto: "tcp", Ports: []string{"22"}},
},
}
cfg, err := Render(in)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.Rules) != 1 {
t.Errorf("reported FIB must not limit rules, got %d", len(cfg.Rules))
}
}
func natInput() Input {
return Input{
Zones: map[string]model.Zone{
"loc": {Name: "loc", Subnets: []string{"10.1.0.0/24"}},
"net": {Name: "net"},
},
SNAT: []model.SNATRule{{ID: 1, Action: "masquerade", Source: "loc", Egress: "net"}},
Netmap: []model.NetmapRule{{ID: 1, Type: "dnat", FromNet: "10.0.0.0/24", ToNet: "192.168.1.0/24", Anchor: "fw-a:net"}},
NAT: []model.NATRule{{ID: 1, Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10", Interface: "eth0"}},
}
}
func TestRenderNATScopesToBindings(t *testing.T) {
// fw-a binds both loc and net (an edge) → masquerade + its netmap + its nat.
edge := natInput()
edge.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall}
edge.Bindings = []model.Binding{
{Device: "fw-a", Zone: "loc", Interfaces: []string{"eth1"}},
{Device: "fw-a", Zone: "net", Interfaces: []string{"eth0"}},
}
cfg, err := Render(edge)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.SNAT) != 1 || cfg.SNAT[0].Action != "masquerade" ||
len(cfg.SNAT[0].Source) != 1 || cfg.SNAT[0].Source[0] != "10.1.0.0/24" ||
len(cfg.SNAT[0].Egress) != 1 || cfg.SNAT[0].Egress[0] != "eth0" {
t.Errorf("edge masquerade not rendered correctly: %+v", cfg.SNAT)
}
if len(cfg.Netmap) != 1 || cfg.Netmap[0].Interface != "eth0" {
t.Errorf("netmap anchor not resolved to eth0: %+v", cfg.Netmap)
}
if len(cfg.NAT) != 1 || cfg.NAT[0].External != "203.0.113.10" {
t.Errorf("1:1 nat not rendered on its device: %+v", cfg.NAT)
}
}
func TestRenderNATSkipsNonEgressAndOtherDevices(t *testing.T) {
// rt1 binds only net (not loc): masquerade requires both, so it's skipped;
// the netmap/nat are anchored/bound to fw-a, so they don't render here either.
interior := natInput()
interior.Device = model.Device{Name: "rt1", Class: model.ClassRouter}
interior.Bindings = []model.Binding{{Device: "rt1", Zone: "net", Interfaces: []string{"eth0"}}}
cfg, err := Render(interior)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(cfg.SNAT) != 0 {
t.Errorf("masquerade should not render without the source-zone binding: %+v", cfg.SNAT)
}
if len(cfg.Netmap) != 0 || len(cfg.NAT) != 0 {
t.Errorf("netmap/nat must not render on a device they aren't bound to: %+v %+v", cfg.Netmap, cfg.NAT)
}
}
func TestEffectiveResolverPrefersDevice(t *testing.T) {
in := baseInput()
in.Device = model.Device{Name: "fw-a", Class: model.ClassFirewall, Resolver: []string{"10.9.9.9"}}
+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,
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package compiler
// Per-device long-tail sections rendered into a device's config. The compiler
// filters each global list to the entries owned by the device.
type RenderedHost struct {
Zone string `yaml:"zone" json:"zone"`
Interface string `yaml:"interface" json:"interface"`
Addresses []string `yaml:"addresses,omitempty" json:"addresses,omitempty"`
Exclusions []string `yaml:"exclusions,omitempty" json:"exclusions,omitempty"`
Dynamic bool `yaml:"dynamic,omitempty" json:"dynamic,omitempty"`
}
type RenderedProvider struct {
Name string `yaml:"name" json:"name"`
Number int `yaml:"number" json:"number"`
Mark int `yaml:"mark,omitempty" json:"mark,omitempty"`
Duplicate string `yaml:"duplicate,omitempty" json:"duplicate,omitempty"`
Interface string `yaml:"interface" json:"interface"`
Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"`
Copy []string `yaml:"copy,omitempty" json:"copy,omitempty"`
}
type RenderedRoute struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
Dest string `yaml:"dest" json:"dest"`
Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"`
Oif string `yaml:"oif,omitempty" json:"oif,omitempty"`
Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedRoutingRule struct {
Source string `yaml:"source,omitempty" json:"source,omitempty"`
Dest string `yaml:"dest,omitempty" json:"dest,omitempty"`
Provider string `yaml:"provider" json:"provider"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"`
Mark string `yaml:"mark,omitempty" json:"mark,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// renderPerDevice projects the per-device long-tail sections owned by this device.
func renderPerDevice(in Input, out *RenderedConfig) {
dev := in.Device.Name
for _, h := range in.Hosts {
if h.Device != dev {
continue
}
out.Hosts = append(out.Hosts, RenderedHost{
Zone: h.Zone, Interface: h.Interface, Addresses: h.Addresses,
Exclusions: h.Exclusions, Dynamic: h.Dynamic,
})
}
for _, p := range in.Providers {
if p.Device != dev {
continue
}
out.Providers = append(out.Providers, RenderedProvider{
Name: p.Name, Number: p.Number, Mark: p.Mark, Duplicate: p.Duplicate,
Interface: p.Interface, Gateway: p.Gateway, Copy: p.Copy,
})
}
for _, r := range in.Routes {
if r.Device != dev {
continue
}
out.Routes = append(out.Routes, RenderedRoute{
Provider: r.Provider, Dest: r.Dest, Gateway: r.Gateway,
Oif: r.Oif, Persistent: r.Persistent, Comment: r.Comment,
})
}
for _, r := range in.RoutingRules {
if r.Device != dev {
continue
}
out.RoutingRules = append(out.RoutingRules, RenderedRoutingRule{
Source: r.Source, Dest: r.Dest, Provider: r.Provider, Priority: r.Priority,
Persistent: r.Persistent, Mark: r.Mark, Comment: r.Comment,
})
}
}
+111
View File
@@ -0,0 +1,111 @@
package compiler
import "git.unkin.net/unkin/tomswallapi/internal/model"
// Per-device L2/misc long-tail sections rendered into a device's config.
type RenderedTunnel struct {
Type string `yaml:"type" json:"type"`
Zone string `yaml:"zone" json:"zone"`
Gateways []string `yaml:"gateways,omitempty" json:"gateways,omitempty"`
GatewayZones []string `yaml:"gateway_zones,omitempty" json:"gateway_zones,omitempty"`
Port int `yaml:"port,omitempty" json:"port,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedStoppedRule 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"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedProxy struct {
Address string `yaml:"address" json:"address"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
External string `yaml:"external" json:"external"`
HaveRoute bool `yaml:"haveroute,omitempty" json:"haveroute,omitempty"`
Persistent bool `yaml:"persistent,omitempty" json:"persistent,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedArpRule struct {
Action string `yaml:"action" json:"action"`
ActionAddress string `yaml:"action_address,omitempty" json:"action_address,omitempty"`
ActionMAC string `yaml:"action_mac,omitempty" json:"action_mac,omitempty"`
Source string `yaml:"source,omitempty" json:"source,omitempty"`
Dest string `yaml:"dest,omitempty" json:"dest,omitempty"`
Opcode int `yaml:"opcode,omitempty" json:"opcode,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedMaclist struct {
Action string `yaml:"action" json:"action"`
Interface string `yaml:"interface" json:"interface"`
MAC string `yaml:"mac,omitempty" json:"mac,omitempty"`
Addresses []string `yaml:"addresses,omitempty" json:"addresses,omitempty"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
func renderPerDeviceL2(in Input, out *RenderedConfig) {
dev := in.Device.Name
for _, t := range in.Tunnels {
if t.Device != dev {
continue
}
out.Tunnels = append(out.Tunnels, RenderedTunnel{
Type: t.Type, Zone: t.Zone, Gateways: t.Gateways,
GatewayZones: t.GatewayZones, Port: t.Port, Comment: t.Comment,
})
}
for _, r := range in.StoppedRules {
if r.Device != dev {
continue
}
out.StoppedRules = append(out.StoppedRules, RenderedStoppedRule{
Action: r.Action, Source: r.Source, Dest: r.Dest, Proto: r.Proto,
DPort: r.DPort, SPort: r.SPort, Comment: r.Comment,
})
}
for _, p := range in.ProxyARP {
if p.Device != dev {
continue
}
out.ProxyARP = append(out.ProxyARP, renderProxy(p))
}
for _, p := range in.ProxyNDP {
if p.Device != dev {
continue
}
out.ProxyNDP = append(out.ProxyNDP, renderProxy(p))
}
for _, a := range in.ArpRules {
if a.Device != dev {
continue
}
out.ArpRules = append(out.ArpRules, RenderedArpRule{
Action: a.Action, ActionAddress: a.ActionAddress, ActionMAC: a.ActionMAC,
Source: a.Source, Dest: a.Dest, Opcode: a.Opcode, Comment: a.Comment,
})
}
for _, m := range in.Maclist {
if m.Device != dev {
continue
}
out.Maclist = append(out.Maclist, RenderedMaclist{
Action: m.Action, Interface: m.Interface, MAC: m.MAC,
Addresses: m.Addresses, Log: m.Log, Comment: m.Comment,
})
}
}
func renderProxy(p model.ProxyEntry) RenderedProxy {
return RenderedProxy{
Address: p.Address, Interface: p.Interface, External: p.External,
HaveRoute: p.HaveRoute, Persistent: p.Persistent, Comment: p.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,
})
}
}
+6 -1
View File
@@ -23,8 +23,12 @@ type Config struct {
// AgentToken guards the per-device config endpoint (used by tomswall agents).
AgentToken string
// IPLocateAPIKey is used to expand ASN address groups into prefixes.
// IPLocateAPIKey is used to expand ASN address groups into prefixes via the
// iplocate API (legacy path). The DB expander below is preferred.
IPLocateAPIKey string
// IPLocateDBURL points at the iplocate ip-to-asn CSV database (proxied via
// artifactapi). Empty uses the built-in default.
IPLocateDBURL string
}
// Load reads configuration from the environment, applying defaults.
@@ -40,6 +44,7 @@ func Load() (*Config, error) {
WriteToken: os.Getenv("TOMSWALLAPI_WRITE_TOKEN"),
AgentToken: os.Getenv("TOMSWALLAPI_AGENT_TOKEN"),
IPLocateAPIKey: os.Getenv("TOMSWALLAPI_IPLOCATE_API_KEY"),
IPLocateDBURL: os.Getenv("TOMSWALLAPI_IPLOCATE_DB_URL"),
}
if c.DBName == "" {
return nil, fmt.Errorf("TOMSWALLAPI_DB_NAME must not be empty")
@@ -0,0 +1,37 @@
-- NAT-tier resources: SNAT/masquerade, netmap, and 1:1 static NAT. These are the
-- "global-intent, device-resolved" tier — declared centrally, resolved per device
-- via bindings. Stored here so they are terraformable; compiler rendering of the
-- NAT tier into per-device configs is a tracked follow-up.
-- SNAT / masquerade. source is a zone name or CIDR; egress is a zone (resolved to
-- the device's egress interface via its binding). address+probability support
-- load-balanced SNAT.
CREATE TABLE snat (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action TEXT NOT NULL CHECK (action IN ('masquerade', 'snat')),
source TEXT NOT NULL,
egress TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
probability REAL,
comment TEXT NOT NULL DEFAULT ''
);
-- Network-to-network mapping (netmap). Anchored at a device+binding or interface.
CREATE TABLE netmap (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('dnat', 'snat')),
from_net TEXT NOT NULL,
to_net TEXT NOT NULL,
anchor TEXT NOT NULL, -- device:zone or device:interface
comment TEXT NOT NULL DEFAULT ''
);
-- One-to-one static NAT, bound to the device that owns the external IP.
CREATE TABLE nat (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
external TEXT NOT NULL,
internal TEXT NOT NULL,
interface TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,7 @@
-- Per-device reachability, reported by the agent from its FIB. The compiler uses
-- it to scope which routers actually need to enforce an intent: a router only
-- carries a rule if it can route to both the source and destination networks.
-- When absent, the compiler safely over-approximates (enforces everywhere).
ALTER TABLE devices
ADD COLUMN reachable_prefixes JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN routes_reported_at TIMESTAMPTZ;
@@ -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 ''
);
@@ -0,0 +1,48 @@
-- Per-device long-tail sections: hosts, providers (multi-ISP), static routes,
-- and routing rules (rtrules). Each is owned by a device and renders into that
-- device's config. (Nested option structs on host/provider are deferred.)
CREATE TABLE hosts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
zone TEXT NOT NULL,
interface TEXT NOT NULL,
addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
exclusions JSONB NOT NULL DEFAULT '[]'::jsonb,
dynamic BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE providers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
name TEXT NOT NULL,
number INT NOT NULL,
mark INT NOT NULL DEFAULT 0,
duplicate TEXT NOT NULL DEFAULT '',
interface TEXT NOT NULL,
gateway TEXT NOT NULL DEFAULT '',
copy JSONB NOT NULL DEFAULT '[]'::jsonb
);
CREATE TABLE routes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT '',
dest TEXT NOT NULL,
gateway TEXT NOT NULL DEFAULT '',
oif TEXT NOT NULL DEFAULT '', -- egress interface (tomswall route "device")
persistent BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE routing_rules (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT '',
dest TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
persistent BOOLEAN NOT NULL DEFAULT false,
mark TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,70 @@
-- Per-device L2/misc long-tail sections: tunnels, stopped_rules, proxy_arp,
-- proxy_ndp, arp_rules, maclist. Each is owned by a device.
CREATE TABLE tunnels (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
type TEXT NOT NULL,
zone TEXT NOT NULL,
gateways JSONB NOT NULL DEFAULT '[]'::jsonb,
gateway_zones JSONB NOT NULL DEFAULT '[]'::jsonb,
port INT NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE stopped_rules (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
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,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE proxy_arp (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
address TEXT NOT NULL,
interface TEXT NOT NULL DEFAULT '',
external TEXT NOT NULL,
haveroute BOOLEAN NOT NULL DEFAULT false,
persistent BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE proxy_ndp (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
address TEXT NOT NULL,
interface TEXT NOT NULL DEFAULT '',
external TEXT NOT NULL,
haveroute BOOLEAN NOT NULL DEFAULT false,
persistent BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE arp_rules (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
action TEXT NOT NULL,
action_address TEXT NOT NULL DEFAULT '',
action_mac TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
dest TEXT NOT NULL DEFAULT '',
opcode INT NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT ''
);
CREATE TABLE maclist (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device TEXT NOT NULL REFERENCES devices(name) ON DELETE CASCADE,
action TEXT NOT NULL,
interface TEXT NOT NULL,
mac TEXT NOT NULL DEFAULT '',
addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
log TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT ''
);
@@ -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 ''
);
+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"`
}
+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"`
}
+4
View File
@@ -99,6 +99,10 @@ type Device struct {
Fabric string `json:"fabric,omitempty"`
Resolver []string `json:"resolver,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
// 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"`
}
// Binding maps a global zone to one device's local interface(s).
+34
View File
@@ -0,0 +1,34 @@
package model
// SNATRule is a source-NAT / masquerade intent. Source is a zone or CIDR; Egress
// is a zone (resolved per device to its egress interface via bindings). Address
// and Probability support load-balanced SNAT.
type SNATRule struct {
ID int64 `json:"id"`
Action string `json:"action"` // masquerade | snat
Source string `json:"source"`
Egress string `json:"egress"`
Address string `json:"address,omitempty"`
Probability *float64 `json:"probability,omitempty"`
Comment string `json:"comment,omitempty"`
}
// NetmapRule maps one network to another, anchored at a device+zone or interface.
type NetmapRule struct {
ID int64 `json:"id"`
Type string `json:"type"` // dnat | snat
FromNet string `json:"from_net"`
ToNet string `json:"to_net"`
Anchor string `json:"anchor"`
Comment string `json:"comment,omitempty"`
}
// NATRule is a one-to-one static NAT bound to the device owning the external IP.
type NATRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
External string `json:"external"`
Internal string `json:"internal"`
Interface string `json:"interface,omitempty"`
Comment string `json:"comment,omitempty"`
}
+54
View File
@@ -0,0 +1,54 @@
package model
// Per-device long-tail sections. Each is owned by a device (the fleet member it
// renders on) and maps to the corresponding tomswall config section.
// Host constrains a zone to specific addresses on a device's interface.
type Host struct {
ID int64 `json:"id"`
Device string `json:"device"`
Zone string `json:"zone"`
Interface string `json:"interface"`
Addresses []string `json:"addresses,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
Dynamic bool `json:"dynamic,omitempty"`
}
// Provider is a multi-ISP routing provider on a device.
type Provider struct {
ID int64 `json:"id"`
Device string `json:"device"`
Name string `json:"name"`
Number int `json:"number"`
Mark int `json:"mark,omitempty"`
Duplicate string `json:"duplicate,omitempty"`
Interface string `json:"interface"`
Gateway string `json:"gateway,omitempty"`
Copy []string `json:"copy,omitempty"`
}
// Route is a static route on a device. Oif is the egress interface (tomswall's
// route "device" field, renamed to avoid colliding with the fleet device).
type Route struct {
ID int64 `json:"id"`
Device string `json:"device"`
Provider string `json:"provider,omitempty"`
Dest string `json:"dest"`
Gateway string `json:"gateway,omitempty"`
Oif string `json:"oif,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Comment string `json:"comment,omitempty"`
}
// RoutingRule directs traffic to a provider's routing table on a device.
type RoutingRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
Source string `json:"source,omitempty"`
Dest string `json:"dest,omitempty"`
Provider string `json:"provider"`
Priority int `json:"priority,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Mark string `json:"mark,omitempty"`
Comment string `json:"comment,omitempty"`
}
+61
View File
@@ -0,0 +1,61 @@
package model
// Per-device L2/misc long-tail sections.
type Tunnel struct {
ID int64 `json:"id"`
Device string `json:"device"`
Type string `json:"type"`
Zone string `json:"zone"`
Gateways []string `json:"gateways,omitempty"`
GatewayZones []string `json:"gateway_zones,omitempty"`
Port int `json:"port,omitempty"`
Comment string `json:"comment,omitempty"`
}
type StoppedRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
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"`
Comment string `json:"comment,omitempty"`
}
// ProxyEntry backs both proxy_arp and proxy_ndp (identical shape).
type ProxyEntry struct {
ID int64 `json:"id"`
Device string `json:"device"`
Address string `json:"address"`
Interface string `json:"interface,omitempty"`
External string `json:"external"`
HaveRoute bool `json:"haveroute,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Comment string `json:"comment,omitempty"`
}
type ArpRule struct {
ID int64 `json:"id"`
Device string `json:"device"`
Action string `json:"action"`
ActionAddress string `json:"action_address,omitempty"`
ActionMAC string `json:"action_mac,omitempty"`
Source string `json:"source,omitempty"`
Dest string `json:"dest,omitempty"`
Opcode int `json:"opcode,omitempty"`
Comment string `json:"comment,omitempty"`
}
type MaclistEntry struct {
ID int64 `json:"id"`
Device string `json:"device"`
Action string `json:"action"`
Interface string `json:"interface"`
MAC string `json:"mac,omitempty"`
Addresses []string `json:"addresses,omitempty"`
Log string `json:"log,omitempty"`
Comment string `json:"comment,omitempty"`
}
+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")))
}
+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))
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountNAT wires the NAT-tier resources (snat/masquerade, netmap, 1:1 nat).
func (s *Server) mountNAT(r chi.Router) {
r.Route("/snat", func(r chi.Router) {
r.Get("/", s.listSNAT)
r.Post("/", s.createSNAT)
r.Get("/{id}", s.getSNAT)
r.Delete("/{id}", s.deleteSNAT)
})
r.Route("/netmap", func(r chi.Router) {
r.Get("/", s.listNetmap)
r.Post("/", s.createNetmap)
r.Get("/{id}", s.getNetmap)
r.Delete("/{id}", s.deleteNetmap)
})
r.Route("/nat", func(r chi.Router) {
r.Get("/", s.listNAT)
r.Post("/", s.createNAT)
r.Get("/{id}", s.getNAT)
r.Delete("/{id}", s.deleteNAT)
})
}
func idParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return 0, false
}
return id, true
}
// ---- SNAT ------------------------------------------------------------------
func (s *Server) listSNAT(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSNAT(r.Context())
respondList(w, list, err)
}
func (s *Server) createSNAT(w http.ResponseWriter, r *http.Request) {
var v model.SNATRule
if !decode(w, r, &v) {
return
}
if v.Action != "masquerade" && v.Action != "snat" {
writeError(w, http.StatusBadRequest, "action must be masquerade or snat")
return
}
id, err := s.store.CreateSNAT(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getSNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetSNAT(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteSNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteSNAT(r.Context(), id))
}
// ---- Netmap ----------------------------------------------------------------
func (s *Server) listNetmap(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListNetmap(r.Context())
respondList(w, list, err)
}
func (s *Server) createNetmap(w http.ResponseWriter, r *http.Request) {
var v model.NetmapRule
if !decode(w, r, &v) {
return
}
if v.Type != "dnat" && v.Type != "snat" {
writeError(w, http.StatusBadRequest, "type must be dnat or snat")
return
}
id, err := s.store.CreateNetmap(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getNetmap(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetNetmap(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteNetmap(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteNetmap(r.Context(), id))
}
// ---- 1:1 NAT ---------------------------------------------------------------
func (s *Server) listNAT(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListNAT(r.Context())
respondList(w, list, err)
}
func (s *Server) createNAT(w http.ResponseWriter, r *http.Request) {
var v model.NATRule
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.External == "" || v.Internal == "" {
writeError(w, http.StatusBadRequest, "device, external, and internal are required")
return
}
id, err := s.store.CreateNAT(r.Context(), v)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v.ID = id
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) getNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetNAT(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteNAT(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteNAT(r.Context(), id))
}
+190
View File
@@ -0,0 +1,190 @@
package server
import (
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountPerDevice wires the per-device long-tail sections: hosts, providers,
// routes, and routing-rules.
func (s *Server) mountPerDevice(r chi.Router) {
r.Route("/hosts", func(r chi.Router) {
r.Get("/", s.listHosts)
r.Post("/", s.createHost)
r.Get("/{id}", s.getHost)
r.Delete("/{id}", s.deleteHost)
})
r.Route("/providers", func(r chi.Router) {
r.Get("/", s.listProviders)
r.Post("/", s.createProvider)
r.Get("/{id}", s.getProvider)
r.Delete("/{id}", s.deleteProvider)
})
r.Route("/routes", func(r chi.Router) {
r.Get("/", s.listRoutes)
r.Post("/", s.createRoute)
r.Get("/{id}", s.getRoute)
r.Delete("/{id}", s.deleteRoute)
})
r.Route("/routing-rules", func(r chi.Router) {
r.Get("/", s.listRoutingRules)
r.Post("/", s.createRoutingRule)
r.Get("/{id}", s.getRoutingRule)
r.Delete("/{id}", s.deleteRoutingRule)
})
}
func (s *Server) listHosts(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListHosts(r.Context())
respondList(w, list, err)
}
func (s *Server) createHost(w http.ResponseWriter, r *http.Request) {
var v model.Host
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Zone == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device, zone, and interface are required")
return
}
id, err := s.store.CreateHost(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getHost(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetHost(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteHost(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteHost(r.Context(), id))
}
func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListProviders(r.Context())
respondList(w, list, err)
}
func (s *Server) createProvider(w http.ResponseWriter, r *http.Request) {
var v model.Provider
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Name == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device, name, and interface are required")
return
}
id, err := s.store.CreateProvider(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getProvider(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetProvider(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteProvider(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteProvider(r.Context(), id))
}
func (s *Server) listRoutes(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListRoutes(r.Context())
respondList(w, list, err)
}
func (s *Server) createRoute(w http.ResponseWriter, r *http.Request) {
var v model.Route
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Dest == "" {
writeError(w, http.StatusBadRequest, "device and dest are required")
return
}
id, err := s.store.CreateRoute(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getRoute(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetRoute(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteRoute(r.Context(), id))
}
func (s *Server) listRoutingRules(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListRoutingRules(r.Context())
respondList(w, list, err)
}
func (s *Server) createRoutingRule(w http.ResponseWriter, r *http.Request) {
var v model.RoutingRule
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Provider == "" {
writeError(w, http.StatusBadRequest, "device and provider are required")
return
}
id, err := s.store.CreateRoutingRule(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getRoutingRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetRoutingRule(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteRoutingRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteRoutingRule(r.Context(), id))
}
+234
View File
@@ -0,0 +1,234 @@
package server
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// mountPerDeviceL2 wires the per-device L2/misc sections: tunnels, stopped_rules,
// proxy_arp, proxy_ndp, arp_rules, maclist.
func (s *Server) mountPerDeviceL2(r chi.Router) {
idCRUD(r, "/tunnels", s.listTunnels, s.createTunnel, s.getTunnel, s.deleteTunnel)
idCRUD(r, "/stopped-rules", s.listStoppedRules, s.createStoppedRule, s.getStoppedRule, s.deleteStoppedRule)
idCRUD(r, "/proxy-arp", s.listProxyARP, s.createProxyARP, s.getProxyARP, s.deleteProxyARP)
idCRUD(r, "/proxy-ndp", s.listProxyNDP, s.createProxyNDP, s.getProxyNDP, s.deleteProxyNDP)
idCRUD(r, "/arp-rules", s.listArpRules, s.createArpRule, s.getArpRule, s.deleteArpRule)
idCRUD(r, "/maclist", s.listMaclist, s.createMaclist, s.getMaclist, s.deleteMaclist)
}
// idCRUD wires the four standard id-keyed handlers for a collection.
func idCRUD(r chi.Router, path string, list, create, get, del http.HandlerFunc) {
r.Route(path, func(r chi.Router) {
r.Get("/", list)
r.Post("/", create)
r.Get("/{id}", get)
r.Delete("/{id}", del)
})
}
func (s *Server) listTunnels(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListTunnels(r.Context())
respondList(w, list, err)
}
func (s *Server) createTunnel(w http.ResponseWriter, r *http.Request) {
var v model.Tunnel
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Type == "" || v.Zone == "" {
writeError(w, http.StatusBadRequest, "device, type, and zone are required")
return
}
id, err := s.store.CreateTunnel(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getTunnel(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetTunnel(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteTunnel(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteTunnel(r.Context(), id))
}
func (s *Server) listStoppedRules(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListStoppedRules(r.Context())
respondList(w, list, err)
}
func (s *Server) createStoppedRule(w http.ResponseWriter, r *http.Request) {
var v model.StoppedRule
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.CreateStoppedRule(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getStoppedRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetStoppedRule(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteStoppedRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteStoppedRule(r.Context(), id))
}
func (s *Server) listProxyARP(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListProxyARP(r.Context())
respondList(w, list, err)
}
func (s *Server) createProxyARP(w http.ResponseWriter, r *http.Request) {
s.createProxy(w, r, s.store.CreateProxyARP)
}
func (s *Server) getProxyARP(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetProxyARP(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteProxyARP(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteProxyARP(r.Context(), id))
}
func (s *Server) listProxyNDP(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListProxyNDP(r.Context())
respondList(w, list, err)
}
func (s *Server) createProxyNDP(w http.ResponseWriter, r *http.Request) {
s.createProxy(w, r, s.store.CreateProxyNDP)
}
func (s *Server) getProxyNDP(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetProxyNDP(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteProxyNDP(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteProxyNDP(r.Context(), id))
}
// createProxy is shared by proxy_arp/ndp (identical shape).
func (s *Server) createProxy(w http.ResponseWriter, r *http.Request, create func(context.Context, model.ProxyEntry) (int64, error)) {
var v model.ProxyEntry
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Address == "" || v.External == "" {
writeError(w, http.StatusBadRequest, "device, address, and external are required")
return
}
id, err := create(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) listArpRules(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListArpRules(r.Context())
respondList(w, list, err)
}
func (s *Server) createArpRule(w http.ResponseWriter, r *http.Request) {
var v model.ArpRule
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.CreateArpRule(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getArpRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetArpRule(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteArpRule(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteArpRule(r.Context(), id))
}
func (s *Server) listMaclist(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListMaclist(r.Context())
respondList(w, list, err)
}
func (s *Server) createMaclist(w http.ResponseWriter, r *http.Request) {
var v model.MaclistEntry
if !decode(w, r, &v) {
return
}
if v.Device == "" || v.Action == "" || v.Interface == "" {
writeError(w, http.StatusBadRequest, "device, action, and interface are required")
return
}
id, err := s.store.CreateMaclist(r.Context(), v)
if err == nil {
v.ID = id
}
respondCreated(w, v, err)
}
func (s *Server) getMaclist(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
v, err := s.store.GetMaclist(r.Context(), id)
respondOne(w, v, err)
}
func (s *Server) deleteMaclist(w http.ResponseWriter, r *http.Request) {
id, ok := idParam(w, r)
if !ok {
return
}
respondDelete(w, s.store.DeleteMaclist(r.Context(), id))
}
+33
View File
@@ -59,6 +59,12 @@ func (s *Server) mountResources(r chi.Router) {
r.Get("/{id}", s.getRule)
r.Delete("/{id}", s.deleteRule)
})
s.mountNAT(r)
s.mountLongtail(r)
s.mountPerDevice(r)
s.mountPerDeviceL2(r)
s.mountTraffic(r)
s.mountGlobal2(r)
}
// respondOne writes a single resource, mapping ErrNotFound to 404.
@@ -74,6 +80,15 @@ func respondOne(w http.ResponseWriter, v any, err error) {
writeJSON(w, http.StatusOK, v)
}
// respondCreated writes a 201 with the created resource, or 500 on error.
func respondCreated(w http.ResponseWriter, v any, err error) {
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, v)
}
// respondDelete maps a delete result to 204/404/500.
func respondDelete(w http.ResponseWriter, err error) {
if err != nil {
@@ -349,6 +364,24 @@ func (s *Server) handleDeviceConfig(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
func (s *Server) handleDeviceRoutes(w http.ResponseWriter, r *http.Request) {
var body struct {
Prefixes []string `json:"prefixes"`
}
if !decode(w, r, &body) {
return
}
if err := s.store.UpdateDeviceRoutes(r.Context(), chi.URLParam(r, "name"), body.Prefixes); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeviceStatus(w http.ResponseWriter, r *http.Request) {
var body struct {
Generation int64 `json:"generation"`
+1
View File
@@ -94,6 +94,7 @@ func (s *Server) routes() http.Handler {
r.Use(s.requireToken(s.agentToken))
r.Get("/devices/{name}/config", s.handleDeviceConfig)
r.Post("/devices/{name}/status", s.handleDeviceStatus)
r.Post("/devices/{name}/routes", s.handleDeviceRoutes)
})
})
+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)
}
+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)
}
+160
View File
@@ -0,0 +1,160 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- SNAT / masquerade -----------------------------------------------------
func (s *Store) ListSNAT(ctx context.Context) ([]model.SNATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SNATRule
for rows.Next() {
var r model.SNATRule
if err := rows.Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetSNAT(ctx context.Context, id int64) (model.SNATRule, error) {
var r model.SNATRule
err := s.pool.QueryRow(ctx,
`SELECT id, action, source, egress, address, probability, comment FROM snat WHERE id = $1`, id,
).Scan(&r.ID, &r.Action, &r.Source, &r.Egress, &r.Address, &r.Probability, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateSNAT(ctx context.Context, r model.SNATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO snat (action, source, egress, address, probability, comment)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
r.Action, r.Source, r.Egress, r.Address, r.Probability, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteSNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM snat WHERE id = $1`, id)
}
// ---- Netmap ----------------------------------------------------------------
func (s *Store) ListNetmap(ctx context.Context) ([]model.NetmapRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NetmapRule
for rows.Next() {
var r model.NetmapRule
if err := rows.Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNetmap(ctx context.Context, id int64) (model.NetmapRule, error) {
var r model.NetmapRule
err := s.pool.QueryRow(ctx,
`SELECT id, type, from_net, to_net, anchor, comment FROM netmap WHERE id = $1`, id,
).Scan(&r.ID, &r.Type, &r.FromNet, &r.ToNet, &r.Anchor, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNetmap(ctx context.Context, r model.NetmapRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO netmap (type, from_net, to_net, anchor, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Type, r.FromNet, r.ToNet, r.Anchor, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNetmap(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM netmap WHERE id = $1`, id)
}
// ---- 1:1 static NAT --------------------------------------------------------
func (s *Store) ListNAT(ctx context.Context) ([]model.NATRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.NATRule
for rows.Next() {
var r model.NATRule
if err := rows.Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetNAT(ctx context.Context, id int64) (model.NATRule, error) {
var r model.NATRule
err := s.pool.QueryRow(ctx,
`SELECT id, device, external, internal, interface, comment FROM nat WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.External, &r.Internal, &r.Interface, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateNAT(ctx context.Context, r model.NATRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO nat (device, external, internal, interface, comment)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
r.Device, r.External, r.Internal, r.Interface, r.Comment,
).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteNAT(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM nat WHERE id = $1`, id)
}
+234
View File
@@ -0,0 +1,234 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- Hosts -----------------------------------------------------------------
func (s *Store) ListHosts(ctx context.Context) ([]model.Host, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, zone, interface, addresses, exclusions, dynamic FROM hosts ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Host
for rows.Next() {
h, err := scanHost(rows)
if err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func (s *Store) GetHost(ctx context.Context, id int64) (model.Host, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, zone, interface, addresses, exclusions, dynamic FROM hosts WHERE id = $1`, id)
if err != nil {
return model.Host{}, err
}
defer rows.Close()
if !rows.Next() {
return model.Host{}, ErrNotFound
}
return scanHost(rows)
}
func scanHost(rows pgx.Rows) (model.Host, error) {
var h model.Host
var addrs, excl []byte
if err := rows.Scan(&h.ID, &h.Device, &h.Zone, &h.Interface, &addrs, &excl, &h.Dynamic); err != nil {
return h, err
}
if err := unmarshalStrings(addrs, &h.Addresses); err != nil {
return h, err
}
return h, unmarshalStrings(excl, &h.Exclusions)
}
func (s *Store) CreateHost(ctx context.Context, h model.Host) (int64, error) {
addrs, _ := jsonb(h.Addresses)
excl, _ := jsonb(h.Exclusions)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO hosts (device, zone, interface, addresses, exclusions, dynamic)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
h.Device, h.Zone, h.Interface, addrs, excl, h.Dynamic).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteHost(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM hosts WHERE id = $1`, id)
}
// ---- Providers -------------------------------------------------------------
func (s *Store) ListProviders(ctx context.Context) ([]model.Provider, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, name, number, mark, duplicate, interface, gateway, copy FROM providers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Provider
for rows.Next() {
p, err := scanProvider(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetProvider(ctx context.Context, id int64) (model.Provider, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, name, number, mark, duplicate, interface, gateway, copy FROM providers WHERE id = $1`, id)
if err != nil {
return model.Provider{}, err
}
defer rows.Close()
if !rows.Next() {
return model.Provider{}, ErrNotFound
}
return scanProvider(rows)
}
func scanProvider(rows pgx.Rows) (model.Provider, error) {
var p model.Provider
var cp []byte
if err := rows.Scan(&p.ID, &p.Device, &p.Name, &p.Number, &p.Mark, &p.Duplicate, &p.Interface, &p.Gateway, &cp); err != nil {
return p, err
}
return p, unmarshalStrings(cp, &p.Copy)
}
func (s *Store) CreateProvider(ctx context.Context, p model.Provider) (int64, error) {
cp, _ := jsonb(p.Copy)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO providers (device, name, number, mark, duplicate, interface, gateway, copy)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
p.Device, p.Name, p.Number, p.Mark, p.Duplicate, p.Interface, p.Gateway, cp).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteProvider(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM providers WHERE id = $1`, id)
}
// ---- Routes ----------------------------------------------------------------
func (s *Store) ListRoutes(ctx context.Context) ([]model.Route, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, provider, dest, gateway, oif, persistent, comment FROM routes ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Route
for rows.Next() {
var r model.Route
if err := rows.Scan(&r.ID, &r.Device, &r.Provider, &r.Dest, &r.Gateway, &r.Oif, &r.Persistent, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetRoute(ctx context.Context, id int64) (model.Route, error) {
var r model.Route
err := s.pool.QueryRow(ctx,
`SELECT id, device, provider, dest, gateway, oif, persistent, comment FROM routes WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.Provider, &r.Dest, &r.Gateway, &r.Oif, &r.Persistent, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateRoute(ctx context.Context, r model.Route) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO routes (device, provider, dest, gateway, oif, persistent, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
r.Device, r.Provider, r.Dest, r.Gateway, r.Oif, r.Persistent, r.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteRoute(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM routes WHERE id = $1`, id)
}
// ---- Routing rules ---------------------------------------------------------
func (s *Store) ListRoutingRules(ctx context.Context) ([]model.RoutingRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, source, dest, provider, priority, persistent, mark, comment FROM routing_rules ORDER BY priority, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.RoutingRule
for rows.Next() {
var r model.RoutingRule
if err := rows.Scan(&r.ID, &r.Device, &r.Source, &r.Dest, &r.Provider, &r.Priority, &r.Persistent, &r.Mark, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetRoutingRule(ctx context.Context, id int64) (model.RoutingRule, error) {
var r model.RoutingRule
err := s.pool.QueryRow(ctx,
`SELECT id, device, source, dest, provider, priority, persistent, mark, comment FROM routing_rules WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.Source, &r.Dest, &r.Provider, &r.Priority, &r.Persistent, &r.Mark, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateRoutingRule(ctx context.Context, r model.RoutingRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO routing_rules (device, source, dest, provider, priority, persistent, mark, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
r.Device, r.Source, r.Dest, r.Provider, r.Priority, r.Persistent, r.Mark, r.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteRoutingRule(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM routing_rules WHERE id = $1`, id)
}
+320
View File
@@ -0,0 +1,320 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/tomswallapi/internal/model"
)
// ---- Tunnels ---------------------------------------------------------------
func (s *Store) ListTunnels(ctx context.Context) ([]model.Tunnel, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, type, zone, gateways, gateway_zones, port, comment FROM tunnels ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Tunnel
for rows.Next() {
t, err := scanTunnel(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) GetTunnel(ctx context.Context, id int64) (model.Tunnel, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, type, zone, gateways, gateway_zones, port, comment FROM tunnels WHERE id = $1`, id)
if err != nil {
return model.Tunnel{}, err
}
defer rows.Close()
if !rows.Next() {
return model.Tunnel{}, ErrNotFound
}
return scanTunnel(rows)
}
func scanTunnel(rows pgx.Rows) (model.Tunnel, error) {
var t model.Tunnel
var gw, gz []byte
if err := rows.Scan(&t.ID, &t.Device, &t.Type, &t.Zone, &gw, &gz, &t.Port, &t.Comment); err != nil {
return t, err
}
if err := unmarshalStrings(gw, &t.Gateways); err != nil {
return t, err
}
return t, unmarshalStrings(gz, &t.GatewayZones)
}
func (s *Store) CreateTunnel(ctx context.Context, t model.Tunnel) (int64, error) {
gw, _ := jsonb(t.Gateways)
gz, _ := jsonb(t.GatewayZones)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO tunnels (device, type, zone, gateways, gateway_zones, port, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
t.Device, t.Type, t.Zone, gw, gz, t.Port, t.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteTunnel(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM tunnels WHERE id = $1`, id)
}
// ---- Stopped rules ---------------------------------------------------------
func (s *Store) ListStoppedRules(ctx context.Context) ([]model.StoppedRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, source, dest, proto, dport, sport, comment FROM stopped_rules ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.StoppedRule
for rows.Next() {
r, err := scanStopped(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetStoppedRule(ctx context.Context, id int64) (model.StoppedRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, source, dest, proto, dport, sport, comment FROM stopped_rules WHERE id = $1`, id)
if err != nil {
return model.StoppedRule{}, err
}
defer rows.Close()
if !rows.Next() {
return model.StoppedRule{}, ErrNotFound
}
return scanStopped(rows)
}
func scanStopped(rows pgx.Rows) (model.StoppedRule, error) {
var r model.StoppedRule
var dport, sport []byte
if err := rows.Scan(&r.ID, &r.Device, &r.Action, &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) CreateStoppedRule(ctx context.Context, r model.StoppedRule) (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 stopped_rules (device, action, source, dest, proto, dport, sport, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
r.Device, r.Action, 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) DeleteStoppedRule(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM stopped_rules WHERE id = $1`, id)
}
// ---- Proxy ARP / NDP (identical shape, table-parameterized) ----------------
func (s *Store) listProxy(ctx context.Context, table string) ([]model.ProxyEntry, error) {
q := fmt.Sprintf(`SELECT id, device, address, interface, external, haveroute, persistent, comment FROM %s ORDER BY id`, table)
rows, err := s.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.ProxyEntry
for rows.Next() {
var p model.ProxyEntry
if err := rows.Scan(&p.ID, &p.Device, &p.Address, &p.Interface, &p.External, &p.HaveRoute, &p.Persistent, &p.Comment); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) getProxy(ctx context.Context, table string, id int64) (model.ProxyEntry, error) {
q := fmt.Sprintf(`SELECT id, device, address, interface, external, haveroute, persistent, comment FROM %s WHERE id = $1`, table)
var p model.ProxyEntry
err := s.pool.QueryRow(ctx, q, id).Scan(&p.ID, &p.Device, &p.Address, &p.Interface, &p.External, &p.HaveRoute, &p.Persistent, &p.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrNotFound
}
return p, err
}
func (s *Store) createProxy(ctx context.Context, table string, p model.ProxyEntry) (int64, error) {
q := fmt.Sprintf(`INSERT INTO %s (device, address, interface, external, haveroute, persistent, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, table)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, q, p.Device, p.Address, p.Interface, p.External, p.HaveRoute, p.Persistent, p.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) ListProxyARP(ctx context.Context) ([]model.ProxyEntry, error) {
return s.listProxy(ctx, "proxy_arp")
}
func (s *Store) GetProxyARP(ctx context.Context, id int64) (model.ProxyEntry, error) {
return s.getProxy(ctx, "proxy_arp", id)
}
func (s *Store) CreateProxyARP(ctx context.Context, p model.ProxyEntry) (int64, error) {
return s.createProxy(ctx, "proxy_arp", p)
}
func (s *Store) DeleteProxyARP(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM proxy_arp WHERE id = $1`, id)
}
func (s *Store) ListProxyNDP(ctx context.Context) ([]model.ProxyEntry, error) {
return s.listProxy(ctx, "proxy_ndp")
}
func (s *Store) GetProxyNDP(ctx context.Context, id int64) (model.ProxyEntry, error) {
return s.getProxy(ctx, "proxy_ndp", id)
}
func (s *Store) CreateProxyNDP(ctx context.Context, p model.ProxyEntry) (int64, error) {
return s.createProxy(ctx, "proxy_ndp", p)
}
func (s *Store) DeleteProxyNDP(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM proxy_ndp WHERE id = $1`, id)
}
// ---- ARP rules -------------------------------------------------------------
func (s *Store) ListArpRules(ctx context.Context) ([]model.ArpRule, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, action_address, action_mac, source, dest, opcode, comment FROM arp_rules ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.ArpRule
for rows.Next() {
var r model.ArpRule
if err := rows.Scan(&r.ID, &r.Device, &r.Action, &r.ActionAddress, &r.ActionMAC, &r.Source, &r.Dest, &r.Opcode, &r.Comment); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetArpRule(ctx context.Context, id int64) (model.ArpRule, error) {
var r model.ArpRule
err := s.pool.QueryRow(ctx,
`SELECT id, device, action, action_address, action_mac, source, dest, opcode, comment FROM arp_rules WHERE id = $1`, id,
).Scan(&r.ID, &r.Device, &r.Action, &r.ActionAddress, &r.ActionMAC, &r.Source, &r.Dest, &r.Opcode, &r.Comment)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
func (s *Store) CreateArpRule(ctx context.Context, r model.ArpRule) (int64, error) {
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO arp_rules (device, action, action_address, action_mac, source, dest, opcode, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
r.Device, r.Action, r.ActionAddress, r.ActionMAC, r.Source, r.Dest, r.Opcode, r.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteArpRule(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM arp_rules WHERE id = $1`, id)
}
// ---- Maclist ---------------------------------------------------------------
func (s *Store) ListMaclist(ctx context.Context) ([]model.MaclistEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, interface, mac, addresses, log, comment FROM maclist ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.MaclistEntry
for rows.Next() {
m, err := scanMaclist(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) GetMaclist(ctx context.Context, id int64) (model.MaclistEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, device, action, interface, mac, addresses, log, comment FROM maclist WHERE id = $1`, id)
if err != nil {
return model.MaclistEntry{}, err
}
defer rows.Close()
if !rows.Next() {
return model.MaclistEntry{}, ErrNotFound
}
return scanMaclist(rows)
}
func scanMaclist(rows pgx.Rows) (model.MaclistEntry, error) {
var m model.MaclistEntry
var addrs []byte
if err := rows.Scan(&m.ID, &m.Device, &m.Action, &m.Interface, &m.MAC, &addrs, &m.Log, &m.Comment); err != nil {
return m, err
}
return m, unmarshalStrings(addrs, &m.Addresses)
}
func (s *Store) CreateMaclist(ctx context.Context, m model.MaclistEntry) (int64, error) {
addrs, _ := jsonb(m.Addresses)
var id int64
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO maclist (device, action, interface, mac, addresses, log, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
m.Device, m.Action, m.Interface, m.MAC, addrs, m.Log, m.Comment).Scan(&id); err != nil {
return err
}
return bump(ctx, tx)
})
return id, err
}
func (s *Store) DeleteMaclist(ctx context.Context, id int64) error {
return s.deleteOne(ctx, `DELETE FROM maclist WHERE id = $1`, id)
}
+31 -3
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) {
@@ -290,10 +295,11 @@ 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 []byte
var resolver, settings, reachable []byte
err := s.pool.QueryRow(ctx,
`SELECT name, class, COALESCE(fabric, ''), resolver, settings FROM devices WHERE name = $1`, name,
).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings)
`SELECT name, class, COALESCE(fabric, ''), resolver, settings, reachable_prefixes
FROM devices WHERE name = $1`, name,
).Scan(&d.Name, &d.Class, &d.Fabric, &resolver, &settings, &reachable)
if errors.Is(err, pgx.ErrNoRows) {
return d, ErrNotFound
}
@@ -303,9 +309,31 @@ func (s *Store) GetDevice(ctx context.Context, name string) (model.Device, error
if err := json.Unmarshal(resolver, &d.Resolver); err != nil {
return d, err
}
if err := json.Unmarshal(reachable, &d.ReachablePrefixes); err != nil {
return d, err
}
return d, json.Unmarshal(settings, &d.Settings)
}
// UpdateDeviceRoutes stores the reachable prefixes an agent reports from its FIB.
// This is scoping data, not config — it does not bump the config generation.
func (s *Store) UpdateDeviceRoutes(ctx context.Context, name string, prefixes []string) error {
reachable, err := jsonb(prefixes)
if err != nil {
return err
}
tag, err := s.pool.Exec(ctx,
`UPDATE devices SET reachable_prefixes = $2, routes_reported_at = now() WHERE name = $1`,
name, reachable)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ---- Settings, portgroups, policies ----------------------------------------
func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) {
+151
View File
@@ -0,0 +1,151 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"git.unkin.net/unkin/tomswallapi/internal/database"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// newTestStore spins up a throwaway Postgres, applies migrations, and returns a
// Store. It skips (rather than fails) when Docker is unavailable, so the default
// `go test -short` run in CI passes without a container runtime.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
if testing.Short() {
t.Skip("skipping container-backed test in -short mode")
}
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("tomswallapi"),
postgres.WithUsername("tomswallapi"),
postgres.WithPassword("tomswallapi"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).WithStartupTimeout(60*time.Second)),
)
if err != nil {
t.Skipf("skipping: cannot start postgres container (Docker unavailable?): %v", err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
db, err := database.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(db.Close)
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
return store.New(db.Pool)
}
func TestZoneLifecycleAndGeneration(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
gen0, err := s.Generation(ctx)
if err != nil {
t.Fatalf("generation: %v", err)
}
if err := s.UpsertZone(ctx, model.Zone{Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}}); err != nil {
t.Fatalf("upsert zone: %v", err)
}
gen1, _ := s.Generation(ctx)
if gen1 <= gen0 {
t.Errorf("generation should bump on write: %d -> %d", gen0, gen1)
}
got, err := s.GetZone(ctx, "zone-a")
if err != nil {
t.Fatalf("get zone: %v", err)
}
if len(got.Subnets) != 1 || got.Subnets[0] != "10.1.0.0/24" {
t.Errorf("subnets round-trip failed: %+v", got)
}
zones, err := s.ListZones(ctx)
if err != nil || len(zones) != 1 {
t.Fatalf("list zones = %d (%v)", len(zones), err)
}
if err := s.DeleteZone(ctx, "zone-a"); err != nil {
t.Fatalf("delete zone: %v", err)
}
if _, err := s.GetZone(ctx, "zone-a"); err != store.ErrNotFound {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
gen2, _ := s.Generation(ctx)
if gen2 <= gen1 {
t.Errorf("generation should bump on delete: %d -> %d", gen1, gen2)
}
}
func TestRuleGrammarValidationAtStore(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// Valid rule with a paired selector.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"loc"}, Dest: []string{"net:+cloudflare"},
}); err != nil {
t.Fatalf("valid rule rejected: %v", err)
}
// Bare selector must be rejected before it reaches the DB.
if _, err := s.CreateRule(ctx, model.Rule{
Action: "accept", Source: []string{"+cloudflare"}, Dest: []string{"loc"},
}); err == nil {
t.Fatal("bare selector rule should be rejected")
}
}
func TestNATTierRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// nat references a device via FK.
if err := s.UpsertDevice(ctx, model.Device{Name: "fw-a", Class: model.ClassFirewall}); err != nil {
t.Fatalf("upsert device: %v", err)
}
sid, err := s.CreateSNAT(ctx, model.SNATRule{Action: "masquerade", Source: "loc", Egress: "net"})
if err != nil {
t.Fatalf("create snat: %v", err)
}
if got, err := s.GetSNAT(ctx, sid); err != nil || got.Action != "masquerade" {
t.Errorf("snat round-trip: %+v (%v)", got, err)
}
nid, err := s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10"})
if err != nil {
t.Fatalf("create nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != nil {
t.Fatalf("delete nat: %v", err)
}
if err := s.DeleteNAT(ctx, nid); err != store.ErrNotFound {
t.Errorf("second delete should be ErrNotFound, got %v", err)
}
// Deleting the device cascades to its nat rows.
_, _ = s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.11", Internal: "10.1.0.11"})
if err := s.DeleteDevice(ctx, "fw-a"); err != nil {
t.Fatalf("delete device: %v", err)
}
nats, _ := s.ListNAT(ctx)
if len(nats) != 0 {
t.Errorf("expected nat rows to cascade-delete with device, got %d", len(nats))
}
}
+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)
}