feat(db): Phase 1 — DB-Schema, goose-Migrations, GORM-Models
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) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,8 @@ cd management-ui && bun run dev
|
|||||||
│ ├── edgeguard-scheduler/ # Cron-Jobs
|
│ ├── edgeguard-scheduler/ # Cron-Jobs
|
||||||
│ └── edgeguard-ctl/ # CLI
|
│ └── edgeguard-ctl/ # CLI
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── database/ # pgxpool + goose-Runner; migrations/ via go:embed
|
||||||
|
│ │ └── migrations/ # SQL (goose-Format) — embedded ins Binary
|
||||||
│ ├── models/ # GORM-Models
|
│ ├── models/ # GORM-Models
|
||||||
│ ├── handlers/ # HTTP-Handler (REST)
|
│ ├── handlers/ # HTTP-Handler (REST)
|
||||||
│ ├── services/ # Business-Logik
|
│ ├── services/ # Business-Logik
|
||||||
@@ -146,7 +148,6 @@ cd management-ui && bun run dev
|
|||||||
│ ├── aggregator/ # Cluster-View APIs
|
│ ├── aggregator/ # Cluster-View APIs
|
||||||
│ └── license/ # Lizenz-Validierung
|
│ └── license/ # Lizenz-Validierung
|
||||||
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
|
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
|
||||||
├── migrations/ # SQL (goose-Format)
|
|
||||||
├── packaging/debian/ # control, postinst, postrm, systemd-Units
|
├── packaging/debian/ # control, postinst, postrm, systemd-Units
|
||||||
├── deploy/
|
├── deploy/
|
||||||
│ ├── systemd/ # *.service, *.target, *.timer
|
│ ├── systemd/ # *.service, *.target, *.timer
|
||||||
@@ -171,7 +172,7 @@ cd management-ui && bun run dev
|
|||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
### Go-Code
|
### 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
|
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung
|
||||||
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
|
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
|
||||||
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
|
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
|||||||
│ ├── edgeguard-scheduler/ # Cron-artige Jobs
|
│ ├── edgeguard-scheduler/ # Cron-artige Jobs
|
||||||
│ └── edgeguard-ctl/ # CLI für Setup/Wartung
|
│ └── edgeguard-ctl/ # CLI für Setup/Wartung
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── database/ # pgxpool + goose-Runner, migrations/ via go:embed
|
||||||
|
│ │ └── migrations/ # 0001_*.sql … (goose-Format, embedded)
|
||||||
│ ├── models/ # GORM-Models (domain, backend, routing_rule, acl, peer, …)
|
│ ├── models/ # GORM-Models (domain, backend, routing_rule, acl, peer, …)
|
||||||
│ ├── handlers/ # HTTP-Handler (REST)
|
│ ├── handlers/ # HTTP-Handler (REST)
|
||||||
│ ├── services/ # Business-Logik (config-render, health-check, cluster-sync)
|
│ ├── 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
|
│ ├── apt-repo/ # build-package.sh, publish.sh, setup-repo.sh
|
||||||
│ ├── install.sh # Bootstrap-Onliner
|
│ ├── install.sh # Bootstrap-Onliner
|
||||||
│ └── release.sh # CI Release-Helper
|
│ └── release.sh # CI Release-Helper
|
||||||
├── migrations/ # SQL-Migrations (goose-Format)
|
|
||||||
├── docs/
|
├── docs/
|
||||||
├── Makefile
|
├── Makefile
|
||||||
├── go.mod # module git.netcell-it.de/projekte/edgeguard-native
|
├── 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`.
|
- **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.
|
- **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`).
|
- **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`.
|
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`.
|
||||||
|
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -1,8 +1,12 @@
|
|||||||
module git.netcell-it.de/projekte/edgeguard-native
|
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 (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // 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/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/text v0.34.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
|
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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
58
go.sum
58
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
188
internal/database/db.go
Normal file
188
internal/database/db.go
Normal file
@@ -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 <version>_<name>.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
|
||||||
|
}
|
||||||
56
internal/database/migrations/0001_init.sql
Normal file
56
internal/database/migrations/0001_init.sql
Normal file
@@ -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
|
||||||
35
internal/database/migrations/0002_cluster.sql
Normal file
35
internal/database/migrations/0002_cluster.sql
Normal file
@@ -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
|
||||||
88
internal/database/migrations/0003_http_frontend.sql
Normal file
88
internal/database/migrations/0003_http_frontend.sql
Normal file
@@ -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
|
||||||
32
internal/database/migrations/0004_forward_proxy.sql
Normal file
32
internal/database/migrations/0004_forward_proxy.sql
Normal file
@@ -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
|
||||||
41
internal/database/migrations/0005_wireguard.sql
Normal file
41
internal/database/migrations/0005_wireguard.sql
Normal file
@@ -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
|
||||||
35
internal/database/migrations/0006_firewall.sql
Normal file
35
internal/database/migrations/0006_firewall.sql
Normal file
@@ -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
|
||||||
47
internal/database/migrations/0007_dns.sql
Normal file
47
internal/database/migrations/0007_dns.sql
Normal file
@@ -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
|
||||||
38
internal/database/migrations/0008_licenses.sql
Normal file
38
internal/database/migrations/0008_licenses.sql
Normal file
@@ -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
|
||||||
19
internal/database/migrations_unique_test.go
Normal file
19
internal/database/migrations_unique_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/models/audit.go
Normal file
18
internal/models/audit.go
Normal file
@@ -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" }
|
||||||
17
internal/models/backend.go
Normal file
17
internal/models/backend.go
Normal file
@@ -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" }
|
||||||
30
internal/models/dns.go
Normal file
30
internal/models/dns.go
Normal file
@@ -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" }
|
||||||
9
internal/models/doc.go
Normal file
9
internal/models/doc.go
Normal file
@@ -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
|
||||||
17
internal/models/domain.go
Normal file
17
internal/models/domain.go
Normal file
@@ -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" }
|
||||||
17
internal/models/firewall_rule.go
Normal file
17
internal/models/firewall_rule.go
Normal file
@@ -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" }
|
||||||
18
internal/models/forward_proxy_acl.go
Normal file
18
internal/models/forward_proxy_acl.go
Normal file
@@ -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" }
|
||||||
19
internal/models/ha_node.go
Normal file
19
internal/models/ha_node.go
Normal file
@@ -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" }
|
||||||
22
internal/models/license.go
Normal file
22
internal/models/license.go
Normal file
@@ -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" }
|
||||||
16
internal/models/routing_rule.go
Normal file
16
internal/models/routing_rule.go
Normal file
@@ -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" }
|
||||||
11
internal/models/system_setting.go
Normal file
11
internal/models/system_setting.go
Normal file
@@ -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" }
|
||||||
20
internal/models/tls_cert.go
Normal file
20
internal/models/tls_cert.go
Normal file
@@ -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" }
|
||||||
16
internal/models/user.go
Normal file
16
internal/models/user.go
Normal file
@@ -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" }
|
||||||
23
internal/models/wireguard_peer.go
Normal file
23
internal/models/wireguard_peer.go
Normal file
@@ -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" }
|
||||||
Reference in New Issue
Block a user