From b307a7b1f74231d21ab63c4987ca57e8a2f9110c Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 8 May 2026 23:44:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(db):=20Phase=201=20=E2=80=94=20DB-Schema,?= =?UTF-8?q?=20goose-Migrations,=20GORM-Models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialer Schema-Set (8 Migrationen, 13 Tabellen) für EdgeGuard v1: users + audit_log + system_settings, ha_nodes, backends/domains/ routing_rules/tls_certs, forward_proxy_acls, wireguard_peers, firewall_rules, dns_zones/dns_records, licenses. Migrations liegen in internal/database/migrations/ (analog mail-gateway) und werden per //go:embed ins Binary gepackt — keine separate SQL-Dateien im .deb. ValidateMigrations + Test schützen vor Duplicate-Versionen (mail-gateway 2026-05-08-Vorfall). GORM-Models für alle Tabellen, sensible Felder (password_hash, private_key_enc) sind json:"-". Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 +- docs/architecture.md | 5 +- go.mod | 27 ++- go.sum | 58 ++++-- internal/database/db.go | 188 ++++++++++++++++++ internal/database/migrations/0001_init.sql | 56 ++++++ internal/database/migrations/0002_cluster.sql | 35 ++++ .../migrations/0003_http_frontend.sql | 88 ++++++++ .../migrations/0004_forward_proxy.sql | 32 +++ .../database/migrations/0005_wireguard.sql | 41 ++++ .../database/migrations/0006_firewall.sql | 35 ++++ internal/database/migrations/0007_dns.sql | 47 +++++ .../database/migrations/0008_licenses.sql | 38 ++++ internal/database/migrations_unique_test.go | 19 ++ internal/models/audit.go | 18 ++ internal/models/backend.go | 17 ++ internal/models/dns.go | 30 +++ internal/models/doc.go | 9 + internal/models/domain.go | 17 ++ internal/models/firewall_rule.go | 17 ++ internal/models/forward_proxy_acl.go | 18 ++ internal/models/ha_node.go | 19 ++ internal/models/license.go | 22 ++ internal/models/routing_rule.go | 16 ++ internal/models/system_setting.go | 11 + internal/models/tls_cert.go | 20 ++ internal/models/user.go | 16 ++ internal/models/wireguard_peer.go | 23 +++ migrations/.gitkeep | 0 29 files changed, 900 insertions(+), 27 deletions(-) create mode 100644 internal/database/db.go create mode 100644 internal/database/migrations/0001_init.sql create mode 100644 internal/database/migrations/0002_cluster.sql create mode 100644 internal/database/migrations/0003_http_frontend.sql create mode 100644 internal/database/migrations/0004_forward_proxy.sql create mode 100644 internal/database/migrations/0005_wireguard.sql create mode 100644 internal/database/migrations/0006_firewall.sql create mode 100644 internal/database/migrations/0007_dns.sql create mode 100644 internal/database/migrations/0008_licenses.sql create mode 100644 internal/database/migrations_unique_test.go create mode 100644 internal/models/audit.go create mode 100644 internal/models/backend.go create mode 100644 internal/models/dns.go create mode 100644 internal/models/doc.go create mode 100644 internal/models/domain.go create mode 100644 internal/models/firewall_rule.go create mode 100644 internal/models/forward_proxy_acl.go create mode 100644 internal/models/ha_node.go create mode 100644 internal/models/license.go create mode 100644 internal/models/routing_rule.go create mode 100644 internal/models/system_setting.go create mode 100644 internal/models/tls_cert.go create mode 100644 internal/models/user.go create mode 100644 internal/models/wireguard_peer.go delete mode 100644 migrations/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index d44fa54..2edf767 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,8 @@ cd management-ui && bun run dev │ ├── edgeguard-scheduler/ # Cron-Jobs │ └── edgeguard-ctl/ # CLI ├── internal/ +│ ├── database/ # pgxpool + goose-Runner; migrations/ via go:embed +│ │ └── migrations/ # SQL (goose-Format) — embedded ins Binary │ ├── models/ # GORM-Models │ ├── handlers/ # HTTP-Handler (REST) │ ├── services/ # Business-Logik @@ -146,7 +148,6 @@ cd management-ui && bun run dev │ ├── aggregator/ # Cluster-View APIs │ └── license/ # Lizenz-Validierung ├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern) -├── migrations/ # SQL (goose-Format) ├── packaging/debian/ # control, postinst, postrm, systemd-Units ├── deploy/ │ ├── systemd/ # *.service, *.target, *.timer @@ -171,7 +172,7 @@ cd management-ui && bun run dev ## Key Conventions ### Go-Code -- **Migrations:** goose SQL-Dateien in `migrations/` — NICHT GORM AutoMigrate +- **Migrations:** goose SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary — NICHT GORM AutoMigrate - **ORM:** GORM für Queries, nicht für Schema-Verwaltung - **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/` - **Config-Reload:** `systemctl reload ` nach Config-Schreiben diff --git a/docs/architecture.md b/docs/architecture.md index ac5bde7..39db9cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,8 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox │ ├── edgeguard-scheduler/ # Cron-artige Jobs │ └── edgeguard-ctl/ # CLI für Setup/Wartung ├── internal/ +│ ├── database/ # pgxpool + goose-Runner, migrations/ via go:embed +│ │ └── migrations/ # 0001_*.sql … (goose-Format, embedded) │ ├── models/ # GORM-Models (domain, backend, routing_rule, acl, peer, …) │ ├── handlers/ # HTTP-Handler (REST) │ ├── services/ # Business-Logik (config-render, health-check, cluster-sync) @@ -82,7 +84,6 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox │ ├── apt-repo/ # build-package.sh, publish.sh, setup-repo.sh │ ├── install.sh # Bootstrap-Onliner │ └── release.sh # CI Release-Helper -├── migrations/ # SQL-Migrations (goose-Format) ├── docs/ ├── Makefile ├── go.mod # module git.netcell-it.de/projekte/edgeguard-native @@ -197,7 +198,7 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). nginx terminiert TLS auf `: - **PostgreSQL 16**, Distro-Paket `postgresql-16`. - **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication. - **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`). -- **Migrations:** `goose` (SQL-Dateien in `migrations/`). **Nicht** GORM AutoMigrate. +- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate. GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`. diff --git a/go.mod b/go.mod index 37d9242..c6d25e3 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ module git.netcell-it.de/projekte/edgeguard-native -go 1.25.0 +go 1.25.7 -require github.com/gin-gonic/gin v1.10.0 +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/pressly/goose/v3 v3.27.1 +) require ( github.com/bytedance/sonic v1.11.6 // indirect @@ -15,23 +19,28 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 28ad161..f0d848b 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -29,35 +31,52 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -75,19 +94,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -96,5 +118,13 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV 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= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..d5c1262 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,188 @@ +// Package database owns the PostgreSQL connection pool and migration +// runner for edgeguard-api. +// +// All routes read and write through a *pgxpool.Pool (jackc/pgx v5). +// GORM (in internal/models) is used for struct tags / query convenience +// against the same DSN; the schema itself is owned by goose +// (// +goose Up/Down files in migrations/). +package database + +import ( + "context" + "embed" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" + + _ "github.com/jackc/pgx/v5/stdlib" // register "pgx" driver for goose +) + +//go:embed migrations/*.sql +var embeddedMigrations embed.FS + +// ConnStringFromEnv returns the DSN for edgeguard-api. Priority: +// +// 1. EDGEGUARD_DB_URL as a full libpq/pgx URL or key=value DSN +// 2. /etc/edgeguard/api.env line "EDGEGUARD_DB_URL=..." +// 3. default: unix-socket peer auth against db "edgeguard" +// +// The default matches the layout edgeguard-ctl initdb sets up +// (see docs/architecture.md §6). +func ConnStringFromEnv() string { + if v := os.Getenv("EDGEGUARD_DB_URL"); v != "" { + return v + } + if data, err := os.ReadFile("/etc/edgeguard/api.env"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "EDGEGUARD_DB_URL=") { + return strings.TrimSpace(strings.TrimPrefix(line, "EDGEGUARD_DB_URL=")) + } + } + } + return "host=/var/run/postgresql dbname=edgeguard sslmode=disable" +} + +// Open constructs a pool and pings the server. Never returns a pool +// that failed its initial ping. +func Open(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + if dsn == "" { + return nil, errors.New("empty DSN") + } + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse DSN: %w", err) + } + if cfg.MaxConns == 0 { + cfg.MaxConns = 10 + } + cfg.MaxConnLifetime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("open pool: %w", err) + } + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} + +// Migrate runs goose `up` with the embedded migrations. Pass an empty +// dsnOverride to use ConnStringFromEnv(). +// +// Pre-flight ValidateMigrations runs first so a duplicate version +// number fails with a clear message instead of goose's panic deep in +// migrate.go (mail-gateway 2026-05-08 incident). +func Migrate(ctx context.Context, dsnOverride string) error { + if err := ValidateMigrations(); err != nil { + return err + } + dsn := dsnOverride + if dsn == "" { + dsn = ConnStringFromEnv() + } + db, err := goose.OpenDBWithDriver("pgx", dsn) + if err != nil { + return fmt.Errorf("open db for migrate: %w", err) + } + defer db.Close() + + goose.SetBaseFS(embeddedMigrations) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.RunContext(ctx, "up", db, "migrations") +} + +// MigrateDown rolls back the most recent migration. Used in tests and +// development; production is forward-only. +func MigrateDown(ctx context.Context, dsnOverride string) error { + dsn := dsnOverride + if dsn == "" { + dsn = ConnStringFromEnv() + } + db, err := goose.OpenDBWithDriver("pgx", dsn) + if err != nil { + return err + } + defer db.Close() + goose.SetBaseFS(embeddedMigrations) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.RunContext(ctx, "down", db, "migrations") +} + +// ValidateMigrations checks the embedded migration files for duplicate +// version prefixes. Called from Migrate() (runtime), from +// `edgeguard-ctl migrate check` (postinst pre-flight), and from +// TestEmbeddedMigrationsUnique (CI gate). +func ValidateMigrations() error { + entries, err := embeddedMigrations.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + versionToFile := map[int64]string{} + versionRe := regexp.MustCompile(`^(\d+)_[^/]+\.sql$`) + var problems []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + m := versionRe.FindStringSubmatch(e.Name()) + if m == nil { + problems = append(problems, fmt.Sprintf("%s: filename does not match _.sql", e.Name())) + continue + } + v, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + problems = append(problems, fmt.Sprintf("%s: version is not an integer", e.Name())) + continue + } + if existing, dup := versionToFile[v]; dup { + problems = append(problems, fmt.Sprintf( + "duplicate migration version %d: %s and %s", + v, existing, e.Name())) + continue + } + versionToFile[v] = e.Name() + } + if len(problems) > 0 { + return fmt.Errorf("migration validation failed:\n %s", strings.Join(problems, "\n ")) + } + return nil +} + +// CopyEmbeddedMigrationsTo writes the embedded SQL files to dst. Used +// by edgeguard-ctl to dump the embedded set for manual inspection. +func CopyEmbeddedMigrationsTo(dst string) error { + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + entries, err := embeddedMigrations.ReadDir("migrations") + if err != nil { + return err + } + for _, e := range entries { + data, err := embeddedMigrations.ReadFile(filepath.ToSlash(filepath.Join("migrations", e.Name()))) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o644); err != nil { + return err + } + } + return nil +} diff --git a/internal/database/migrations/0001_init.sql b/internal/database/migrations/0001_init.sql new file mode 100644 index 0000000..90909ab --- /dev/null +++ b/internal/database/migrations/0001_init.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- +goose StatementBegin + +-- Admin users (analog enconf admin_users). Auth is JWT-based; password +-- is bcrypt'd via golang.org/x/crypto/bcrypt. role is a free-form +-- text column today (only "admin" exists in v1) so future RBAC roles +-- can be added without a CHECK-constraint migration. +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT users_email_unique UNIQUE (email) +); + +CREATE INDEX IF NOT EXISTS idx_users_active ON users (active) WHERE active; + +-- Append-only audit trail. Every config change, login, license event, +-- upgrade trigger gets an entry. detail is freeform JSONB so each +-- handler can attach whatever context is meaningful (e.g. diff of the +-- domain row, apt versions before/after, cluster-join token hash). +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + actor TEXT NOT NULL, + action TEXT NOT NULL, + subject TEXT, + detail JSONB, + node_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_subject ON audit_log (subject); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action); + +-- Global key/value config. Avoids one-row tables for things like +-- cluster FQDN, ACME contact e-mail, default WireGuard listen port. +-- value is TEXT — JSON encoding is on the caller if needed. +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS system_settings; +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/internal/database/migrations/0002_cluster.sql b/internal/database/migrations/0002_cluster.sql new file mode 100644 index 0000000..af05b34 --- /dev/null +++ b/internal/database/migrations/0002_cluster.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- +goose StatementBegin + +-- Cluster peers. Populated by edgeguard-ctl cluster-join. Used by: +-- * internal/proxy → resolve current PG primary URL (also via KeyDB) +-- * internal/aggregator → fan-out reads to peer APIs +-- * internal/unbound → generate eg.cluster local-zone records +-- * internal/firewall → open peer-internal ports per IP +-- +-- id is a stable UUID (not BIGSERIAL) so a node keeps its identity +-- across re-joins / re-images. fqdn is the externally addressable +-- name; api_url is its 9443 endpoint via mTLS. +CREATE TABLE IF NOT EXISTS ha_nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fqdn TEXT NOT NULL, + api_url TEXT NOT NULL, + public_ip INET, + internal_ip INET, + role TEXT NOT NULL DEFAULT 'peer', + last_seen TIMESTAMPTZ, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ha_nodes_fqdn_unique UNIQUE (fqdn) +); + +CREATE INDEX IF NOT EXISTS idx_ha_nodes_last_seen ON ha_nodes (last_seen DESC); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS ha_nodes; +-- +goose StatementEnd diff --git a/internal/database/migrations/0003_http_frontend.sql b/internal/database/migrations/0003_http_frontend.sql new file mode 100644 index 0000000..ffdd2b7 --- /dev/null +++ b/internal/database/migrations/0003_http_frontend.sql @@ -0,0 +1,88 @@ +-- +goose Up +-- +goose StatementBegin + +-- Upstream backends (target servers behind the reverse-proxy). +CREATE TABLE IF NOT EXISTS backends ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + scheme TEXT NOT NULL DEFAULT 'http', + address TEXT NOT NULL, + port INTEGER NOT NULL, + health_check_path TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT backends_name_unique UNIQUE (name), + CONSTRAINT backends_scheme_check CHECK (scheme IN ('http', 'https')), + CONSTRAINT backends_port_check CHECK (port > 0 AND port < 65536) +); + +CREATE INDEX IF NOT EXISTS idx_backends_active ON backends (active) WHERE active; + +-- Public-facing domains. primary_backend_id is the default upstream +-- when no path-rule matches. +CREATE TABLE IF NOT EXISTS domains ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + primary_backend_id BIGINT REFERENCES backends(id) ON DELETE SET NULL, + http_to_https BOOLEAN NOT NULL DEFAULT TRUE, + hsts_enabled BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT domains_name_unique UNIQUE (name) +); + +CREATE INDEX IF NOT EXISTS idx_domains_active ON domains (active) WHERE active; + +-- Path-based routing rules. Higher priority wins; ties broken by id. +CREATE TABLE IF NOT EXISTS routing_rules ( + id BIGSERIAL PRIMARY KEY, + domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + path_prefix TEXT NOT NULL DEFAULT '/', + backend_id BIGINT NOT NULL REFERENCES backends(id) ON DELETE RESTRICT, + priority INTEGER NOT NULL DEFAULT 100, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_routing_rules_domain ON routing_rules (domain_id); +CREATE INDEX IF NOT EXISTS idx_routing_rules_priority ON routing_rules (domain_id, priority DESC); + +-- ACME-managed TLS certificates. status mirrors certbot's view: +-- pending — issue requested, not yet completed +-- active — issued, currently deployed +-- renewing — renewal in progress +-- expired — past not_after, awaiting cleanup +-- error — last attempt failed (see audit_log for detail) +CREATE TABLE IF NOT EXISTS tls_certs ( + id BIGSERIAL PRIMARY KEY, + domain TEXT NOT NULL, + issuer TEXT NOT NULL DEFAULT 'letsencrypt', + status TEXT NOT NULL DEFAULT 'pending', + cert_path TEXT, + key_path TEXT, + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + last_renewed_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT tls_certs_domain_unique UNIQUE (domain), + CONSTRAINT tls_certs_status_check CHECK (status IN ('pending', 'active', 'renewing', 'expired', 'error')) +); + +CREATE INDEX IF NOT EXISTS idx_tls_certs_not_after ON tls_certs (not_after); +CREATE INDEX IF NOT EXISTS idx_tls_certs_status ON tls_certs (status); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tls_certs; +DROP TABLE IF EXISTS routing_rules; +DROP TABLE IF EXISTS domains; +DROP TABLE IF EXISTS backends; +-- +goose StatementEnd diff --git a/internal/database/migrations/0004_forward_proxy.sql b/internal/database/migrations/0004_forward_proxy.sql new file mode 100644 index 0000000..5671510 --- /dev/null +++ b/internal/database/migrations/0004_forward_proxy.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- +goose StatementBegin + +-- Squid forward-proxy ACL entries. Generator merges all active rows +-- into /etc/edgeguard/squid/squid.conf via internal/squid templates. +-- +-- acl_type matches squid's vocabulary: src, dst, dstdomain, port, +-- proto, time, url_regex, urlpath_regex, ... +-- action is allow|deny. +CREATE TABLE IF NOT EXISTS forward_proxy_acls ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + acl_type TEXT NOT NULL, + value TEXT NOT NULL, + action TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 100, + active BOOLEAN NOT NULL DEFAULT TRUE, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT forward_proxy_acls_action_check CHECK (action IN ('allow', 'deny')) +); + +CREATE INDEX IF NOT EXISTS idx_forward_proxy_acls_priority ON forward_proxy_acls (priority DESC); +CREATE INDEX IF NOT EXISTS idx_forward_proxy_acls_active ON forward_proxy_acls (active) WHERE active; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS forward_proxy_acls; +-- +goose StatementEnd diff --git a/internal/database/migrations/0005_wireguard.sql b/internal/database/migrations/0005_wireguard.sql new file mode 100644 index 0000000..c2cbb18 --- /dev/null +++ b/internal/database/migrations/0005_wireguard.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin + +-- WireGuard peers. v1 uses Option A (shared server identity, see +-- docs/architecture.md §8.1) — the local server peer is row 1, type +-- 'server'; remote site-to-site peers and road-warrior clients are +-- separate rows. +-- +-- private_key_enc holds the encrypted server private key (NULL for +-- non-server rows); decryption key lives in /var/lib/edgeguard/.wg_key +-- (mode 0600). Never logged. +CREATE TABLE IF NOT EXISTS wireguard_peers ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + peer_type TEXT NOT NULL, + public_key TEXT NOT NULL, + private_key_enc TEXT, + preshared_key_enc TEXT, + allowed_ips TEXT NOT NULL, + endpoint TEXT, + listen_port INTEGER, + persistent_keepalive INTEGER, + last_handshake_at TIMESTAMPTZ, + active BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT wireguard_peers_name_unique UNIQUE (name), + CONSTRAINT wireguard_peers_pubkey_unique UNIQUE (public_key), + CONSTRAINT wireguard_peers_type_check CHECK (peer_type IN ('server', 's2s', 'roadwarrior')) +); + +CREATE INDEX IF NOT EXISTS idx_wireguard_peers_active ON wireguard_peers (active) WHERE active; +CREATE INDEX IF NOT EXISTS idx_wireguard_peers_type ON wireguard_peers (peer_type); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS wireguard_peers; +-- +goose StatementEnd diff --git a/internal/database/migrations/0006_firewall.sql b/internal/database/migrations/0006_firewall.sql new file mode 100644 index 0000000..bbce21f --- /dev/null +++ b/internal/database/migrations/0006_firewall.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- +goose StatementBegin + +-- nftables custom rules. Generator merges these into the ruleset +-- emitted by internal/firewall on top of the base inet edgeguard +-- table. v1 has no CrowdSec/threat_intel sets (see architecture.md +-- §8 — those entries entfallen ohne IDS/IPS). +-- +-- chain examples: input, forward, output, prerouting, postrouting. +-- match is the literal nft expression body, e.g. +-- "tcp dport 22 ip saddr 10.0.0.0/8" +-- action: accept | drop | reject | masquerade | dnat | snat +CREATE TABLE IF NOT EXISTS firewall_rules ( + id BIGSERIAL PRIMARY KEY, + chain TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 100, + match_expr TEXT NOT NULL, + action TEXT NOT NULL, + comment TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT firewall_rules_chain_check CHECK (chain IN ('input', 'forward', 'output', 'prerouting', 'postrouting')), + CONSTRAINT firewall_rules_action_check CHECK (action IN ('accept', 'drop', 'reject', 'masquerade', 'dnat', 'snat')) +); + +CREATE INDEX IF NOT EXISTS idx_firewall_rules_chain ON firewall_rules (chain, priority); +CREATE INDEX IF NOT EXISTS idx_firewall_rules_active ON firewall_rules (active) WHERE active; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS firewall_rules; +-- +goose StatementEnd diff --git a/internal/database/migrations/0007_dns.sql b/internal/database/migrations/0007_dns.sql new file mode 100644 index 0000000..fc1a672 --- /dev/null +++ b/internal/database/migrations/0007_dns.sql @@ -0,0 +1,47 @@ +-- +goose Up +-- +goose StatementBegin + +-- Unbound zones. zone_type 'local' = local-data records served +-- authoritatively (e.g. eg.cluster). 'forward' = stub-zone forwarded +-- to a specific upstream. The eg.cluster zone is auto-managed by +-- internal/cluster; user-defined zones land here too. +CREATE TABLE IF NOT EXISTS dns_zones ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + zone_type TEXT NOT NULL DEFAULT 'local', + description TEXT, + managed_by TEXT NOT NULL DEFAULT 'user', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT dns_zones_name_unique UNIQUE (name), + CONSTRAINT dns_zones_type_check CHECK (zone_type IN ('local', 'forward')) +); + +-- DNS records. record_type matches RFC 1035 / RFC 3596 mnemonics +-- (A, AAAA, CNAME, TXT, MX, SRV, ...). value is the record's RDATA in +-- text form (priority + value for MX/SRV are concatenated, e.g. +-- "10 mail.example.com"). +CREATE TABLE IF NOT EXISTS dns_records ( + id BIGSERIAL PRIMARY KEY, + zone_id BIGINT NOT NULL REFERENCES dns_zones(id) ON DELETE CASCADE, + name TEXT NOT NULL, + record_type TEXT NOT NULL, + value TEXT NOT NULL, + ttl INTEGER NOT NULL DEFAULT 300, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT dns_records_type_check CHECK (record_type IN ('A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA')) +); + +CREATE INDEX IF NOT EXISTS idx_dns_records_zone ON dns_records (zone_id); +CREATE INDEX IF NOT EXISTS idx_dns_records_active ON dns_records (active) WHERE active; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS dns_records; +DROP TABLE IF EXISTS dns_zones; +-- +goose StatementEnd diff --git a/internal/database/migrations/0008_licenses.sql b/internal/database/migrations/0008_licenses.sql new file mode 100644 index 0000000..4d03619 --- /dev/null +++ b/internal/database/migrations/0008_licenses.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- +goose StatementBegin + +-- License state cache. The license-leader (KeyDB-locked, see +-- architecture.md §12.1) calls https://license.netcell-it.com and +-- mirrors the result here. Every node reads from this table for its +-- "is the license valid?" check; the KeyDB key cluster:license-status +-- is just the in-flight TTL cache layered on top. +-- +-- payload holds the verbatim verify-response (limits, feature flags, +-- expiry). active_domains_at_verify is the value that was sent to the +-- license server — kept for audit / re-issue (so we can prove what +-- we reported on a given day). +CREATE TABLE IF NOT EXISTS licenses ( + id BIGSERIAL PRIMARY KEY, + license_key TEXT NOT NULL, + status TEXT NOT NULL, + valid_until TIMESTAMPTZ, + last_verified_at TIMESTAMPTZ, + last_verified_node TEXT, + active_domains_at_verify INTEGER, + payload JSONB, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT licenses_key_unique UNIQUE (license_key), + CONSTRAINT licenses_status_check CHECK (status IN ('trial', 'active', 'expired', 'invalid', 'unknown')) +); + +CREATE INDEX IF NOT EXISTS idx_licenses_valid_until ON licenses (valid_until); +CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses (status); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS licenses; +-- +goose StatementEnd diff --git a/internal/database/migrations_unique_test.go b/internal/database/migrations_unique_test.go new file mode 100644 index 0000000..1304cb1 --- /dev/null +++ b/internal/database/migrations_unique_test.go @@ -0,0 +1,19 @@ +package database + +import "testing" + +// TestEmbeddedMigrationsUnique guards against duplicate migration +// version prefixes. mail-gateway hit this on 2026-05-08 when two +// parallel agents both committed a 0092_*.sql; goose panicked at +// startup, the API restart-looped, the cluster rolling-upgrade hung. +// +// Cheap assertion that runs as part of `go test ./...` — fails the +// build before `make deb` ever produces an artefact, so the bad +// version never reaches the APT registry. Same logic also runs at +// service start via Migrate() and via `edgeguard-ctl migrate check` +// in postinst (defense in depth). +func TestEmbeddedMigrationsUnique(t *testing.T) { + if err := ValidateMigrations(); err != nil { + t.Fatalf("embedded migrations failed validation: %v", err) + } +} diff --git a/internal/models/audit.go b/internal/models/audit.go new file mode 100644 index 0000000..c1aee03 --- /dev/null +++ b/internal/models/audit.go @@ -0,0 +1,18 @@ +package models + +import ( + "encoding/json" + "time" +) + +type AuditLog struct { + ID int64 `gorm:"primaryKey" json:"id"` + Actor string `gorm:"column:actor" json:"actor"` + Action string `gorm:"column:action" json:"action"` + Subject *string `gorm:"column:subject" json:"subject,omitempty"` + Detail json.RawMessage `gorm:"column:detail;type:jsonb" json:"detail,omitempty"` + NodeID *string `gorm:"column:node_id" json:"node_id,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` +} + +func (AuditLog) TableName() string { return "audit_log" } diff --git a/internal/models/backend.go b/internal/models/backend.go new file mode 100644 index 0000000..e1f72ae --- /dev/null +++ b/internal/models/backend.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type Backend struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + Scheme string `gorm:"column:scheme" json:"scheme"` + Address string `gorm:"column:address" json:"address"` + Port int `gorm:"column:port" json:"port"` + HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (Backend) TableName() string { return "backends" } diff --git a/internal/models/dns.go b/internal/models/dns.go new file mode 100644 index 0000000..6de7889 --- /dev/null +++ b/internal/models/dns.go @@ -0,0 +1,30 @@ +package models + +import "time" + +type DNSZone struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + ZoneType string `gorm:"column:zone_type" json:"zone_type"` + Description *string `gorm:"column:description" json:"description,omitempty"` + ManagedBy string `gorm:"column:managed_by" json:"managed_by"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (DNSZone) TableName() string { return "dns_zones" } + +type DNSRecord struct { + ID int64 `gorm:"primaryKey" json:"id"` + ZoneID int64 `gorm:"column:zone_id" json:"zone_id"` + Name string `gorm:"column:name" json:"name"` + RecordType string `gorm:"column:record_type" json:"record_type"` + Value string `gorm:"column:value" json:"value"` + TTL int `gorm:"column:ttl" json:"ttl"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (DNSRecord) TableName() string { return "dns_records" } diff --git a/internal/models/doc.go b/internal/models/doc.go new file mode 100644 index 0000000..b98dd7d --- /dev/null +++ b/internal/models/doc.go @@ -0,0 +1,9 @@ +// Package models holds the GORM data models for edgeguard-api: users, +// audit_log, system_settings, ha_nodes, backends, domains, +// routing_rules, tls_certs, forward_proxy_acls, wireguard_peers, +// firewall_rules, dns_zones, dns_records, licenses. +// +// Schema is owned by goose (internal/database/migrations/) — these +// structs only describe the row layout for query convenience; never +// rely on GORM's AutoMigrate against this package. +package models diff --git a/internal/models/domain.go b/internal/models/domain.go new file mode 100644 index 0000000..52f80ff --- /dev/null +++ b/internal/models/domain.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type Domain struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + Active bool `gorm:"column:active" json:"active"` + PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"` + HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"` + HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"` + Notes *string `gorm:"column:notes" json:"notes,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (Domain) TableName() string { return "domains" } diff --git a/internal/models/firewall_rule.go b/internal/models/firewall_rule.go new file mode 100644 index 0000000..a0cd106 --- /dev/null +++ b/internal/models/firewall_rule.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type FirewallRule struct { + ID int64 `gorm:"primaryKey" json:"id"` + Chain string `gorm:"column:chain" json:"chain"` + Priority int `gorm:"column:priority" json:"priority"` + MatchExpr string `gorm:"column:match_expr" json:"match_expr"` + Action string `gorm:"column:action" json:"action"` + Comment *string `gorm:"column:comment" json:"comment,omitempty"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (FirewallRule) TableName() string { return "firewall_rules" } diff --git a/internal/models/forward_proxy_acl.go b/internal/models/forward_proxy_acl.go new file mode 100644 index 0000000..8c11f0a --- /dev/null +++ b/internal/models/forward_proxy_acl.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type ForwardProxyACL struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name" json:"name"` + ACLType string `gorm:"column:acl_type" json:"acl_type"` + Value string `gorm:"column:value" json:"value"` + Action string `gorm:"column:action" json:"action"` + Priority int `gorm:"column:priority" json:"priority"` + Active bool `gorm:"column:active" json:"active"` + Comment *string `gorm:"column:comment" json:"comment,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (ForwardProxyACL) TableName() string { return "forward_proxy_acls" } diff --git a/internal/models/ha_node.go b/internal/models/ha_node.go new file mode 100644 index 0000000..a461748 --- /dev/null +++ b/internal/models/ha_node.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type HANode struct { + ID string `gorm:"column:id;primaryKey" json:"id"` + Name string `gorm:"column:name" json:"name"` + FQDN string `gorm:"column:fqdn;uniqueIndex" json:"fqdn"` + APIURL string `gorm:"column:api_url" json:"api_url"` + PublicIP *string `gorm:"column:public_ip;type:inet" json:"public_ip,omitempty"` + InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"` + Role string `gorm:"column:role" json:"role"` + LastSeen *time.Time `gorm:"column:last_seen" json:"last_seen,omitempty"` + JoinedAt time.Time `gorm:"column:joined_at" json:"joined_at"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (HANode) TableName() string { return "ha_nodes" } diff --git a/internal/models/license.go b/internal/models/license.go new file mode 100644 index 0000000..08e27d0 --- /dev/null +++ b/internal/models/license.go @@ -0,0 +1,22 @@ +package models + +import ( + "encoding/json" + "time" +) + +type License struct { + ID int64 `gorm:"primaryKey" json:"id"` + LicenseKey string `gorm:"column:license_key;uniqueIndex" json:"license_key"` + Status string `gorm:"column:status" json:"status"` + ValidUntil *time.Time `gorm:"column:valid_until" json:"valid_until,omitempty"` + LastVerifiedAt *time.Time `gorm:"column:last_verified_at" json:"last_verified_at,omitempty"` + LastVerifiedNode *string `gorm:"column:last_verified_node" json:"last_verified_node,omitempty"` + ActiveDomainsAtVerify *int `gorm:"column:active_domains_at_verify" json:"active_domains_at_verify,omitempty"` + Payload json.RawMessage `gorm:"column:payload;type:jsonb" json:"payload,omitempty"` + LastError *string `gorm:"column:last_error" json:"last_error,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (License) TableName() string { return "licenses" } diff --git a/internal/models/routing_rule.go b/internal/models/routing_rule.go new file mode 100644 index 0000000..8eb33c1 --- /dev/null +++ b/internal/models/routing_rule.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type RoutingRule struct { + ID int64 `gorm:"primaryKey" json:"id"` + DomainID int64 `gorm:"column:domain_id" json:"domain_id"` + PathPrefix string `gorm:"column:path_prefix" json:"path_prefix"` + BackendID int64 `gorm:"column:backend_id" json:"backend_id"` + Priority int `gorm:"column:priority" json:"priority"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (RoutingRule) TableName() string { return "routing_rules" } diff --git a/internal/models/system_setting.go b/internal/models/system_setting.go new file mode 100644 index 0000000..02214de --- /dev/null +++ b/internal/models/system_setting.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type SystemSetting struct { + Key string `gorm:"column:key;primaryKey" json:"key"` + Value string `gorm:"column:value" json:"value"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (SystemSetting) TableName() string { return "system_settings" } diff --git a/internal/models/tls_cert.go b/internal/models/tls_cert.go new file mode 100644 index 0000000..3088765 --- /dev/null +++ b/internal/models/tls_cert.go @@ -0,0 +1,20 @@ +package models + +import "time" + +type TLSCert struct { + ID int64 `gorm:"primaryKey" json:"id"` + Domain string `gorm:"column:domain;uniqueIndex" json:"domain"` + Issuer string `gorm:"column:issuer" json:"issuer"` + Status string `gorm:"column:status" json:"status"` + CertPath *string `gorm:"column:cert_path" json:"cert_path,omitempty"` + KeyPath *string `gorm:"column:key_path" json:"key_path,omitempty"` + NotBefore *time.Time `gorm:"column:not_before" json:"not_before,omitempty"` + NotAfter *time.Time `gorm:"column:not_after" json:"not_after,omitempty"` + LastRenewedAt *time.Time `gorm:"column:last_renewed_at" json:"last_renewed_at,omitempty"` + LastError *string `gorm:"column:last_error" json:"last_error,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (TLSCert) TableName() string { return "tls_certs" } diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..6c092ca --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type User struct { + ID int64 `gorm:"primaryKey" json:"id"` + Email string `gorm:"column:email;uniqueIndex" json:"email"` + PasswordHash string `gorm:"column:password_hash" json:"-"` + Role string `gorm:"column:role" json:"role"` + Active bool `gorm:"column:active" json:"active"` + LastLoginAt *time.Time `gorm:"column:last_login_at" json:"last_login_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (User) TableName() string { return "users" } diff --git a/internal/models/wireguard_peer.go b/internal/models/wireguard_peer.go new file mode 100644 index 0000000..b6b270e --- /dev/null +++ b/internal/models/wireguard_peer.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type WireGuardPeer struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + PeerType string `gorm:"column:peer_type" json:"peer_type"` + PublicKey string `gorm:"column:public_key;uniqueIndex" json:"public_key"` + PrivateKeyEnc *string `gorm:"column:private_key_enc" json:"-"` + PresharedKeyEnc *string `gorm:"column:preshared_key_enc" json:"-"` + AllowedIPs string `gorm:"column:allowed_ips" json:"allowed_ips"` + Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"` + ListenPort *int `gorm:"column:listen_port" json:"listen_port,omitempty"` + PersistentKeepalive *int `gorm:"column:persistent_keepalive" json:"persistent_keepalive,omitempty"` + LastHandshakeAt *time.Time `gorm:"column:last_handshake_at" json:"last_handshake_at,omitempty"` + Active bool `gorm:"column:active" json:"active"` + Notes *string `gorm:"column:notes" json:"notes,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (WireGuardPeer) TableName() string { return "wireguard_peers" } diff --git a/migrations/.gitkeep b/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000