From 8c21b62a4f81f198a225ec73764135767f6c92f3 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 17 Jun 2026 04:28:17 +0300 Subject: [PATCH] first commit --- .env.example | 1 + .gitignore | 4 + Dockerfile.migrate | 5 + README.md | 77 +++++++ cmd/migrate/main.go | 60 ++++++ cmd/panel/main.go | 31 +++ docker-compose.yml | 35 ++++ go.mod | 22 ++ go.sum | 85 ++++++++ internal/config/config.go | 18 ++ internal/database/migrate.go | 44 ++++ internal/database/postgres.go | 34 +++ internal/models/models.go | 146 +++++++++++++ migrations/000001_init.down.sql | 46 ++++ migrations/000001_init.up.sql | 357 ++++++++++++++++++++++++++++++++ 15 files changed, 965 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile.migrate create mode 100644 README.md create mode 100644 cmd/migrate/main.go create mode 100644 cmd/panel/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/migrate.go create mode 100644 internal/database/postgres.go create mode 100644 internal/models/models.go create mode 100644 migrations/000001_init.down.sql create mode 100644 migrations/000001_init.up.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aee84ae --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d1b33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*.exe +/bin/ +/dist/ diff --git a/Dockerfile.migrate b/Dockerfile.migrate new file mode 100644 index 0000000..b4f397d --- /dev/null +++ b/Dockerfile.migrate @@ -0,0 +1,5 @@ +FROM migrate/migrate:v4.18.2 + +COPY migrations /migrations + +ENTRYPOINT ["migrate", "-path", "/migrations", "-database"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f132f8 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# PanelHosting + +Панель управления хостингом (аналог FastPanel) на Go + Docker + PostgreSQL. + +Биллинг и оплата — в планах. Сейчас реализована база данных и инфраструктура для панели. + +## Стек + +- **Go 1.23** — backend +- **PostgreSQL 16** — хранение данных +- **Docker Compose** — локальная разработка +- **golang-migrate** — миграции схемы + +## Быстрый старт + +### 1. Переменные окружения + +```bash +cp .env.example .env +``` + +### 2. Запуск PostgreSQL + +```bash +docker compose up -d postgres +``` + +### 3. Миграции + +Через Docker: + +```bash +docker compose --profile migrate run --rm migrate +``` + +Или локально: + +```bash +export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable +go run ./cmd/migrate +``` + +### 4. Проверка подключения + +```bash +go run ./cmd/panel +``` + +## Структура проекта + +``` +cmd/ + migrate/ — CLI для применения миграций + panel/ — точка входа панели (API в разработке) +internal/ + config/ — конфигурация из env + database/ — пул соединений и миграции + models/ — Go-модели +migrations/ — SQL-схема +``` + +## Схема БД + +| Раздел | Таблицы | +|--------|---------| +| Auth | `users`, `sessions`, `api_keys` | +| Серверы | `servers`, `server_access` | +| Сайты | `sites`, `domains` | +| Сервисы | `databases`, `database_users`, `ftp_accounts`, `ssl_certificates` | +| Почта | `mail_domains`, `mail_accounts` | +| DNS | `dns_zones`, `dns_records` | +| Операции | `cron_jobs`, `backups`, `jobs` | +| Аудит | `audit_logs` | + +## Лицензия + +Proprietary diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..8bde4c4 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "path/filepath" + + "github.com/panelhosting/panel/internal/config" + "github.com/panelhosting/panel/internal/database" +) + +func main() { + down := flag.Bool("down", false, "rollback all migrations") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + migrationsPath, err := resolveMigrationsPath() + if err != nil { + log.Fatal(err) + } + + if *down { + if err := database.RollbackMigrations(cfg.DatabaseURL, migrationsPath); err != nil { + log.Fatal(err) + } + log.Println("migrations rolled back") + return + } + + if err := database.RunMigrations(cfg.DatabaseURL, migrationsPath); err != nil { + log.Fatal(err) + } + log.Println("migrations applied") + + ctx := context.Background() + pool, err := database.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal(err) + } + defer pool.Close() + log.Println("database connection ok") +} + +func resolveMigrationsPath() (string, error) { + if p := os.Getenv("MIGRATIONS_PATH"); p != "" { + return p, nil + } + + wd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(wd, "migrations"), nil +} diff --git a/cmd/panel/main.go b/cmd/panel/main.go new file mode 100644 index 0000000..e4cabb1 --- /dev/null +++ b/cmd/panel/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/panelhosting/panel/internal/config" + "github.com/panelhosting/panel/internal/database" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pool, err := database.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatal(err) + } + defer pool.Close() + + log.Println("panel database ready (API coming soon)") + <-ctx.Done() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..35ba6dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:16-alpine + container_name: panel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: panel + POSTGRES_PASSWORD: panel_secret + POSTGRES_DB: panel + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U panel -d panel"] + interval: 5s + timeout: 5s + retries: 5 + + migrate: + build: + context: . + dockerfile: Dockerfile.migrate + container_name: panel-migrate + depends_on: + postgres: + condition: service_healthy + command: + - "postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable" + - "up" + profiles: + - migrate + +volumes: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e6b8d26 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/panelhosting/panel + +go 1.23.0 + +require ( + github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 +) + +require ( + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // 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/lib/pq v1.10.9 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac01d5d --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/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/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= +github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= +github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +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.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..935ab12 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,18 @@ +package config + +import ( + "fmt" + "os" +) + +type Config struct { + DatabaseURL string +} + +func Load() (*Config, error) { + url := os.Getenv("DATABASE_URL") + if url == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + return &Config{DatabaseURL: url}, nil +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..45a7f22 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,44 @@ +package database + +import ( + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func RunMigrations(databaseURL, migrationsPath string) error { + m, err := migrate.New( + fmt.Sprintf("file://%s", migrationsPath), + databaseURL, + ) + if err != nil { + return fmt.Errorf("create migrator: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + + return nil +} + +func RollbackMigrations(databaseURL, migrationsPath string) error { + m, err := migrate.New( + fmt.Sprintf("file://%s", migrationsPath), + databaseURL, + ) + if err != nil { + return fmt.Errorf("create migrator: %w", err) + } + defer m.Close() + + if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate down: %w", err) + } + + return nil +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..4947a2c --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse database url: %w", err) + } + + cfg.MaxConns = 20 + cfg.MinConns = 2 + cfg.MaxConnLifetime = time.Hour + cfg.MaxConnIdleTime = 30 * time.Minute + cfg.HealthCheckPeriod = time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + return pool, nil +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..0417572 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,146 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusSuspended UserStatus = "suspended" + UserStatusPending UserStatus = "pending" +) + +type UserRole string + +const ( + RoleSuperAdmin UserRole = "superadmin" + RoleAdmin UserRole = "admin" + RoleReseller UserRole = "reseller" + RoleUser UserRole = "user" +) + +type User struct { + ID int64 `json:"id"` + UUID uuid.UUID `json:"uuid"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role UserRole `json:"role"` + Status UserStatus `json:"status"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ServerStatus string + +const ( + ServerOnline ServerStatus = "online" + ServerOffline ServerStatus = "offline" + ServerMaintenance ServerStatus = "maintenance" + ServerProvisioning ServerStatus = "provisioning" +) + +type Server struct { + ID int64 `json:"id"` + UUID uuid.UUID `json:"uuid"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + SSHPort int `json:"ssh_port"` + Status ServerStatus `json:"status"` + AgentVersion *string `json:"agent_version,omitempty"` + OSInfo map[string]any `json:"os_info"` + Resources map[string]any `json:"resources"` + Settings map[string]any `json:"settings"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SiteStatus string + +const ( + SiteActive SiteStatus = "active" + SiteSuspended SiteStatus = "suspended" + SiteCreating SiteStatus = "creating" + SiteDeleting SiteStatus = "deleting" + SiteError SiteStatus = "error" +) + +type Site struct { + ID int64 `json:"id"` + UUID uuid.UUID `json:"uuid"` + ServerID int64 `json:"server_id"` + OwnerID int64 `json:"owner_id"` + Name string `json:"name"` + DocumentRoot string `json:"document_root"` + PHPVersion *string `json:"php_version,omitempty"` + Status SiteStatus `json:"status"` + Settings map[string]any `json:"settings"` + DiskQuotaMB *int `json:"disk_quota_mb,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Domain struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + Domain string `json:"domain"` + IsPrimary bool `json:"is_primary"` + SSLEnabled bool `json:"ssl_enabled"` + CreatedAt time.Time `json:"created_at"` +} + +type DBEngine string + +const ( + EngineMySQL DBEngine = "mysql" + EnginePostgreSQL DBEngine = "postgresql" +) + +type Database struct { + ID int64 `json:"id"` + UUID uuid.UUID `json:"uuid"` + SiteID int64 `json:"site_id"` + ServerID int64 `json:"server_id"` + Name string `json:"name"` + Engine DBEngine `json:"engine"` + Charset string `json:"charset"` + CreatedAt time.Time `json:"created_at"` +} + +type JobStatus string + +const ( + JobPending JobStatus = "pending" + JobRunning JobStatus = "running" + JobCompleted JobStatus = "completed" + JobFailed JobStatus = "failed" + JobCancelled JobStatus = "cancelled" +) + +type Job struct { + ID int64 `json:"id"` + UUID uuid.UUID `json:"uuid"` + ServerID *int64 `json:"server_id,omitempty"` + SiteID *int64 `json:"site_id,omitempty"` + UserID *int64 `json:"user_id,omitempty"` + Type string `json:"type"` + Payload map[string]any `json:"payload"` + Status JobStatus `json:"status"` + Result map[string]any `json:"result,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + Attempts int `json:"attempts"` + MaxAttempts int `json:"max_attempts"` + ScheduledAt time.Time `json:"scheduled_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..58c6ae5 --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,46 @@ +DROP TRIGGER IF EXISTS trg_cron_jobs_updated_at ON cron_jobs; +DROP TRIGGER IF EXISTS trg_dns_records_updated_at ON dns_records; +DROP TRIGGER IF EXISTS trg_dns_zones_updated_at ON dns_zones; +DROP TRIGGER IF EXISTS trg_mail_accounts_updated_at ON mail_accounts; +DROP TRIGGER IF EXISTS trg_ssl_certificates_updated_at ON ssl_certificates; +DROP TRIGGER IF EXISTS trg_ftp_accounts_updated_at ON ftp_accounts; +DROP TRIGGER IF EXISTS trg_sites_updated_at ON sites; +DROP TRIGGER IF EXISTS trg_servers_updated_at ON servers; +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; + +DROP FUNCTION IF EXISTS set_updated_at(); + +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS backups; +DROP TABLE IF EXISTS cron_jobs; +DROP TABLE IF EXISTS dns_records; +DROP TABLE IF EXISTS dns_zones; +DROP TABLE IF EXISTS mail_accounts; +DROP TABLE IF EXISTS mail_domains; +DROP TABLE IF EXISTS ssl_certificates; +DROP TABLE IF EXISTS ftp_accounts; +DROP TABLE IF EXISTS database_users; +DROP TABLE IF EXISTS databases; +DROP TABLE IF EXISTS domains; +DROP TABLE IF EXISTS sites; +DROP TABLE IF EXISTS server_access; +DROP TABLE IF EXISTS servers; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; + +DROP TYPE IF EXISTS dns_record_type; +DROP TYPE IF EXISTS job_status; +DROP TYPE IF EXISTS backup_status; +DROP TYPE IF EXISTS backup_type; +DROP TYPE IF EXISTS db_engine; +DROP TYPE IF EXISTS ssl_status; +DROP TYPE IF EXISTS ssl_type; +DROP TYPE IF EXISTS site_status; +DROP TYPE IF EXISTS server_status; +DROP TYPE IF EXISTS user_role; +DROP TYPE IF EXISTS user_status; + +DROP EXTENSION IF EXISTS citext; +DROP EXTENSION IF EXISTS pgcrypto; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..374e55c --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,357 @@ +-- Panel hosting control panel — initial schema + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE user_status AS ENUM ('active', 'suspended', 'pending'); +CREATE TYPE user_role AS ENUM ('superadmin', 'admin', 'reseller', 'user'); + +CREATE TYPE server_status AS ENUM ('online', 'offline', 'maintenance', 'provisioning'); +CREATE TYPE site_status AS ENUM ('active', 'suspended', 'creating', 'deleting', 'error'); +CREATE TYPE ssl_type AS ENUM ('letsencrypt', 'custom', 'self_signed'); +CREATE TYPE ssl_status AS ENUM ('pending', 'active', 'expired', 'error'); +CREATE TYPE db_engine AS ENUM ('mysql', 'postgresql'); +CREATE TYPE backup_type AS ENUM ('full', 'files', 'database'); +CREATE TYPE backup_status AS ENUM ('pending', 'running', 'completed', 'failed'); +CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'failed', 'cancelled'); +CREATE TYPE dns_record_type AS ENUM ('A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR'); + +-- ─── Users & auth ──────────────────────────────────────────────────────────── + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + email CITEXT NOT NULL UNIQUE, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role user_role NOT NULL DEFAULT 'user', + status user_status NOT NULL DEFAULT 'pending', + locale VARCHAR(10) NOT NULL DEFAULT 'ru', + timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/Moscow', + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + ip_address INET, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +CREATE TABLE api_keys ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix VARCHAR(16) NOT NULL, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); + +-- ─── Servers (managed nodes) ───────────────────────────────────────────────── + +CREATE TABLE servers ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + name VARCHAR(128) NOT NULL, + hostname VARCHAR(255) NOT NULL, + ip_address INET NOT NULL, + ssh_port INT NOT NULL DEFAULT 22, + status server_status NOT NULL DEFAULT 'provisioning', + agent_token_hash TEXT, + agent_version VARCHAR(32), + os_info JSONB NOT NULL DEFAULT '{}', + resources JSONB NOT NULL DEFAULT '{}', + settings JSONB NOT NULL DEFAULT '{}', + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_servers_hostname ON servers(hostname); + +-- User access to servers (multi-tenant / reseller model) +CREATE TABLE server_access ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + can_manage BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, server_id) +); + +-- ─── Sites (websites) ──────────────────────────────────────────────────────── + +CREATE TABLE sites ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE RESTRICT, + owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + name VARCHAR(128) NOT NULL, + document_root VARCHAR(512) NOT NULL, + php_version VARCHAR(16), + status site_status NOT NULL DEFAULT 'creating', + settings JSONB NOT NULL DEFAULT '{}', + disk_quota_mb INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (server_id, name) +); + +CREATE INDEX idx_sites_owner_id ON sites(owner_id); +CREATE INDEX idx_sites_server_id ON sites(server_id); + +CREATE TABLE domains ( + id BIGSERIAL PRIMARY KEY, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + domain CITEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT false, + ssl_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (domain) +); + +CREATE INDEX idx_domains_site_id ON domains(site_id); + +-- Only one primary domain per site +CREATE UNIQUE INDEX idx_domains_site_primary + ON domains(site_id) WHERE is_primary = true; + +-- ─── Databases ─────────────────────────────────────────────────────────────── + +CREATE TABLE databases ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE RESTRICT, + name VARCHAR(64) NOT NULL, + engine db_engine NOT NULL DEFAULT 'mysql', + charset VARCHAR(32) NOT NULL DEFAULT 'utf8mb4', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (server_id, engine, name) +); + +CREATE INDEX idx_databases_site_id ON databases(site_id); + +CREATE TABLE database_users ( + id BIGSERIAL PRIMARY KEY, + database_id BIGINT NOT NULL REFERENCES databases(id) ON DELETE CASCADE, + username VARCHAR(64) NOT NULL, + password_hash TEXT NOT NULL, + privileges TEXT[] NOT NULL DEFAULT '{ALL}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (database_id, username) +); + +-- ─── FTP ───────────────────────────────────────────────────────────────────── + +CREATE TABLE ftp_accounts ( + id BIGSERIAL PRIMARY KEY, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + username VARCHAR(64) NOT NULL, + password_hash TEXT NOT NULL, + home_path VARCHAR(512) NOT NULL, + quota_mb INT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (site_id, username) +); + +-- ─── SSL certificates ──────────────────────────────────────────────────────── + +CREATE TABLE ssl_certificates ( + id BIGSERIAL PRIMARY KEY, + domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + type ssl_type NOT NULL, + status ssl_status NOT NULL DEFAULT 'pending', + issuer VARCHAR(255), + cert_pem TEXT, + key_pem TEXT, + chain_pem TEXT, + issued_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + auto_renew BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_ssl_certificates_domain_id ON ssl_certificates(domain_id); +CREATE INDEX idx_ssl_certificates_expires_at ON ssl_certificates(expires_at); + +-- ─── Mail ──────────────────────────────────────────────────────────────────── + +CREATE TABLE mail_domains ( + id BIGSERIAL PRIMARY KEY, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + domain CITEXT NOT NULL UNIQUE, + dkim_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE mail_accounts ( + id BIGSERIAL PRIMARY KEY, + mail_domain_id BIGINT NOT NULL REFERENCES mail_domains(id) ON DELETE CASCADE, + email CITEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + quota_mb INT NOT NULL DEFAULT 1024, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_mail_accounts_mail_domain_id ON mail_accounts(mail_domain_id); + +-- ─── DNS ───────────────────────────────────────────────────────────────────── + +CREATE TABLE dns_zones ( + id BIGSERIAL PRIMARY KEY, + server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + domain CITEXT NOT NULL, + serial BIGINT NOT NULL DEFAULT 1, + ttl INT NOT NULL DEFAULT 3600, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (server_id, domain) +); + +CREATE TABLE dns_records ( + id BIGSERIAL PRIMARY KEY, + zone_id BIGINT NOT NULL REFERENCES dns_zones(id) ON DELETE CASCADE, + type dns_record_type NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '@', + content TEXT NOT NULL, + ttl INT NOT NULL DEFAULT 3600, + priority INT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_dns_records_zone_id ON dns_records(zone_id); + +-- ─── Cron jobs ─────────────────────────────────────────────────────────────── + +CREATE TABLE cron_jobs ( + id BIGSERIAL PRIMARY KEY, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + schedule VARCHAR(128) NOT NULL, + command TEXT NOT NULL, + run_as VARCHAR(64) NOT NULL DEFAULT 'www-data', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_cron_jobs_site_id ON cron_jobs(site_id); + +-- ─── Backups ───────────────────────────────────────────────────────────────── + +CREATE TABLE backups ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + site_id BIGINT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + type backup_type NOT NULL, + status backup_status NOT NULL DEFAULT 'pending', + storage_path VARCHAR(1024), + size_bytes BIGINT, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_backups_site_id ON backups(site_id); +CREATE INDEX idx_backups_status ON backups(status); + +-- ─── Async job queue (panel → agent tasks) ─────────────────────────────────── + +CREATE TABLE jobs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL, + site_id BIGINT REFERENCES sites(id) ON DELETE SET NULL, + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + type VARCHAR(64) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + status job_status NOT NULL DEFAULT 'pending', + result JSONB, + error_message TEXT, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_jobs_status_scheduled ON jobs(status, scheduled_at) + WHERE status IN ('pending', 'running'); +CREATE INDEX idx_jobs_server_id ON jobs(server_id); + +-- ─── Audit log ─────────────────────────────────────────────────────────────── + +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(64) NOT NULL, + resource VARCHAR(64) NOT NULL, + resource_id BIGINT, + details JSONB NOT NULL DEFAULT '{}', + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource, resource_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + +-- ─── updated_at trigger ────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_servers_updated_at + BEFORE UPDATE ON servers FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_sites_updated_at + BEFORE UPDATE ON sites FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_ftp_accounts_updated_at + BEFORE UPDATE ON ftp_accounts FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_ssl_certificates_updated_at + BEFORE UPDATE ON ssl_certificates FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_mail_accounts_updated_at + BEFORE UPDATE ON mail_accounts FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_dns_zones_updated_at + BEFORE UPDATE ON dns_zones FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_dns_records_updated_at + BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + +CREATE TRIGGER trg_cron_jobs_updated_at + BEFORE UPDATE ON cron_jobs FOR EACH ROW EXECUTE PROCEDURE set_updated_at();