From df7516e7ab8bcb2ac45cfc214b5c2d06dbbf31a6 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 12 Jul 2026 20:56:45 +0200 Subject: [PATCH] feat: production-ready domain control dashboard --- .dockerignore | 16 + .env.example | 10 + .env.production.example | 11 + .gitea/workflows/release.yml | 45 + .gitignore | 13 + Dockerfile | 28 + PRODUCTION.md | 170 ++ README.md | 76 + build/.gitkeep | 0 cmd/dyndns-web/main.go | 116 ++ compose.production.yml | 39 + docker-compose.yml | 32 + go.mod | 14 + go.sum | 10 + inspo/hetzner-dyndns | 1 - internal/config/config.go | 92 + internal/hetzner/client.go | 221 +++ internal/hetzner/client_test.go | 148 ++ internal/ipresolve/resolver.go | 190 ++ internal/ipresolve/resolver_test.go | 34 + internal/model/model.go | 137 ++ internal/scheduler/scheduler.go | 830 +++++++++ internal/scheduler/scheduler_test.go | 538 ++++++ internal/secure/secure.go | 122 ++ internal/secure/secure_test.go | 59 + internal/store/store.go | 1253 +++++++++++++ internal/store/store_test.go | 294 +++ internal/web/server.go | 2035 +++++++++++++++++++++ internal/web/server_test.go | 216 +++ internal/web/static/app.js | 252 +++ internal/web/static/styles.css | 396 ++++ internal/web/templates/dashboard.html | 58 + internal/web/templates/domain_detail.html | 128 ++ internal/web/templates/domain_new.html | 13 + internal/web/templates/domains.html | 22 + internal/web/templates/errors.html | 53 + internal/web/templates/layout.html | 71 + internal/web/templates/login.html | 18 + internal/web/templates/security.html | 8 + internal/web/templates/settings.html | 43 + internal/web/templates/setup.html | 26 + internal/web/templates/token.html | 4 + internal/web/templates/user_detail.html | 73 + internal/web/templates/user_new.html | 13 + internal/web/templates/users.html | 5 + 45 files changed, 7932 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PRODUCTION.md create mode 100644 README.md delete mode 100644 build/.gitkeep create mode 100644 cmd/dyndns-web/main.go create mode 100644 compose.production.yml create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum delete mode 160000 inspo/hetzner-dyndns create mode 100644 internal/config/config.go create mode 100644 internal/hetzner/client.go create mode 100644 internal/hetzner/client_test.go create mode 100644 internal/ipresolve/resolver.go create mode 100644 internal/ipresolve/resolver_test.go create mode 100644 internal/model/model.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/scheduler/scheduler_test.go create mode 100644 internal/secure/secure.go create mode 100644 internal/secure/secure_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 internal/web/static/app.js create mode 100644 internal/web/static/styles.css create mode 100644 internal/web/templates/dashboard.html create mode 100644 internal/web/templates/domain_detail.html create mode 100644 internal/web/templates/domain_new.html create mode 100644 internal/web/templates/domains.html create mode 100644 internal/web/templates/errors.html create mode 100644 internal/web/templates/layout.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/security.html create mode 100644 internal/web/templates/settings.html create mode 100644 internal/web/templates/setup.html create mode 100644 internal/web/templates/token.html create mode 100644 internal/web/templates/user_detail.html create mode 100644 internal/web/templates/user_new.html create mode 100644 internal/web/templates/users.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f865642 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.gitea +.agents +.codex +build +inspo +coverage +bin +tmp +secrets +.env* +backups +compose*.yml +docker-compose.yml +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e1a9b5d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# For LAN use, set this to the host's specific private address, for example 192.168.178.10. +# Keep 127.0.0.1 when a reverse proxy terminates HTTPS locally. +BIND_ADDRESS=127.0.0.1 +WEB_PORT=8080 + +# lan is only appropriate for an isolated, trusted LAN. Set https behind a TLS reverse proxy. +APP_MODE=lan + +# Create this file once with: openssl rand -base64 32 > secrets/dyndns_master_key +MASTER_KEY_FILE=./secrets/dyndns_master_key diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..998fa12 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,11 @@ +# Unveränderlichen Release-Tag aus der Gitea Container Registry verwenden. +DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod +IMAGE_TAG=v1.0.0 + +# Hinter einem HTTPS-Reverse-Proxy ausschließlich an localhost binden. +BIND_ADDRESS=127.0.0.1 +WEB_PORT=8080 +APP_MODE=https + +# Datei einmalig auf dem Produktionsserver erzeugen und dauerhaft sichern. +MASTER_KEY_FILE=./secrets/dyndns_master_key diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..71a4f2f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,45 @@ +name: Container-Image veröffentlichen + +on: + push: + tags: + - "v*" + +env: + REGISTRY: git.j-mahler.de + IMAGE_NAME: admin/domain-control-webinterface-prod + +jobs: + container: + runs-on: ubuntu-latest + steps: + - name: Repository auschecken + uses: actions/checkout@v4 + + - name: QEMU einrichten + uses: docker/setup-qemu-action@v3 + + - name: Docker Buildx einrichten + uses: docker/setup-buildx-action@v3 + + - name: An der Gitea Container Registry anmelden + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Versioniertes Image bauen und veröffentlichen + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.ref_name }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable + labels: | + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.version=${{ gitea.ref_name }} + provenance: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab50694 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.env.production +secrets/ +data/ +*.db +*.db-shm +*.db-wal +*.log +bin/ +coverage/ +/build/ +/inspo/ +/backups/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70cb9e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.25.12-alpine AS build + +RUN apk add --no-cache build-base +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dyndns-web ./cmd/dyndns-web + +FROM alpine:3.23.2 AS runtime + +RUN apk add --no-cache ca-certificates && \ + addgroup -S -g 10001 dyndns && \ + adduser -S -D -H -u 10001 -G dyndns dyndns && \ + mkdir -p /data && chown dyndns:dyndns /data + +COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web + +USER dyndns:dyndns +WORKDIR /data +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dyndns-web", "healthcheck"] + +ENTRYPOINT ["/usr/local/bin/dyndns-web"] diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 0000000..3305413 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,170 @@ +# Produktivbetrieb mit Gitea und Docker Compose + +Dieses Repository veröffentlicht versionierte Container-Images unter: + +```text +git.j-mahler.de/admin/domain-control-webinterface-prod:vX.Y.Z +``` + +Das Image enthält ausschließlich die Anwendung. SQLite-Datenbank und Master-Key liegen dauerhaft auf dem Produktionsserver und bleiben bei Image-Updates erhalten. + +## 1. Gitea für Releases vorbereiten + +In den Repository-Einstellungen unter **Actions → Secrets** diese zwei Secrets anlegen: + +- `REGISTRY_USERNAME`: Gitea-Benutzer oder eigener Release-Bot +- `REGISTRY_TOKEN`: persönlicher Gitea-Token mit Schreibrecht für Container-Pakete + +Auf der Gitea-Instanz muss ein Actions-Runner mit dem Label `ubuntu-latest` registriert sein. Jeder Git-Tag, der mit `v` beginnt, startet anschließend `.gitea/workflows/release.yml` und baut Images für AMD64 und ARM64. + +Erstes Release veröffentlichen: + +```bash +git tag -a v1.0.0 -m "Release v1.0.0" +git push origin v1.0.0 +``` + +Nach erfolgreicher Action stehen `v1.0.0` und der bewegliche Tag `stable` in der Gitea Container Registry. Auf Produktion immer den unveränderlichen Versionstag verwenden. + +### Image alternativ manuell veröffentlichen + +Falls Gitea Actions noch nicht eingerichtet ist: + +```bash +echo "$GITEA_TOKEN" | docker login git.j-mahler.de --username DEIN_BENUTZER --password-stdin + +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t git.j-mahler.de/admin/domain-control-webinterface-prod:v1.0.0 \ + -t git.j-mahler.de/admin/domain-control-webinterface-prod:stable \ + --push . +``` + +## 2. Produktionsserver vorbereiten + +Voraussetzungen: + +- Linux-Server mit Docker Engine und Docker-Compose-Plugin +- HTTPS-Reverse-Proxy wie Caddy, nginx oder Traefik +- DNS-Name, der auf den Server zeigt + +Repository installieren: + +```bash +sudo mkdir -p /opt/domain-control +sudo chown "$USER":"$USER" /opt/domain-control +git clone https://git.j-mahler.de/admin/domain-control-webinterface-prod.git /opt/domain-control +cd /opt/domain-control + +cp .env.production.example .env.production +mkdir -p secrets backups +chmod 700 secrets backups +openssl rand -base64 32 > secrets/dyndns_master_key +chmod 644 secrets/dyndns_master_key +``` + +Der Master-Key wird vom unprivilegierten Benutzer im Container gelesen. Deshalb ist die Datei lesbar, während das übergeordnete Verzeichnis auf dem Host nur für seinen Besitzer zugänglich bleibt. Den Schlüssel niemals nachträglich ersetzen: Ohne ihn lassen sich gespeicherte Hetzner-Tokens nicht mehr entschlüsseln. + +Bei einer privaten Registry auf dem Produktionsserver einmalig mit einem Token anmelden, der nur Leserechte auf Container-Pakete besitzt: + +```bash +echo "$GITEA_PULL_TOKEN" | docker login git.j-mahler.de --username deploy-bot --password-stdin +``` + +In `.env.production` mindestens `IMAGE_TAG` kontrollieren: + +```dotenv +DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod +IMAGE_TAG=v1.0.0 +BIND_ADDRESS=127.0.0.1 +WEB_PORT=8080 +APP_MODE=https +MASTER_KEY_FILE=./secrets/dyndns_master_key +``` + +Anwendung laden und starten: + +```bash +docker compose --env-file .env.production -f compose.production.yml pull +docker compose --env-file .env.production -f compose.production.yml up -d +docker compose --env-file .env.production -f compose.production.yml ps +curl --fail http://127.0.0.1:8080/healthz +``` + +## 3. HTTPS-Reverse-Proxy + +Port 8080 bleibt mit `BIND_ADDRESS=127.0.0.1` ausschließlich lokal erreichbar. Ein minimales Caddy-Beispiel: + +```caddyfile +dns.example.de { + reverse_proxy 127.0.0.1:8080 +} +``` + +Danach `https://dns.example.de` öffnen und den ersten Administrator anlegen. `APP_MODE=https` sorgt dabei für HTTPS-gebundene Session-Cookies. + +Nur in einem isolierten, vertrauenswürdigen LAN darf stattdessen eine private Server-IP und `APP_MODE=lan` verwendet werden. Den Dienst nicht unverschlüsselt über `0.0.0.0` ins Internet stellen. + +## 4. Backup vor jedem Update + +Für eine konsistente SQLite-Kopie wird der Container kurz gestoppt: + +```bash +cd /opt/domain-control +docker compose --env-file .env.production -f compose.production.yml stop dyndns + +docker run --rm \ + -v domain-control-data:/data:ro \ + -v "$PWD/backups:/backup" \ + alpine:3.23.2 \ + cp /data/dyndns.db /backup/dyndns-before-update.db + +docker compose --env-file .env.production -f compose.production.yml start dyndns +``` + +Zusätzlich `secrets/dyndns_master_key` verschlüsselt und außerhalb dieses Servers sichern. Eine Datenbanksicherung ohne den dazugehörigen Master-Key ist für gespeicherte API-Tokens unbrauchbar. + +## 5. Update + +Nach Veröffentlichung beispielsweise von `v1.1.0`: + +1. Backup wie oben erstellen. +2. In `.env.production` setzen: + + ```dotenv + IMAGE_TAG=v1.1.0 + ``` + +3. Neues Image laden und Container ersetzen: + + ```bash + docker compose --env-file .env.production -f compose.production.yml pull + docker compose --env-file .env.production -f compose.production.yml up -d + docker compose --env-file .env.production -f compose.production.yml ps + docker compose --env-file .env.production -f compose.production.yml logs --tail=100 + curl --fail http://127.0.0.1:8080/healthz + ``` + +Das benannte Volume `domain-control-data` wird dabei nicht ersetzt. + +## 6. Rollback + +Ohne Datenbankmigration reicht es, in `.env.production` den vorherigen `IMAGE_TAG` einzutragen und erneut `pull` sowie `up -d` auszuführen. + +Wenn die neue Version eine Datenbankmigration ausgeführt hat, gemeinsam auf das vorherige Image und die unmittelbar davor erstellte Datenbanksicherung zurückgehen. Automatische, unbeaufsichtigte Updates sind deshalb nicht empfohlen. + +## 7. Nützliche Betriebsbefehle + +```bash +# Status +docker compose --env-file .env.production -f compose.production.yml ps + +# Logs +docker compose --env-file .env.production -f compose.production.yml logs -f --tail=100 + +# Neustart +docker compose --env-file .env.production -f compose.production.yml restart dyndns + +# Bereits heruntergeladene, ungenutzte Images aufräumen +docker image prune +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..c26ff22 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Hetzner DynDNS Dashboard + +Ein schlankes, selbst gehostetes Dashboard für mehrere Hetzner-DNS-Zonen und DynDNS-Ziele. Der Container erkennt seine öffentliche IPv4/IPv6 in einem einstellbaren Rhythmus und aktualisiert ausschließlich aktivierte A-/AAAA-Records über die Hetzner Cloud DNS API. + +## Was es kann + +- Mehrere Hetzner-Domains mit getrennten, verschlüsselten API-Tokens +- Import vorhandener A-/AAAA-Records und Anlegen neuer DynDNS-Hosts +- Reversibles Trennen von DynDNS-Zielen: Hetzner-Records bleiben bestehen und werden im lokalen Archiv angezeigt +- Globale, konfigurierbare IP-Prüfung mit HTTPS-Fallbackquellen +- Live-Countdown bis zum nächsten Scheduler-Lauf und manueller Sofortlauf für Admins +- Beschreibungen lokal und als Hetzner-Record-Kommentar +- Sichere Mehrbenutzerverwaltung: + - `Admin`: vollständige Zonen-, Token-, Updater- und Benutzerverwaltung + - `Mitglied`: ausschließlich explizit freigegebene Domains oder einzelne Subdomains +- SQLite-Datenvolume, Docker Secret für die Token-Verschlüsselung und kein externer Login-Dienst + +## Schnellstart + +1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen: + + ```bash + mkdir -p secrets + openssl rand -base64 32 > secrets/dyndns_master_key + chmod 700 secrets + chmod 644 secrets/dyndns_master_key + cp .env.example .env + ``` + +2. Für LAN-Zugriff in `.env` `BIND_ADDRESS` auf die konkrete private IP des Docker-Hosts setzen, zum Beispiel `192.168.178.10`. Verwende im HTTP-LAN-Modus nie `0.0.0.0`. + + Die Schlüsseldatei benötigt bei Docker Compose Leserechte für den unprivilegierten Containerprozess. Das Verzeichnis selbst bleibt deshalb auf dem Host mit Modus `700` geschützt; im Container läuft nur der nicht-root DynDNS-Prozess. + +3. Starten: + + ```bash + docker compose up -d --build + ``` + +4. Öffne `http://:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token. + +## Produktivbetrieb + +Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut: + +```bash +docker compose --env-file .env.production -f compose.production.yml pull +docker compose --env-file .env.production -f compose.production.yml up -d +``` + +Die vollständige Anleitung für Gitea Actions, HTTPS-Reverse-Proxy, Installation, Backups, Updates und Rollbacks steht in [PRODUCTION.md](PRODUCTION.md). + +## Sicherheit + +- Passwörter werden mit Argon2id gehasht. +- Hetzner-Tokens werden mit XChaCha20-Poly1305 verschlüsselt; der Schlüssel liegt als Docker Secret außerhalb des Datenvolumes. +- Cookies sind `HttpOnly` und `SameSite=Strict`; alle schreibenden Formulare verwenden CSRF-Tokens. +- Token-Anzeige verlangt die erneute Eingabe des Admin-Passworts und setzt `Cache-Control: no-store`. +- Unprivilegierte Nutzer sehen niemals API-Tokens, globale IP-Quellen, Update-Historie anderer Domains oder Benutzerverwaltung. + +`APP_MODE=lan` ist nur für ein isoliertes, vertrauenswürdiges Netz geeignet. Passwort und API-Token sind bei HTTP auf einem fremden oder geteilten Netz nicht gegen Mitlesen geschützt. Für WLAN mit Gästen oder externen Zugriff setze einen HTTPS-Reverse-Proxy vor den Container und verwende `APP_MODE=https`. + +## Berechtigungen + +Ein Admin legt unter **Benutzer** Konten an und entscheidet pro Mitglied: + +- **Ganze Domain**: Zugriff auf alle aktuellen und später importierten/angelegten DynDNS-Ziele dieser Zone. +- **Einzelne Subdomain**: Zugriff nur auf den konkreten Host, etwa `home.example.de`. + +Mitglieder dürfen ihren freigegebenen Host aktivieren/deaktivieren und dessen Beschreibung pflegen. Sie können keine DNS-Records löschen, Domains importieren, API-Tokens einsehen oder globale Einstellungen verändern. + +## Daten und Betrieb + +Die persistente Datenbank liegt im Docker-Volume `dyndns-data`. Sichere für Backups sowohl das Volume als auch das separate Master-Key-Secret; ohne den Master-Key lassen sich gespeicherte Hetzner-Tokens absichtlich nicht wiederherstellen. + +Der Container bietet `/healthz` und `/readyz`. Für eine Aktualisierung ohne Warten auf das Intervall gibt es im Admin-Dashboard **Jetzt aktualisieren**. diff --git a/build/.gitkeep b/build/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go new file mode 100644 index 0000000..c0730a4 --- /dev/null +++ b/cmd/dyndns-web/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(healthcheck()) + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + if err := run(logger); err != nil { + logger.Error("application stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + cipher, err := secure.LoadCipher(cfg.MasterKeyFile) + if err != nil { + return err + } + ctx := context.Background() + database, err := store.Open(ctx, cfg.DatabasePath) + if err != nil { + return err + } + defer database.Close() + if err := database.Prune(ctx); err != nil { + logger.Warn("prune old local data", "error", err) + } + provider := hetzner.New(cfg.HCloudBaseURL) + updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger) + settings, err := database.GetSettings(ctx) + if err != nil { + return err + } + if !settings.IPv6Enabled { + deleted, cleanupErr := updater.DisableIPv6(ctx) + if cleanupErr != nil { + logger.Warn("enforce disabled IPv6", "error", cleanupErr) + } else if deleted > 0 { + logger.Info("removed AAAA records for disabled IPv6", "count", deleted) + } + } + serverUI, err := web.New(cfg, database, cipher, updater, logger) + if err != nil { + return err + } + + appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + updater.Start(appCtx) + httpServer := &http.Server{ + Addr: cfg.Address, + Handler: serverUI.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + serverErrors := make(chan error, 1) + go func() { + logger.Info("starting server", "address", cfg.Address, "mode", cfg.Mode) + serverErrors <- httpServer.ListenAndServe() + }() + select { + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-appCtx.Done(): + logger.Info("shutdown requested") + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + return httpServer.Shutdown(shutdownCtx) + } +} + +func healthcheck() int { + url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL")) + if url == "" { + url = "http://127.0.0.1:8080/healthz" + } + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Get(url) + if err != nil { + return 1 + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return 1 + } + return 0 +} diff --git a/compose.production.yml b/compose.production.yml new file mode 100644 index 0000000..4b76cd6 --- /dev/null +++ b/compose.production.yml @@ -0,0 +1,39 @@ +name: domain-control + +services: + dyndns: + image: ${DYNDNS_IMAGE:-git.j-mahler.de/admin/domain-control-webinterface-prod}:${IMAGE_TAG:-v1.0.0} + restart: unless-stopped + init: true + stop_grace_period: 20s + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080" + environment: + APP_MODE: ${APP_MODE:-https} + APP_ADDRESS: :8080 + APP_DATA_DIR: /data + APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key + volumes: + - dyndns-data:/data + secrets: + - dyndns_master_key + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + +secrets: + dyndns_master_key: + file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key} + +volumes: + dyndns-data: + name: domain-control-data diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e3988a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + dyndns: + build: + context: . + image: local/hetzner-dyndns-dashboard:latest + restart: unless-stopped + ports: + # Use one explicit RFC1918 host address for LAN access, never 0.0.0.0 in HTTP mode. + - "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080" + environment: + APP_MODE: ${APP_MODE:-lan} + APP_ADDRESS: :8080 + APP_DATA_DIR: /data + APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key + volumes: + - dyndns-data:/data + secrets: + - dyndns_master_key + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +secrets: + dyndns_master_key: + file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key} + +volumes: + dyndns-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28de4fa --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface + +go 1.25.0 + +require ( + github.com/mattn/go-sqlite3 v1.14.47 + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 +) + +require ( + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..031f64e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/inspo/hetzner-dyndns b/inspo/hetzner-dyndns deleted file mode 160000 index e89c126..0000000 --- a/inspo/hetzner-dyndns +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e89c1262cc49f65fe1297ada361d2da66dd7ec37 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4db589d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + DefaultIPv4Primary = "https://api.ipify.org" + DefaultIPv4Fallback = "https://ifconfig.co/ip" + DefaultIPv6Primary = "https://api6.ipify.org" + DefaultIPv6Fallback = "https://ifconfig.co/ip" +) + +type Config struct { + Address string + DataDir string + DatabasePath string + MasterKeyFile string + CookieSecure bool + SessionTTL time.Duration + HCloudBaseURL string + Mode string + ShutdownTimeout time.Duration +} + +func Load() (Config, error) { + dataDir := value("APP_DATA_DIR", "/data") + address := value("APP_ADDRESS", ":8080") + if _, _, err := net.SplitHostPort(address); err != nil { + return Config{}, fmt.Errorf("APP_ADDRESS must be host:port: %w", err) + } + + mode := strings.ToLower(value("APP_MODE", "lan")) + if mode != "lan" && mode != "https" { + return Config{}, fmt.Errorf("APP_MODE must be either lan or https") + } + + ttl, err := duration("APP_SESSION_TTL", 8*time.Hour) + if err != nil || ttl < time.Hour || ttl > 7*24*time.Hour { + return Config{}, fmt.Errorf("APP_SESSION_TTL must be between 1h and 168h") + } + shutdownTimeout, err := duration("APP_SHUTDOWN_TIMEOUT", 15*time.Second) + if err != nil { + return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err) + } + + keyFile := os.Getenv("APP_MASTER_KEY_FILE") + if keyFile == "" { + return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)") + } + + cookieSecure := mode == "https" + if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" { + parsed, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + return Config{}, fmt.Errorf("APP_COOKIE_SECURE: %w", parseErr) + } + cookieSecure = parsed + } + + return Config{ + Address: address, + DataDir: dataDir, + DatabasePath: value("APP_DATABASE_PATH", filepath.Join(dataDir, "dyndns.db")), + MasterKeyFile: keyFile, + CookieSecure: cookieSecure, + SessionTTL: ttl, + HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"), + Mode: mode, + ShutdownTimeout: shutdownTimeout, + }, nil +} + +func value(name, fallback string) string { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v + } + return fallback +} + +func duration(name string, fallback time.Duration) (time.Duration, error) { + if raw := strings.TrimSpace(os.Getenv(name)); raw != "" { + return time.ParseDuration(raw) + } + return fallback, nil +} diff --git a/internal/hetzner/client.go b/internal/hetzner/client.go new file mode 100644 index 0000000..432d035 --- /dev/null +++ b/internal/hetzner/client.go @@ -0,0 +1,221 @@ +package hetzner + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Client struct { + baseURL string + http *http.Client +} + +type Zone struct { + ID string + Name string +} + +type Record struct { + Value string `json:"value"` + Comment string `json:"comment,omitempty"` +} + +type RRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL *int `json:"ttl"` + Records []Record `json:"records"` + Protection struct { + Change bool `json:"change"` + } `json:"protection"` +} + +type APIError struct { + Status int + Code string + Message string +} + +func (e *APIError) Error() string { + if e.Code != "" { + return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message) + } + return fmt.Sprintf("Hetzner API %d: %s", e.Status, e.Message) +} + +func New(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) { + var result []Zone + for page := 1; ; page++ { + var response struct { + Zones []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"zones"` + Meta struct { + Pagination struct { + NextPage *int `json:"next_page"` + } `json:"pagination"` + } `json:"meta"` + } + query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"50"}} + if err := c.doJSON(ctx, token, http.MethodGet, "/zones?"+query.Encode(), nil, &response); err != nil { + return nil, err + } + for _, zone := range response.Zones { + result = append(result, Zone{ID: strconv.FormatInt(zone.ID, 10), Name: zone.Name}) + } + if response.Meta.Pagination.NextPage == nil { + break + } + page = *response.Meta.Pagination.NextPage - 1 + } + return result, nil +} + +func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) { + var result []RRSet + for page := 1; ; page++ { + var response struct { + RRSets []RRSet `json:"rrsets"` + Meta struct { + Pagination struct { + NextPage *int `json:"next_page"` + } `json:"pagination"` + } `json:"meta"` + } + query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"100"}} + if err := c.doJSON(ctx, token, http.MethodGet, "/zones/"+url.PathEscape(zoneID)+"/rrsets?"+query.Encode(), nil, &response); err != nil { + return nil, err + } + result = append(result, response.RRSets...) + if response.Meta.Pagination.NextPage == nil { + break + } + page = *response.Meta.Pagination.NextPage - 1 + } + return result, nil +} + +func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordType string, ttl int, records []Record) error { + body := struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + Records []Record `json:"records"` + }{Name: name, Type: recordType, TTL: ttl, Records: records} + return c.doJSON(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body, nil) +} + +func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error { + body := struct { + Records []Record `json:"records"` + }{Records: records} + return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil) +} + +func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error { + body := struct { + TTL int `json:"ttl"` + }{TTL: ttl} + return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil) +} + +func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error { + return c.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil) +} + +func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error { + body := struct { + Records []struct { + Value string `json:"value"` + Comment string `json:"comment"` + } `json:"records"` + }{Records: make([]struct { + Value string `json:"value"` + Comment string `json:"comment"` + }, 0, len(records))} + for _, record := range records { + body.Records = append(body.Records, struct { + Value string `json:"value"` + Comment string `json:"comment"` + }{Value: record.Value, Comment: record.Comment}) + } + return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body, nil) +} + +func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error { + var body io.Reader + if requestBody != nil { + encoded, err := json.Marshal(requestBody) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request Hetzner API: %w", err) + } + defer resp.Body.Close() + content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("read Hetzner API response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return decodeAPIError(resp.StatusCode, content) + } + if responseBody != nil && len(content) > 0 { + if err := json.Unmarshal(content, responseBody); err != nil { + return fmt.Errorf("decode Hetzner API response: %w", err) + } + } + return nil +} + +func rrsetPath(zoneID, name, recordType string) string { + return "/zones/" + url.PathEscape(zoneID) + "/rrsets/" + url.PathEscape(name) + "/" + url.PathEscape(recordType) +} + +func decodeAPIError(status int, content []byte) error { + var response struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(content, &response) + message := strings.TrimSpace(response.Error.Message) + if message == "" { + message = http.StatusText(status) + } + return &APIError{Status: status, Code: response.Error.Code, Message: message} +} diff --git a/internal/hetzner/client_test.go b/internal/hetzner/client_test.go new file mode 100644 index 0000000..bc08c5f --- /dev/null +++ b/internal/hetzner/client_test.go @@ -0,0 +1,148 @@ +package hetzner + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) { + t.Parallel() + type capturedRequest struct { + Method string + Path string + Authorization string + Records []Record + } + captured := make(chan capturedRequest, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Records []Record `json:"records"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + captured <- capturedRequest{ + Method: r.Method, + Path: r.URL.Path, + Authorization: r.Header.Get("Authorization"), + Records: body.Records, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"action":{"status":"running"}}`)) + })) + defer api.Close() + + client := New(api.URL) + err := client.SetRRSetRecords( + context.Background(), + "test-token", + "123", + "home", + "A", + []Record{{Value: "198.51.100.42", Comment: "DynDNS"}}, + ) + if err != nil { + t.Fatalf("set RRSet records: %v", err) + } + request := <-captured + if request.Method != http.MethodPost { + t.Errorf("unexpected method: got %s, want POST", request.Method) + } + if request.Path != "/zones/123/rrsets/home/A/actions/set_records" { + t.Errorf("unexpected path: %s", request.Path) + } + if request.Authorization != "Bearer test-token" { + t.Errorf("unexpected authorization header: %q", request.Authorization) + } + if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" { + t.Errorf("unexpected records payload: %#v", request.Records) + } +} + +func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) { + t.Parallel() + captured := make(chan map[string]any, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body["method"] = r.Method + body["path"] = r.URL.Path + captured <- body + w.WriteHeader(http.StatusCreated) + })) + defer api.Close() + + client := New(api.URL) + if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil { + t.Fatalf("update record comment: %v", err) + } + request := <-captured + if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" { + t.Fatalf("unexpected request: %#v", request) + } + records, ok := request["records"].([]any) + if !ok || len(records) != 1 { + t.Fatalf("unexpected records payload: %#v", request["records"]) + } + record, ok := records[0].(map[string]any) + if !ok { + t.Fatalf("unexpected record payload: %#v", records[0]) + } + comment, exists := record["comment"] + if !exists || comment != "" { + t.Errorf("empty comment must be sent explicitly, got %#v", record) + } +} + +func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) { + t.Parallel() + captured := make(chan map[string]any, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body["method"] = r.Method + body["path"] = r.URL.Path + captured <- body + w.WriteHeader(http.StatusCreated) + })) + defer api.Close() + + client := New(api.URL) + if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil { + t.Fatalf("change RRSet TTL: %v", err) + } + request := <-captured + if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) { + t.Fatalf("unexpected request: %#v", request) + } +} + +func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) { + t.Parallel() + captured := make(chan *http.Request, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured <- r + w.WriteHeader(http.StatusNoContent) + })) + defer api.Close() + + client := New(api.URL) + if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil { + t.Fatalf("delete RRSet: %v", err) + } + request := <-captured + if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" { + t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization")) + } +} diff --git a/internal/ipresolve/resolver.go b/internal/ipresolve/resolver.go new file mode 100644 index 0000000..cd7dd53 --- /dev/null +++ b/internal/ipresolve/resolver.go @@ -0,0 +1,190 @@ +package ipresolve + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" +) + +type Result struct { + IPv4 string + IPv6 string + IPv4Err error + IPv6Err error +} + +type Resolver struct { + resolver *net.Resolver +} + +func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} } + +func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result { + return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true) +} + +func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result { + result := Result{} + result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true) + if ipv6Enabled { + result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false) + } + return result +} + +func (r *Resolver) ValidateEndpoint(ctx context.Context, raw string) error { + _, _, err := r.parseAndResolve(ctx, raw, true) + if err == nil { + return nil + } + _, _, err6 := r.parseAndResolve(ctx, raw, false) + if err6 == nil { + return nil + } + return fmt.Errorf("endpoint is not a reachable public HTTPS host: %w", err) +} + +func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 bool) (string, error) { + if len(endpoints) == 0 { + return "", errors.New("no endpoints configured") + } + var failures []string + for _, endpoint := range endpoints { + value, err := r.resolveOne(ctx, endpoint, ipv4) + if err == nil { + return value, nil + } + failures = append(failures, safeEndpointName(endpoint)+": "+err.Error()) + } + family := "IPv6" + if ipv4 { + family = "IPv4" + } + return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; ")) +} + +func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) { + u, ips, err := r.parseAndResolve(ctx, raw, ipv4) + if err != nil { + return "", err + } + port := u.Port() + if port == "" { + port = "443" + } + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + IdleConnTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()}, + DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) { + var lastErr error + for _, ip := range ips { + conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, lastErr + }, + } + client := &http.Client{ + Transport: transport, + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "text/plain") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1025)) + if err != nil { + return "", err + } + if len(body) > 1024 { + return "", errors.New("response is larger than 1 KiB") + } + address, err := netip.ParseAddr(strings.TrimSpace(string(body))) + if err != nil { + return "", errors.New("response is not a plain IP address") + } + if ipv4 != address.Is4() { + return "", errors.New("response has the wrong IP family") + } + if !publicAddress(address) { + return "", errors.New("response is not a globally routable address") + } + return address.String(), nil +} + +func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil { + return nil, nil, errors.New("invalid URL") + } + if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + return nil, nil, errors.New("only credential-free HTTPS URLs are allowed") + } + if port := u.Port(); port != "" && port != "443" { + return nil, nil, errors.New("only HTTPS port 443 is allowed") + } + if u.RawQuery != "" { + return nil, nil, errors.New("query parameters are not allowed") + } + host := u.Hostname() + if literal, err := netip.ParseAddr(host); err == nil { + if literal.Is4() != ipv4 || !publicAddress(literal) { + return nil, nil, errors.New("host must resolve to a public address of the requested family") + } + return u, []netip.Addr{literal}, nil + } + addresses, err := r.resolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, nil, fmt.Errorf("resolve host: %w", err) + } + filtered := make([]netip.Addr, 0, len(addresses)) + for _, address := range addresses { + if address.Is4() == ipv4 && publicAddress(address) { + filtered = append(filtered, address) + } + } + if len(filtered) == 0 { + return nil, nil, errors.New("host has no public address of the requested family") + } + return u, filtered, nil +} + +func publicAddress(address netip.Addr) bool { + return address.IsValid() && address.IsGlobalUnicast() && !address.IsPrivate() && !address.IsLoopback() && !address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() && !address.IsUnspecified() && !address.IsMulticast() +} + +func safeEndpointName(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return "configured endpoint" + } + return u.Hostname() +} diff --git a/internal/ipresolve/resolver_test.go b/internal/ipresolve/resolver_test.go new file mode 100644 index 0000000..321668d --- /dev/null +++ b/internal/ipresolve/resolver_test.go @@ -0,0 +1,34 @@ +package ipresolve + +import ( + "context" + "net/netip" + "testing" +) + +func TestPublicAddress(t *testing.T) { + t.Parallel() + for _, raw := range []string{"1.1.1.1", "2001:4860:4860::8888"} { + address := netip.MustParseAddr(raw) + if !publicAddress(address) { + t.Fatalf("expected %s to be public", raw) + } + } + for _, raw := range []string{"127.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fe80::1"} { + address := netip.MustParseAddr(raw) + if publicAddress(address) { + t.Fatalf("expected %s to be rejected", raw) + } + } +} + +func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) { + t.Parallel() + result := New().ResolveWithIPv6(context.Background(), nil, nil, false) + if result.IPv4Err == nil { + t.Fatal("missing IPv4 endpoints should still be reported") + } + if result.IPv6 != "" || result.IPv6Err != nil { + t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..58fa1c8 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,137 @@ +package model + +import "time" + +// Settings are global to the single-admin installation. +type Settings struct { + UpdateIntervalSeconds int + UpdaterEnabled bool + IPv6Enabled bool + IPv4Endpoints []string + IPv6Endpoints []string + LastIPv4 string + LastIPv6 string + LastRunAt time.Time + LastRunStatus string + UpdatedAt time.Time +} + +// Credential is a reusable, encrypted Hetzner project token. +type Credential struct { + ID int64 + Label string + Ciphertext []byte + Nonce []byte + Fingerprint string + CreatedAt time.Time +} + +// Zone is one Hetzner DNS zone selected by the administrator. +type Zone struct { + ID int64 + CredentialID int64 + ProviderID string + Name string + CreatedAt time.Time + LastSyncAt time.Time + LastError string +} + +// Target is the desired state for one host name and its dynamic address families. +type Target struct { + ID int64 + ZoneID int64 + RecordName string + FQDN string + UpdateIPv4 bool + UpdateIPv6 bool + Active bool + StaticIPv4 string + StaticIPv6 string + Description string + TTL int + LastUpdatedAt time.Time + LastStatus string + LastError string + CreatedAt time.Time + ArchivedAt time.Time +} + +// CNAMERecord is a static DNS alias managed directly in a zone. It is kept +// separate from Target because the DynDNS worker must never overwrite it. +type CNAMERecord struct { + ID int64 + ZoneID int64 + Active bool + RecordName string + FQDN string + Destination string + Description string + TTL int + CreatedAt time.Time + UpdatedAt time.Time +} + +type User struct { + ID int64 + Username string + DisplayName string + Role string + Active bool + CreatedAt time.Time + UpdatedAt time.Time + LastLoginAt time.Time +} + +func (u User) IsAdmin() bool { return u.Role == "admin" } + +type AccessGrant struct { + ID int64 + UserID int64 + ZoneID int64 + TargetID *int64 + CanManageTargets bool + CanCreateTargets bool +} + +// Session deliberately contains only hashes/random CSRF material, never a password. +type Session struct { + ID int64 + UserID int64 + Username string + DisplayName string + Role string + TokenHash string + CSRFToken string + CreatedAt time.Time + ExpiresAt time.Time + ReauthUntil time.Time +} + +type AuditEvent struct { + ID int64 + CreatedAt time.Time + Kind string + Message string +} + +type UpdateRun struct { + ID int64 + StartedAt time.Time + FinishedAt time.Time + Status string + IPv4 string + IPv6 string + Summary string +} + +type Dashboard struct { + Settings Settings + Zones []Zone + ActiveTargets int + RecentRuns []UpdateRun + RecentEvents []AuditEvent + NextRunAt time.Time + SchedulerRunning bool + ErrorCount int +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..5fd751a --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,830 @@ +package scheduler + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync/atomic" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" +) + +var ( + ErrRunInProgress = errors.New("an update run is already in progress") + ErrDisabled = errors.New("the updater is disabled") +) + +type Scheduler struct { + store *store.Store + provider *hetzner.Client + resolver *ipresolve.Resolver + cipher *secure.Cipher + logger *slog.Logger + running atomic.Bool + nextRun atomic.Int64 + wake chan struct{} +} + +func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler { + return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)} +} + +func (s *Scheduler) Start(ctx context.Context) { + go func() { + defer s.nextRun.Store(0) + for { + settings, err := s.store.GetSettings(ctx) + if err != nil { + s.logger.Error("read scheduler settings", "error", err) + s.nextRun.Store(0) + timer := time.NewTimer(time.Minute) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-s.wake: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + } + continue + } + if settings.UpdaterEnabled { + if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) { + s.logger.Warn("scheduled update run failed", "error", runErr) + } + } else { + s.nextRun.Store(0) + select { + case <-ctx.Done(): + return + case <-s.wake: + } + continue + } + wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second + if wait < time.Minute { + wait = time.Minute + } + s.nextRun.Store(time.Now().UTC().Add(wait).Unix()) + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-s.wake: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + } + } + }() +} + +func (s *Scheduler) Trigger() { + s.nextRun.Store(0) + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *Scheduler) IsRunning() bool { return s.running.Load() } + +func (s *Scheduler) NextRunAt() time.Time { + value := s.nextRun.Load() + if value == 0 { + return time.Time{} + } + return time.Unix(value, 0).UTC() +} + +func (s *Scheduler) Run(ctx context.Context) (string, error) { + if !s.running.CompareAndSwap(false, true) { + return "", ErrRunInProgress + } + defer s.running.Store(false) + + settings, err := s.store.GetSettings(ctx) + if err != nil { + return "", err + } + if !settings.UpdaterEnabled { + return "", ErrDisabled + } + runID, err := s.store.CreateUpdateRun(ctx) + if err != nil { + return "", err + } + ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled) + if ipResult.IPv4 == "" && ipResult.IPv6 == "" { + message := "Keine öffentliche IP ermittelt" + if ipResult.IPv4Err != nil { + message += ": " + ipResult.IPv4Err.Error() + } + return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message)) + } + + targets, err := s.store.ListManagedTargets(ctx) + if err != nil { + return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err) + } + if len(targets) == 0 { + summary := "Keine aktiven DynDNS-Ziele" + return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil) + } + + grouped := make(map[int64][]store.ManagedTarget) + for _, target := range targets { + grouped[target.Zone.ID] = append(grouped[target.Zone.ID], target) + } + updated, failed := 0, 0 + for _, group := range grouped { + if len(group) == 0 { + continue + } + if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil { + failed += len(group) + s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err) + for _, target := range group { + _ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false) + } + _ = s.store.MarkZoneSynced(ctx, group[0].Zone.ID, safeError(err)) + } + } + status := "ok" + if failed > 0 { + status = "partial" + } + summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed) + if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) { + summary += "; eine IP-Familie war nicht verfügbar" + if status == "ok" { + status = "partial" + } + } + return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil) +} + +func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) { + _ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary) + _ = s.store.UpdateRunState(ctx, ipv4, ipv6, status) + if status != "ok" { + _ = s.store.AddAudit(ctx, "update."+status, summary) + } + return summary, runErr +} + +func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.ManagedTarget, ipv4, ipv6 string, ipv6Enabled bool, updated, failed *int) error { + zone, credential := targets[0].Zone, targets[0].Credential + token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce) + if err != nil { + return err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return err + } + byKey := make(map[string]hetzner.RRSet, len(rrsets)) + for _, rrset := range rrsets { + if rrset.Type == "A" || rrset.Type == "AAAA" { + byKey[rrKey(rrset.Name, rrset.Type)] = rrset + } + } + for _, item := range targets { + targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled) + if targetErr != nil { + *failed = *failed + 1 + _ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false) + continue + } + if targetUpdated { + *updated = *updated + 1 + } + _ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated) + } + return s.store.MarkZoneSynced(ctx, zone.ID, "") +} + +func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) { + var states []string + updated := false + if target.UpdateIPv4 { + changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4) + if err != nil { + return false, "", err + } + updated = updated || changed + states = append(states, state) + } + if target.UpdateIPv6 { + if !ipv6Enabled { + states = append(states, "AAAA global deaktiviert") + } else { + changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6) + if err != nil { + return false, "", err + } + updated = updated || changed + states = append(states, state) + } + } + if len(states) == 0 { + return false, "Deaktivierte IP-Familien", nil + } + return updated, strings.Join(states, " · "), nil +} + +func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) { + if ip == "" { + return false, recordType + " nicht verfügbar", nil + } + if rrset.Name == "" { + ttl := target.TTL + if ttl < 60 { + ttl = 60 + } + if err := s.provider.CreateRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil { + return false, "", err + } + return true, recordType + " angelegt", nil + } + if rrset.Protection.Change { + return false, "", errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt") + } + if len(rrset.Records) != 1 { + return false, "", fmt.Errorf("%s enthält %d Records und wird aus Sicherheitsgründen nicht geändert", rrset.Name, len(rrset.Records)) + } + ttlChanged := false + if rrset.TTL == nil || *rrset.TTL != targetTTL(target.TTL) { + if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, targetTTL(target.TTL)); err != nil { + return false, "", err + } + ttlChanged = true + } + current := rrset.Records[0] + if current.Value == ip { + if current.Comment != target.Description { + if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil { + return false, "", err + } + status := recordType + " Kommentar synchronisiert" + if ttlChanged { + status += " · TTL aktualisiert" + } + return true, status, nil + } + if ttlChanged { + return true, recordType + " TTL aktualisiert", nil + } + return false, recordType + " unverändert", nil + } + if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil { + return false, "", err + } + status := recordType + " aktualisiert" + if ttlChanged { + status += " · TTL aktualisiert" + } + return true, status, nil +} + +func (s *Scheduler) SyncZone(ctx context.Context, zoneID int64) (int, error) { + zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID) + if err != nil { + return 0, err + } + token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce) + if err != nil { + return 0, err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + _ = s.store.MarkZoneSynced(ctx, zone.ID, safeError(err)) + return 0, err + } + type imported struct { + ipv4, ipv6 bool + description string + ttl int + } + grouped := map[string]*imported{} + var cnames []model.CNAMERecord + for _, rrset := range rrsets { + if rrset.Type == "CNAME" { + if len(rrset.Records) != 1 { + continue + } + ttl := targetTTL(60) + if rrset.TTL != nil { + ttl = targetTTL(*rrset.TTL) + } + cnames = append(cnames, model.CNAMERecord{ + ZoneID: zone.ID, + Active: true, + RecordName: normalRecordName(rrset.Name), + FQDN: fqdn(rrset.Name, zone.Name), + Destination: rrset.Records[0].Value, + Description: rrset.Records[0].Comment, + TTL: ttl, + }) + continue + } + if rrset.Type != "A" && rrset.Type != "AAAA" { + continue + } + entry := grouped[rrset.Name] + if entry == nil { + entry = &imported{ttl: 60} + grouped[rrset.Name] = entry + } + if rrset.Type == "A" { + entry.ipv4 = true + } else { + entry.ipv6 = true + } + if rrset.TTL != nil && *rrset.TTL >= 60 { + entry.ttl = *rrset.TTL + } + if entry.description == "" && len(rrset.Records) > 0 { + entry.description = rrset.Records[0].Comment + } + } + added := 0 + for name, entry := range grouped { + target := model.Target{ZoneID: zone.ID, RecordName: normalRecordName(name), FQDN: fqdn(name, zone.Name), UpdateIPv4: entry.ipv4, UpdateIPv6: entry.ipv6, Description: entry.description, TTL: entry.ttl} + isNew, err := s.store.UpsertImportedTarget(ctx, target) + if err != nil { + return added, err + } + if isNew { + added++ + continue + } + if err := s.store.UpdateImportedTargetTTL(ctx, zone.ID, target.RecordName, target.TTL); err != nil { + return added, err + } + } + if err := s.store.ReplaceCNAMERecords(ctx, zone.ID, cnames); err != nil { + return 0, err + } + _ = s.store.MarkZoneSynced(ctx, zone.ID, "") + _ = s.store.AddAudit(ctx, "zone.import", fmt.Sprintf("%s: %d neue A/AAAA-Ziele importiert", zone.Name, added)) + return added, nil +} + +func (s *Scheduler) SyncTargetComment(ctx context.Context, target model.Target) error { + zone, credential, err := s.store.GetZoneWithCredential(ctx, target.ZoneID) + if err != nil { + return err + } + token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce) + if err != nil { + return err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return err + } + for _, rrset := range rrsets { + if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") { + continue + } + if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || len(rrset.Records) != 1 { + continue + } + if rrset.Records[0].Comment != target.Description { + if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, rrset.Type, []hetzner.Record{{Value: rrset.Records[0].Value, Comment: target.Description}}); err != nil { + return err + } + } + } + return nil +} + +// SyncTargetTTL applies a changed TTL immediately when the selected A/AAAA +// RRsets already exist. Missing RRsets will receive the same TTL when the +// DynDNS worker creates them on its next run. +func (s *Scheduler) SyncTargetTTL(ctx context.Context, target model.Target) error { + zone, token, err := s.zoneToken(ctx, target.ZoneID) + if err != nil { + return err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return err + } + ttl := targetTTL(target.TTL) + var recordTypes []string + for _, rrset := range rrsets { + if rrset.Name != target.RecordName { + continue + } + if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || (rrset.Type != "A" && rrset.Type != "AAAA") { + continue + } + if rrset.TTL != nil && *rrset.TTL == ttl { + continue + } + if rrset.Protection.Change { + return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt") + } + recordTypes = append(recordTypes, rrset.Type) + } + for _, recordType := range recordTypes { + if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl); err != nil { + return err + } + } + return nil +} + +// SyncStaticTarget writes manually entered addresses while DynDNS is disabled. +// It deliberately reuses the guarded RRSet update path used by the updater: +// protected or multi-record RRSets are left untouched. +func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (bool, string, error) { + zone, token, err := s.zoneToken(ctx, target.ZoneID) + if err != nil { + return false, "", err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return false, "", err + } + byKey := make(map[string]hetzner.RRSet, len(rrsets)) + for _, rrset := range rrsets { + if rrset.Type == "A" || rrset.Type == "AAAA" { + byKey[rrKey(rrset.Name, rrset.Type)] = rrset + } + } + updated := false + var states []string + if target.UpdateIPv4 && target.StaticIPv4 != "" { + changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4) + if err != nil { + return false, "", err + } + updated = updated || changed + states = append(states, state) + } + if target.UpdateIPv6 && target.StaticIPv6 != "" { + changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6) + if err != nil { + return false, "", err + } + updated = updated || changed + states = append(states, state) + } + if len(states) == 0 { + return false, "Keine feste IP eingetragen", nil + } + return updated, strings.Join(states, " · "), nil +} + +// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local +// target itself. This lets users temporarily disable every family without +// losing its configuration. +func (s *Scheduler) DeleteTargetFamilies(ctx context.Context, target model.Target, deleteIPv4, deleteIPv6 bool) (int, error) { + if !deleteIPv4 && !deleteIPv6 { + return 0, nil + } + zone, token, err := s.zoneToken(ctx, target.ZoneID) + if err != nil { + return 0, err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return 0, err + } + var recordTypes []string + for _, rrset := range rrsets { + if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") { + continue + } + if (rrset.Type == "A" && !deleteIPv4) || (rrset.Type == "AAAA" && !deleteIPv6) { + continue + } + if rrset.Protection.Change { + return 0, errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt") + } + recordTypes = append(recordTypes, rrset.Type) + } + for _, recordType := range recordTypes { + if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType); err != nil { + return 0, err + } + } + return len(recordTypes), nil +} + +// DeleteZoneRecords removes every A, AAAA and CNAME RRSet represented by the +// supplied local records. Other provider records in the zone remain untouched. +// Protected RRsets are rejected before the first remote deletion. +func (s *Scheduler) DeleteZoneRecords(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord) (int, error) { + zone, token, err := s.zoneToken(ctx, zoneID) + if err != nil { + return 0, err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return 0, err + } + managed := make(map[string]struct{}, len(targets)*2+len(cnames)) + for _, target := range targets { + managed[rrKey(target.RecordName, "A")] = struct{}{} + managed[rrKey(target.RecordName, "AAAA")] = struct{}{} + } + for _, record := range cnames { + managed[rrKey(record.RecordName, "CNAME")] = struct{}{} + } + deletions := make([]hetzner.RRSet, 0, len(managed)) + for _, rrset := range rrsets { + if _, found := managed[rrKey(rrset.Name, rrset.Type)]; !found { + continue + } + if rrset.Protection.Change { + return 0, fmt.Errorf("das RRset %s (%s) ist bei Hetzner gegen Änderungen geschützt", fqdn(rrset.Name, zone.Name), rrset.Type) + } + deletions = append(deletions, rrset) + } + deleted := 0 + for _, rrset := range deletions { + if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, rrset.Name, rrset.Type); err != nil { + return deleted, err + } + deleted++ + } + return deleted, nil +} + +// DeleteIPv6Targets removes all managed AAAA RRSets before global IPv6 is +// disabled. All protected RRSets are detected before the first deletion. +func (s *Scheduler) DeleteIPv6Targets(ctx context.Context, targets []model.Target) (int, error) { + type zoneState struct { + zone model.Zone + token string + rrsets map[string]hetzner.RRSet + } + type deletion struct { + zone model.Zone + token string + name string + } + states := make(map[int64]zoneState) + deletions := make([]deletion, 0, len(targets)) + for _, target := range targets { + state, found := states[target.ZoneID] + if !found { + zone, token, err := s.zoneToken(ctx, target.ZoneID) + if err != nil { + return 0, err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return 0, err + } + state = zoneState{zone: zone, token: token, rrsets: make(map[string]hetzner.RRSet, len(rrsets))} + for _, rrset := range rrsets { + state.rrsets[rrKey(rrset.Name, rrset.Type)] = rrset + } + states[target.ZoneID] = state + } + rrset, exists := state.rrsets[rrKey(target.RecordName, "AAAA")] + if !exists { + continue + } + if rrset.Protection.Change { + return 0, errors.New("mindestens ein AAAA-Record ist bei Hetzner gegen Änderungen geschützt") + } + deletions = append(deletions, deletion{zone: state.zone, token: state.token, name: target.RecordName}) + } + for _, item := range deletions { + if err := s.provider.DeleteRRSet(ctx, item.token, item.zone.ProviderID, item.name, "AAAA"); err != nil { + return 0, err + } + } + return len(deletions), nil +} + +// DisableIPv6 removes managed AAAA RRSets and disables IPv6 on every linked +// local target. It is safe to run when IPv6 is already disabled. +func (s *Scheduler) DisableIPv6(ctx context.Context) (int, error) { + targets, err := s.store.ListTargetsWithIPv6(ctx) + if err != nil { + return 0, err + } + deleted, err := s.DeleteIPv6Targets(ctx, targets) + if err != nil { + return 0, err + } + if err := s.store.DisableIPv6ForTargets(ctx); err != nil { + return 0, err + } + return deleted, nil +} + +// SyncZoneTTL updates the TTL of every RRSet this application manages in a +// zone. It preflights protected or stale CNAME RRSets before changing any +// remote value, while missing DynDNS RRSets keep the new TTL locally for their +// eventual creation. +func (s *Scheduler) SyncZoneTTL(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord, ttl int) (int, error) { + zone, token, err := s.zoneToken(ctx, zoneID) + if err != nil { + return 0, err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return 0, err + } + byKey := make(map[string]hetzner.RRSet, len(rrsets)) + for _, rrset := range rrsets { + byKey[rrKey(rrset.Name, rrset.Type)] = rrset + } + type change struct { + name string + recordType string + } + changes := make([]change, 0, len(targets)*2+len(cnames)) + seen := make(map[string]bool, cap(changes)) + queue := func(name, recordType string, required bool) error { + key := rrKey(name, recordType) + rrset, found := byKey[key] + if !found { + if required { + return fmt.Errorf("der CNAME %s wurde bei Hetzner nicht gefunden; lies die Zone erneut ein", fqdn(name, zone.Name)) + } + return nil + } + if rrset.TTL != nil && *rrset.TTL == targetTTL(ttl) { + return nil + } + if rrset.Protection.Change { + return errors.New("mindestens ein RRset ist bei Hetzner gegen Änderungen geschützt") + } + if !seen[key] { + seen[key] = true + changes = append(changes, change{name: name, recordType: recordType}) + } + return nil + } + for _, target := range targets { + if target.UpdateIPv4 { + if err := queue(target.RecordName, "A", false); err != nil { + return 0, err + } + } + if target.UpdateIPv6 { + if err := queue(target.RecordName, "AAAA", false); err != nil { + return 0, err + } + } + } + for _, record := range cnames { + if !record.Active { + continue + } + if err := queue(record.RecordName, "CNAME", true); err != nil { + return 0, err + } + } + for _, item := range changes { + if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, item.name, item.recordType, targetTTL(ttl)); err != nil { + return 0, err + } + } + return len(changes), nil +} + +func (s *Scheduler) CreateCNAME(ctx context.Context, record model.CNAMERecord) error { + zone, token, err := s.zoneToken(ctx, record.ZoneID) + if err != nil { + return err + } + return s.provider.CreateRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL), []hetzner.Record{{Value: record.Destination, Comment: record.Description}}) +} + +func (s *Scheduler) UpdateCNAME(ctx context.Context, previous, record model.CNAMERecord) error { + if previous.ZoneID != record.ZoneID || previous.RecordName != record.RecordName { + return errors.New("der Name eines CNAME-Records kann nicht geändert werden") + } + if previous.Destination == record.Destination && previous.Description == record.Description && previous.TTL == record.TTL { + return nil + } + zone, token, err := s.zoneToken(ctx, record.ZoneID) + if err != nil { + return err + } + rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID) + if err != nil { + return err + } + var current *hetzner.RRSet + for index := range rrsets { + if rrsets[index].Name == record.RecordName && rrsets[index].Type == "CNAME" { + current = &rrsets[index] + break + } + } + if current == nil { + return errors.New("der CNAME-Record wurde bei Hetzner nicht gefunden; lies die Zone erneut ein") + } + if current.Protection.Change { + return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt") + } + if previous.TTL != record.TTL { + if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL)); err != nil { + return err + } + } + if previous.Destination != record.Destination || previous.Description != record.Description { + if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, record.RecordName, "CNAME", []hetzner.Record{{Value: record.Destination, Comment: record.Description}}); err != nil { + return err + } + } + return nil +} + +func (s *Scheduler) DeleteCNAME(ctx context.Context, record model.CNAMERecord) error { + zone, token, err := s.zoneToken(ctx, record.ZoneID) + if err != nil { + return err + } + return s.provider.DeleteRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME") +} + +func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, string, error) { + zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID) + if err != nil { + return model.Zone{}, "", err + } + token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce) + if err != nil { + return model.Zone{}, "", err + } + return zone, token, nil +} + +func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error { + for _, endpoint := range endpoints { + if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil { + return fmt.Errorf("%s: %w", endpoint, err) + } + } + return nil +} + +func (s *Scheduler) ValidateToken(ctx context.Context, token string) ([]hetzner.Zone, error) { + return s.provider.ListZones(ctx, token) +} + +func (s *Scheduler) Provider() *hetzner.Client { return s.provider } + +func rrKey(name, recordType string) string { return normalRecordName(name) + "|" + recordType } + +func targetTTL(ttl int) int { + if ttl < 60 { + return 60 + } + return ttl +} + +func normalRecordName(name string) string { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + if name == "" || name == "@" { + return "@" + } + return name +} + +func fqdn(name, zone string) string { + if normalRecordName(name) == "@" { + return zone + } + return normalRecordName(name) + "." + zone +} + +func safeError(err error) string { + if err == nil { + return "" + } + message := err.Error() + if len(message) > 300 { + return message[:300] + } + return message +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..bfd18ff --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,538 @@ +package scheduler + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" +) + +func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) { + t.Parallel() + ctx := context.Background() + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/zones/123/rrsets" || r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unexpected request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "rrsets": [ + {"name":"@","type":"A","ttl":60,"records":[{"value":"198.51.100.10","comment":"Apex"}]}, + {"name":"home","type":"A","ttl":120,"records":[{"value":"198.51.100.11","comment":"Router"}]}, + {"name":"home","type":"AAAA","ttl":120,"records":[{"value":"2001:db8::10","comment":"Router"}]}, + {"name":"www","type":"CNAME","ttl":300,"records":[{"value":"example.de."}]} + ], + "meta":{"pagination":{"next_page":null}} + }`)) + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + added, err := schedule.SyncZone(ctx, zone.ID) + if err != nil || added != 2 { + t.Fatalf("expected apex and home to be imported, added=%d err=%v", added, err) + } + targets, err := database.ListTargets(ctx, zone.ID) + if err != nil || len(targets) != 2 { + t.Fatalf("expected two address targets, got %#v err=%v", targets, err) + } + var homeID int64 + for _, target := range targets { + if target.RecordName == "home" { + homeID = target.ID + if !target.UpdateIPv4 || !target.UpdateIPv6 || target.Active || target.Description != "Router" || target.TTL != 120 { + t.Fatalf("home target was not grouped safely: %#v", target) + } + } + } + if homeID == 0 { + t.Fatal("home target missing") + } + cnames, err := database.ListCNAMERecords(ctx, zone.ID) + if err != nil || len(cnames) != 1 || !cnames[0].Active || cnames[0].RecordName != "www" || cnames[0].Destination != "example.de." || cnames[0].TTL != 300 { + t.Fatalf("CNAME was not imported: %#v err=%v", cnames, err) + } + home, err := database.GetTarget(ctx, homeID) + if err != nil { + t.Fatal(err) + } + home.TTL = 60 + if err := database.UpdateTarget(ctx, home); err != nil { + t.Fatal(err) + } + added, err = schedule.SyncZone(ctx, zone.ID) + if err != nil || added != 0 { + t.Fatalf("TTL refresh import failed: added=%d err=%v", added, err) + } + home, err = database.GetTarget(ctx, homeID) + if err != nil || home.TTL != 120 { + t.Fatalf("provider TTL was not refreshed on existing target: %#v err=%v", home, err) + } + if err := database.ArchiveTarget(ctx, homeID); err != nil { + t.Fatal(err) + } + added, err = schedule.SyncZone(ctx, zone.ID) + if err != nil || added != 0 { + t.Fatalf("re-import should not restore archived target, added=%d err=%v", added, err) + } + linked, _ := database.ListTargets(ctx, zone.ID) + archived, _ := database.ListArchivedTargets(ctx, zone.ID) + if len(linked) != 1 || len(archived) != 1 || archived[0].ID != homeID { + t.Fatalf("archive state changed during provider import: linked=%#v archived=%#v", linked, archived) + } +} + +func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + settings, err := database.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(ctx, settings.UpdateIntervalSeconds, false, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { + t.Fatal(err) + } + + schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil))) + if _, err := schedule.Run(ctx); !errors.Is(err, ErrDisabled) { + t.Fatalf("disabled updater accepted a run: %v", err) + } + runs, err := database.RecentRuns(ctx, 10) + if err != nil || len(runs) != 0 { + t.Fatalf("disabled run must not create history: %#v err=%v", runs, err) + } + + schedule.nextRun.Store(time.Now().Add(time.Hour).Unix()) + schedule.Trigger() + if !schedule.NextRunAt().IsZero() { + t.Fatalf("trigger did not reset the visible countdown: %v", schedule.NextRunAt()) + } + select { + case <-schedule.wake: + default: + t.Fatal("trigger did not wake the scheduler") + } +} + +func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) { + t.Parallel() + schedule := &Scheduler{} + changed, status, err := schedule.syncTarget( + context.Background(), + "unused-token", + model.Zone{ProviderID: "123"}, + model.Target{RecordName: "home", UpdateIPv6: true}, + nil, + "", + "", + false, + ) + if err != nil || changed || status != "AAAA global deaktiviert" { + t.Fatalf("disabled IPv6 was not skipped cleanly: changed=%v status=%q err=%v", changed, status, err) + } +} + +func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) { + t.Parallel() + ctx := context.Background() + changes := make(chan int, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones/123/rrsets": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}],"meta":{"pagination":{"next_page":null}}}`)) + case "/zones/123/rrsets/home/A/actions/change_ttl": + var body struct { + TTL int `json:"ttl"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + changes <- body.TTL + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err := schedule.SyncTargetTTL(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, TTL: 300}); err != nil { + t.Fatalf("sync target TTL: %v", err) + } + select { + case ttl := <-changes: + if ttl != 300 { + t.Fatalf("unexpected changed TTL: %d", ttl) + } + default: + t.Fatal("TTL action was not called") + } +} + +func TestSyncStaticTargetWritesManualAddress(t *testing.T) { + t.Parallel() + ctx := context.Background() + actions := make(chan string, 2) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones/123/rrsets": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11","comment":"Old"}]}],"meta":{"pagination":{"next_page":null}}}`)) + case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records": + actions <- r.URL.Path + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + changed, status, err := schedule.SyncStaticTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, StaticIPv4: "203.0.113.25", Description: "Manual", TTL: 300}) + if err != nil || !changed || status != "A aktualisiert · TTL aktualisiert" { + t.Fatalf("static target was not synchronized: changed=%v status=%q err=%v", changed, status, err) + } + seen := map[string]bool{} + for range 2 { + seen[<-actions] = true + } + for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records"} { + if !seen[expected] { + t.Errorf("missing static update action %s", expected) + } + } +} + +func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) { + t.Parallel() + ctx := context.Background() + actions := make(chan string, 2) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones/123/rrsets": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}],"meta":{"pagination":{"next_page":null}}}`)) + case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl": + actions <- r.URL.Path + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + changed, err := schedule.SyncZoneTTL(ctx, zone.ID, []model.Target{{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true}}, []model.CNAMERecord{{ZoneID: zone.ID, Active: true, RecordName: "www"}}, 600) + if err != nil || changed != 2 { + t.Fatalf("zone TTL was not synchronized: changed=%d err=%v", changed, err) + } + seen := map[string]bool{} + for range 2 { + seen[<-actions] = true + } + for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl"} { + if !seen[expected] { + t.Errorf("missing bulk TTL action %s", expected) + } + } +} + +func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) { + t.Parallel() + ctx := context.Background() + deletions := make(chan string, 2) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones/123/rrsets": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}],"meta":{"pagination":{"next_page":null}}}`)) + case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA": + if r.Method != http.MethodDelete { + http.Error(w, "unexpected method", http.StatusBadRequest) + return + } + deletions <- r.URL.Path + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + target := model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, UpdateIPv6: true} + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + deleted, err := schedule.DeleteTargetFamilies(ctx, target, true, false) + if err != nil || deleted != 1 { + t.Fatalf("A record was not deleted: deleted=%d err=%v", deleted, err) + } + deleted, err = schedule.DeleteIPv6Targets(ctx, []model.Target{target}) + if err != nil || deleted != 1 { + t.Fatalf("global AAAA cleanup failed: deleted=%d err=%v", deleted, err) + } + seen := map[string]bool{} + for range 2 { + seen[<-deletions] = true + } + for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA"} { + if !seen[expected] { + t.Errorf("missing delete request %s", expected) + } + } +} + +func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) { + t.Parallel() + ctx := context.Background() + deletions := make(chan string, 4) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zones/123/rrsets": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rrsets":[ + {"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}, + {"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}, + {"name":"legacy","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::12"}]}, + {"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}, + {"name":"mail","type":"CNAME","ttl":300,"records":[{"value":"external.example."}]}, + {"name":"@","type":"TXT","ttl":300,"records":[{"value":"unmanaged"}]} + ],"meta":{"pagination":{"next_page":null}}}`)) + case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME": + if r.Method != http.MethodDelete { + http.Error(w, "unexpected method", http.StatusBadRequest) + return + } + deletions <- r.URL.Path + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer api.Close() + + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(dir, "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("test-token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de") + if err != nil { + t.Fatal(err) + } + + schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil))) + deleted, err := schedule.DeleteZoneRecords( + ctx, + zone.ID, + []model.Target{{RecordName: "home"}, {RecordName: "legacy"}}, + []model.CNAMERecord{{RecordName: "www"}}, + ) + if err != nil || deleted != 4 { + t.Fatalf("managed zone records were not deleted: deleted=%d err=%v", deleted, err) + } + seen := map[string]bool{} + for range 4 { + seen[<-deletions] = true + } + for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME"} { + if !seen[expected] { + t.Errorf("missing delete request %s", expected) + } + } +} diff --git a/internal/secure/secure.go b/internal/secure/secure.go new file mode 100644 index 0000000..8ad8fae --- /dev/null +++ b/internal/secure/secure.go @@ -0,0 +1,122 @@ +package secure + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + argonMemory = 64 * 1024 + argonTime = 3 + argonThreads = 2 + argonKeyLen = 32 +) + +type Cipher struct { + key []byte +} + +func LoadCipher(path string) (*Cipher, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read master key: %w", err) + } + raw = []byte(strings.TrimSpace(string(raw))) + key, err := base64.RawStdEncoding.DecodeString(string(raw)) + if err != nil { + key, err = base64.StdEncoding.DecodeString(string(raw)) + } + if err != nil || len(key) != chacha20poly1305.KeySize { + return nil, errors.New("master key must be a base64-encoded 32-byte value") + } + return &Cipher{key: key}, nil +} + +func (c *Cipher) Encrypt(plaintext string) (ciphertext, nonce []byte, err error) { + aead, err := chacha20poly1305.NewX(c.key) + if err != nil { + return nil, nil, err + } + nonce = make([]byte, chacha20poly1305.NonceSizeX) + if _, err = rand.Read(nonce); err != nil { + return nil, nil, fmt.Errorf("create encryption nonce: %w", err) + } + return aead.Seal(nil, nonce, []byte(plaintext), nil), nonce, nil +} + +func (c *Cipher) Decrypt(ciphertext, nonce []byte) (string, error) { + aead, err := chacha20poly1305.NewX(c.key) + if err != nil { + return "", err + } + plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", errors.New("could not decrypt stored credential") + } + return string(plaintext), nil +} + +func (c *Cipher) Fingerprint(value string) string { + mac := hmac.New(sha256.New, c.key) + _, _ = mac.Write([]byte(value)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func RandomToken(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func HashPassword(password string) (string, error) { + if len(password) < 12 { + return "", errors.New("password must contain at least 12 characters") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" { + return false + } + var memory uint32 + var iterations uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} diff --git a/internal/secure/secure_test.go b/internal/secure/secure_test.go new file mode 100644 index 0000000..4dd8739 --- /dev/null +++ b/internal/secure/secure_test.go @@ -0,0 +1,59 @@ +package secure + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestCipherRoundTripAndFingerprint(t *testing.T) { + t.Parallel() + dir := t.TempDir() + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + path := filepath.Join(dir, "master-key") + if err := os.WriteFile(path, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := LoadCipher(path) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("hetzner-token-value") + if err != nil { + t.Fatal(err) + } + if string(ciphertext) == "hetzner-token-value" { + t.Fatal("token was not encrypted") + } + plaintext, err := cipher.Decrypt(ciphertext, nonce) + if err != nil { + t.Fatal(err) + } + if plaintext != "hetzner-token-value" { + t.Fatalf("got %q", plaintext) + } + if cipher.Fingerprint("same") != cipher.Fingerprint("same") || cipher.Fingerprint("same") == cipher.Fingerprint("different") { + t.Fatal("fingerprint must be deterministic and token-specific") + } +} + +func TestPasswordHash(t *testing.T) { + t.Parallel() + hash, err := HashPassword("a secure password") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(hash, "a secure password") { + t.Fatal("expected password verification to succeed") + } + if VerifyPassword(hash, "wrong password") { + t.Fatal("wrong password verified") + } + if _, err := HashPassword("short"); err == nil { + t.Fatal("short password was accepted") + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..fbddf76 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,1253 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" +) + +type Store struct { + db *sql.DB +} + +type ManagedTarget struct { + Target model.Target + Zone model.Zone + Credential model.Credential +} + +type AccessSelection struct { + ZoneIDs []int64 + TargetIDs []int64 + ManageZoneIDs []int64 + ManageTargetIDs []int64 + CreateTargetZoneIDs []int64 +} + +func Open(ctx context.Context, path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create data directory: %w", err) + } + db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + store := &Store{db: db} + if err := store.migrate(ctx); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil { + return err + } + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil { + return err + } + if count == 0 { + if _, err := tx.ExecContext(ctx, schemaV1); err != nil { + return fmt.Errorf("apply schema v1: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)`, now()); err != nil { + return err + } + } + if err := s.applyMigrationV2(ctx, tx); err != nil { + return err + } + if err := s.applyMigrationV3(ctx, tx); err != nil { + return err + } + if err := s.applyMigrationV4(ctx, tx); err != nil { + return err + } + if err := s.applyMigrationV5(ctx, tx); err != nil { + return err + } + if err := s.applyMigrationV6(ctx, tx); err != nil { + return err + } + if err := s.applyMigrationV7(ctx, tx); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`, + 300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) applyMigrationV4(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 4`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV4); err != nil { + return fmt.Errorf("apply schema v4: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(4, ?)`, now()); err != nil { + return err + } + return nil +} + +func (s *Store) applyMigrationV5(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 5`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV5); err != nil { + return fmt.Errorf("apply schema v5: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(5, ?)`, now()); err != nil { + return err + } + return nil +} + +func (s *Store) applyMigrationV6(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 6`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV6); err != nil { + return fmt.Errorf("apply schema v6: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(6, ?)`, now()); err != nil { + return err + } + return nil +} + +func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 7`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV7); err != nil { + return fmt.Errorf("apply schema v7: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(7, ?)`, now()); err != nil { + return err + } + return nil +} + +func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV3); err != nil { + return fmt.Errorf("apply schema v3: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(3, ?)`, now()); err != nil { + return err + } + return nil +} + +func (s *Store) applyMigrationV2(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 2`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV2); err != nil { + return fmt.Errorf("apply schema v2: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO users(id, username, display_name, password_hash, role, active, created_at, updated_at) +SELECT 1, 'admin', 'Administrator', password_hash, 'admin', 1, created_at, updated_at FROM admins WHERE id = 1`); err != nil { + return fmt.Errorf("migrate legacy admin: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, now()); err != nil { + return err + } + return nil +} + +const schemaV1 = ` +CREATE TABLE admins ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); +CREATE TABLE zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE RESTRICT, + provider_id TEXT NOT NULL, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + created_at INTEGER NOT NULL, + last_sync_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +); +CREATE TABLE targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, + record_name TEXT NOT NULL, + fqdn TEXT NOT NULL, + update_ipv4 INTEGER NOT NULL DEFAULT 1 CHECK (update_ipv4 IN (0,1)), + update_ipv6 INTEGER NOT NULL DEFAULT 0 CHECK (update_ipv6 IN (0,1)), + active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0,1)), + description TEXT NOT NULL DEFAULT '', + ttl INTEGER NOT NULL DEFAULT 60, + last_updated_at INTEGER NOT NULL DEFAULT 0, + last_status TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + UNIQUE(zone_id, record_name) +); +CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + update_interval_seconds INTEGER NOT NULL, + ipv4_endpoints TEXT NOT NULL, + ipv6_endpoints TEXT NOT NULL, + last_ipv4 TEXT NOT NULL DEFAULT '', + last_ipv6 TEXT NOT NULL DEFAULT '', + last_run_at INTEGER NOT NULL DEFAULT 0, + last_run_status TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL +); +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + csrf_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + reauth_until INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX sessions_expires_at_idx ON sessions(expires_at); +CREATE TABLE update_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + ipv4 TEXT NOT NULL DEFAULT '', + ipv6 TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '' +); +CREATE INDEX update_runs_started_at_idx ON update_runs(started_at DESC); +CREATE TABLE audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at INTEGER NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL +); +CREATE INDEX audit_events_created_at_idx ON audit_events(created_at DESC); +` + +const schemaV2 = ` +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL COLLATE NOCASE UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'member')), + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_login_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE access_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, + target_id INTEGER REFERENCES targets(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + UNIQUE(user_id, zone_id, target_id) +); +CREATE INDEX access_grants_user_zone_idx ON access_grants(user_id, zone_id); +ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1; +CREATE INDEX sessions_user_id_idx ON sessions(user_id); +` + +const schemaV3 = ` +ALTER TABLE targets ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0; +CREATE INDEX targets_zone_archived_idx ON targets(zone_id, archived_at); +` + +const schemaV4 = ` +ALTER TABLE settings ADD COLUMN updater_enabled INTEGER NOT NULL DEFAULT 1 CHECK (updater_enabled IN (0,1)); +ALTER TABLE settings ADD COLUMN ipv6_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ipv6_enabled IN (0,1)); +ALTER TABLE access_grants ADD COLUMN can_manage_targets INTEGER NOT NULL DEFAULT 1 CHECK (can_manage_targets IN (0,1)); +ALTER TABLE access_grants ADD COLUMN can_create_targets INTEGER NOT NULL DEFAULT 0 CHECK (can_create_targets IN (0,1)); +` + +const schemaV5 = ` +CREATE TABLE cname_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE, + record_name TEXT NOT NULL, + fqdn TEXT NOT NULL, + destination TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + ttl INTEGER NOT NULL DEFAULT 60, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(zone_id, record_name) +); +CREATE INDEX cname_records_zone_idx ON cname_records(zone_id, record_name); +` + +const schemaV6 = ` +ALTER TABLE targets ADD COLUMN static_ipv4 TEXT NOT NULL DEFAULT ''; +ALTER TABLE targets ADD COLUMN static_ipv6 TEXT NOT NULL DEFAULT ''; +` + +const schemaV7 = ` +ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)); +` + +func (s *Store) HasAdmin(ctx context.Context) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count) + return count > 0, err +} + +func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now()) + return err +} + +func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) { + var hash string + err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id = ? AND active = 1`, userID).Scan(&hash) + return hash, err +} + +func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash string) error { + result, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) { + return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id)) +} + +func (s *Store) GetUserByUsername(ctx context.Context, username string) (model.User, string, error) { + var user model.User + var passwordHash string + var active int + var createdAt, updatedAt, lastLoginAt int64 + err := s.db.QueryRowContext(ctx, `SELECT id, username, display_name, password_hash, role, active, created_at, updated_at, last_login_at FROM users WHERE username = ?`, username).Scan( + &user.ID, &user.Username, &user.DisplayName, &passwordHash, &user.Role, &active, &createdAt, &updatedAt, &lastLoginAt, + ) + if err != nil { + return user, "", err + } + user.Active = intBool(active) + user.CreatedAt, user.UpdatedAt, user.LastLoginAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastLoginAt) + return user, passwordHash, nil +} + +func (s *Store) ListUsers(ctx context.Context) ([]model.User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, username COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var users []model.User + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (model.User, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, ?, 1, ?, ?)`, username, displayName, passwordHash, role, now(), now()) + if err != nil { + return model.User{}, err + } + id, err := result.LastInsertId() + if err != nil { + return model.User{}, err + } + return s.GetUser(ctx, id) +} + +func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error { + result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error { + return s.UpdatePassword(ctx, userID, passwordHash) +} + +func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, now(), userID) + return err +} + +func (s *Store) CountActiveAdmins(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count) + return count, err +} + +func (s *Store) ListAccessGrants(ctx context.Context, userID int64) ([]model.AccessGrant, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, zone_id, target_id, can_manage_targets, can_create_targets FROM access_grants WHERE user_id = ? ORDER BY zone_id, target_id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var grants []model.AccessGrant + for rows.Next() { + var grant model.AccessGrant + var targetID sql.NullInt64 + var canManage, canCreate int + if err := rows.Scan(&grant.ID, &grant.UserID, &grant.ZoneID, &targetID, &canManage, &canCreate); err != nil { + return nil, err + } + grant.CanManageTargets, grant.CanCreateTargets = intBool(canManage), intBool(canCreate) + if targetID.Valid { + value := targetID.Int64 + grant.TargetID = &value + } + grants = append(grants, grant) + } + return grants, rows.Err() +} + +// ReplaceAccessGrants atomically applies domain-wide and individual target grants. +func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection AccessSelection) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil { + return err + } + managedZones := idSet(selection.ManageZoneIDs) + createZones := idSet(selection.CreateTargetZoneIDs) + for _, zoneID := range uniqueIDs(selection.ZoneIDs) { + canManage := managedZones[zoneID] + canCreate := canManage && createZones[zoneID] + if _, err := tx.ExecContext(ctx, `INSERT INTO access_grants(user_id, zone_id, target_id, can_manage_targets, can_create_targets, created_at) VALUES(?, ?, NULL, ?, ?, ?)`, userID, zoneID, boolInt(canManage), boolInt(canCreate), now()); err != nil { + return err + } + } + managedTargets := idSet(selection.ManageTargetIDs) + for _, targetID := range uniqueIDs(selection.TargetIDs) { + var zoneID int64 + if err := tx.QueryRowContext(ctx, `SELECT zone_id FROM targets WHERE id = ?`, targetID).Scan(&zoneID); err != nil { + return fmt.Errorf("invalid target access grant: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO access_grants(user_id, zone_id, target_id, can_manage_targets, can_create_targets, created_at) VALUES(?, ?, ?, ?, 0, ?)`, userID, zoneID, targetID, boolInt(managedTargets[targetID]), now()); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) CanAccessZone(ctx context.Context, userID, zoneID int64) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ?`, userID, zoneID).Scan(&count) + return count > 0, err +} + +func (s *Store) CanAccessTarget(ctx context.Context, userID, zoneID, targetID int64) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND (target_id IS NULL OR target_id = ?)`, userID, zoneID, targetID).Scan(&count) + return count > 0, err +} + +func (s *Store) CanManageTarget(ctx context.Context, userID, zoneID, targetID int64) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND (target_id IS NULL OR target_id = ?) AND can_manage_targets = 1`, userID, zoneID, targetID).Scan(&count) + return count > 0, err +} + +func (s *Store) CanCreateTarget(ctx context.Context, userID, zoneID int64) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM access_grants WHERE user_id = ? AND zone_id = ? AND target_id IS NULL AND can_manage_targets = 1 AND can_create_targets = 1`, userID, zoneID).Scan(&count) + return count > 0, err +} + +func (s *Store) ListAccessibleZones(ctx context.Context, userID int64) ([]model.Zone, error) { + rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error +FROM zones z JOIN access_grants a ON a.zone_id = z.id WHERE a.user_id = ? ORDER BY z.name COLLATE NOCASE`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var zones []model.Zone + for rows.Next() { + zone, err := scanZone(rows) + if err != nil { + return nil, err + } + zones = append(zones, zone) + } + return zones, rows.Err() +} + +func (s *Store) ListAccessibleTargets(ctx context.Context, userID, zoneID int64) ([]model.Target, error) { + rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at +FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS ( + SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id) +) ORDER BY CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTargets(rows) +} + +func (s *Store) CountActiveAccessibleTargets(ctx context.Context, userID int64) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM targets t WHERE t.active = 1 AND t.archived_at = 0 AND EXISTS ( + SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id) +)`, userID).Scan(&count) + return count, err +} + +func (s *Store) GetSettings(ctx context.Context) (model.Settings, error) { + var settings model.Settings + var ipv4JSON, ipv6JSON string + var updaterEnabled, ipv6Enabled int + var lastRunAt, updatedAt int64 + err := s.db.QueryRowContext(ctx, `SELECT update_interval_seconds, updater_enabled, ipv6_enabled, ipv4_endpoints, ipv6_endpoints, last_ipv4, last_ipv6, last_run_at, last_run_status, updated_at FROM settings WHERE id = 1`).Scan( + &settings.UpdateIntervalSeconds, &updaterEnabled, &ipv6Enabled, &ipv4JSON, &ipv6JSON, &settings.LastIPv4, &settings.LastIPv6, &lastRunAt, &settings.LastRunStatus, &updatedAt, + ) + if err != nil { + return settings, err + } + if err := json.Unmarshal([]byte(ipv4JSON), &settings.IPv4Endpoints); err != nil { + return settings, fmt.Errorf("decode IPv4 endpoints: %w", err) + } + if err := json.Unmarshal([]byte(ipv6JSON), &settings.IPv6Endpoints); err != nil { + return settings, fmt.Errorf("decode IPv6 endpoints: %w", err) + } + settings.LastRunAt = fromUnix(lastRunAt) + settings.UpdatedAt = fromUnix(updatedAt) + settings.UpdaterEnabled, settings.IPv6Enabled = intBool(updaterEnabled), intBool(ipv6Enabled) + return settings, nil +} + +func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled, ipv6Enabled bool, ipv4, ipv6 []string) error { + _, err := s.db.ExecContext(ctx, `UPDATE settings SET update_interval_seconds = ?, updater_enabled = ?, ipv6_enabled = ?, ipv4_endpoints = ?, ipv6_endpoints = ?, updated_at = ? WHERE id = 1`, interval, boolInt(updaterEnabled), boolInt(ipv6Enabled), mustJSON(ipv4), mustJSON(ipv6), now()) + return err +} + +func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error { + _, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status) + return err +} + +func (s *Store) FindCredentialByFingerprint(ctx context.Context, fingerprint string) (*model.Credential, error) { + credential, err := scanCredential(s.db.QueryRowContext(ctx, `SELECT id, label, ciphertext, nonce, fingerprint, created_at FROM credentials WHERE fingerprint = ?`, fingerprint)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return credential, err +} + +func (s *Store) CreateCredential(ctx context.Context, label string, ciphertext, nonce []byte, fingerprint string) (model.Credential, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO credentials(label, ciphertext, nonce, fingerprint, created_at) VALUES(?, ?, ?, ?, ?)`, label, ciphertext, nonce, fingerprint, now()) + if err != nil { + return model.Credential{}, err + } + id, err := result.LastInsertId() + if err != nil { + return model.Credential{}, err + } + return s.GetCredential(ctx, id) +} + +func (s *Store) GetCredential(ctx context.Context, id int64) (model.Credential, error) { + credential, err := scanCredential(s.db.QueryRowContext(ctx, `SELECT id, label, ciphertext, nonce, fingerprint, created_at FROM credentials WHERE id = ?`, id)) + if err != nil { + return model.Credential{}, err + } + return *credential, nil +} + +func (s *Store) CreateZone(ctx context.Context, credentialID int64, providerID, name string) (model.Zone, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO zones(credential_id, provider_id, name, created_at) VALUES(?, ?, ?, ?)`, credentialID, providerID, strings.ToLower(name), now()) + if err != nil { + return model.Zone{}, err + } + id, err := result.LastInsertId() + if err != nil { + return model.Zone{}, err + } + return s.GetZone(ctx, id) +} + +func (s *Store) GetZone(ctx context.Context, id int64) (model.Zone, error) { + return scanZone(s.db.QueryRowContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones WHERE id = ?`, id)) +} + +func (s *Store) GetZoneWithCredential(ctx context.Context, id int64) (model.Zone, model.Credential, error) { + row := s.db.QueryRowContext(ctx, `SELECT z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error, c.id, c.label, c.ciphertext, c.nonce, c.fingerprint, c.created_at +FROM zones z JOIN credentials c ON c.id = z.credential_id WHERE z.id = ?`, id) + var zone model.Zone + var credential model.Credential + var zoneCreatedAt, zoneLastSyncAt, credentialCreatedAt int64 + err := row.Scan(&zone.ID, &zone.CredentialID, &zone.ProviderID, &zone.Name, &zoneCreatedAt, &zoneLastSyncAt, &zone.LastError, + &credential.ID, &credential.Label, &credential.Ciphertext, &credential.Nonce, &credential.Fingerprint, &credentialCreatedAt) + if err != nil { + return zone, credential, err + } + zone.CreatedAt, zone.LastSyncAt, credential.CreatedAt = fromUnix(zoneCreatedAt), fromUnix(zoneLastSyncAt), fromUnix(credentialCreatedAt) + return zone, credential, nil +} + +func (s *Store) ListZones(ctx context.Context) ([]model.Zone, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones ORDER BY name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var zones []model.Zone + for rows.Next() { + zone, scanErr := scanZone(rows) + if scanErr != nil { + return nil, scanErr + } + zones = append(zones, zone) + } + return zones, rows.Err() +} + +func (s *Store) DeleteZone(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM zones WHERE id = ?`, id) + return err +} + +func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string) error { + _, err := s.db.ExecContext(ctx, `UPDATE zones SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id) + return err +} + +func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTargets(rows) +} + +func (s *Store) ListArchivedTargets(ctx context.Context, zoneID int64) ([]model.Target, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at > 0 ORDER BY archived_at DESC, record_name`, zoneID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTargets(rows) +} + +func (s *Store) GetTarget(ctx context.Context, id int64) (model.Target, error) { + return scanTarget(s.db.QueryRowContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE id = ?`, id)) +} + +func (s *Store) CreateTarget(ctx context.Context, target model.Target) (model.Target, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO targets(zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + target.ZoneID, target.RecordName, target.FQDN, boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), boolInt(target.Active), target.StaticIPv4, target.StaticIPv6, target.Description, target.TTL, now()) + if err != nil { + return model.Target{}, err + } + id, err := result.LastInsertId() + if err != nil { + return model.Target{}, err + } + return s.GetTarget(ctx, id) +} + +// UpsertImportedTarget adds a safe, inactive target only when the host was not already known. +func (s *Store) UpsertImportedTarget(ctx context.Context, target model.Target) (bool, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO targets(zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, description, ttl, created_at) +VALUES(?, ?, ?, ?, ?, 0, ?, ?, ?) +ON CONFLICT(zone_id, record_name) DO NOTHING`, target.ZoneID, target.RecordName, target.FQDN, boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), target.Description, target.TTL, now()) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count == 1, err +} + +// UpdateImportedTargetTTL refreshes the provider-side TTL without changing a +// target's local activation, address-family selection, or static addresses. +func (s *Store) UpdateImportedTargetTTL(ctx context.Context, zoneID int64, recordName string, ttl int) error { + _, err := s.db.ExecContext(ctx, `UPDATE targets SET ttl = ? WHERE zone_id = ? AND record_name = ?`, ttl, zoneID, recordName) + return err +} + +func (s *Store) UpdateZoneTargetTTLs(ctx context.Context, zoneID int64, ttl int) error { + _, err := s.db.ExecContext(ctx, `UPDATE targets SET ttl = ? WHERE zone_id = ? AND archived_at = 0`, ttl, zoneID) + return err +} + +func (s *Store) UpdateTarget(ctx context.Context, target model.Target) error { + result, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv4 = ?, update_ipv6 = ?, active = ?, static_ipv4 = ?, static_ipv6 = ?, description = ?, ttl = ? WHERE id = ? AND archived_at = 0`, + boolInt(target.UpdateIPv4), boolInt(target.UpdateIPv6), boolInt(target.Active), target.StaticIPv4, target.StaticIPv6, target.Description, target.TTL, target.ID) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) UpdateTargetResult(ctx context.Context, id int64, status, lastError string, updated bool) error { + updatedAt := int64(0) + if updated { + updatedAt = now() + } + _, err := s.db.ExecContext(ctx, `UPDATE targets SET last_status = ?, last_error = ?, last_updated_at = CASE WHEN ? > 0 THEN ? ELSE last_updated_at END WHERE id = ? AND archived_at = 0`, status, lastError, updatedAt, updatedAt, id) + return err +} + +func (s *Store) DeleteTarget(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM targets WHERE id = ?`, id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE update_ipv6 = 1 AND archived_at = 0 ORDER BY zone_id, id`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTargets(rows) +} + +func (s *Store) DisableIPv6ForTargets(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`) + return err +} + +func (s *Store) ArchiveTarget(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = ?, last_status = 'Getrennt', last_error = '' WHERE id = ? AND archived_at = 0`, now(), id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) RestoreTarget(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = 0, last_status = 'Wieder verknüpft', last_error = '' WHERE id = ? AND archived_at > 0`, id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanCNAMERecords(rows) +} + +func (s *Store) GetCNAMERecord(ctx context.Context, id int64) (model.CNAMERecord, error) { + return scanCNAMERecord(s.db.QueryRowContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE id = ?`, id)) +} + +func (s *Store) CreateCNAMERecord(ctx context.Context, record model.CNAMERecord) (model.CNAMERecord, error) { + timestamp := now() + result, err := s.db.ExecContext(ctx, `INSERT INTO cname_records(zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.ZoneID, boolInt(record.Active), record.RecordName, record.FQDN, record.Destination, record.Description, record.TTL, timestamp, timestamp) + if err != nil { + return model.CNAMERecord{}, err + } + id, err := result.LastInsertId() + if err != nil { + return model.CNAMERecord{}, err + } + return s.GetCNAMERecord(ctx, id) +} + +func (s *Store) UpdateCNAMERecord(ctx context.Context, record model.CNAMERecord) error { + result, err := s.db.ExecContext(ctx, `UPDATE cname_records SET active = ?, destination = ?, description = ?, ttl = ?, updated_at = ? WHERE id = ?`, + boolInt(record.Active), record.Destination, record.Description, record.TTL, now(), record.ID) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) UpdateZoneCNAMERecordTTLs(ctx context.Context, zoneID int64, ttl int) error { + _, err := s.db.ExecContext(ctx, `UPDATE cname_records SET ttl = ?, updated_at = ? WHERE zone_id = ?`, ttl, now(), zoneID) + return err +} + +func (s *Store) DeleteCNAMERecord(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM cname_records WHERE id = ?`, id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count != 1 { + return sql.ErrNoRows + } + return nil +} + +// ReplaceCNAMERecords mirrors the provider's active CNAME RRSets while keeping +// locally disabled records in the UI. Missing provider records become inactive +// instead of disappearing; only the explicit remove action deletes them. +func (s *Store) ReplaceCNAMERecords(ctx context.Context, zoneID int64, records []model.CNAMERecord) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + timestamp := now() + if _, err := tx.ExecContext(ctx, `UPDATE cname_records SET active = 0, updated_at = ? WHERE zone_id = ?`, timestamp, zoneID); err != nil { + return err + } + for _, record := range records { + if _, err := tx.ExecContext(ctx, `INSERT INTO cname_records(zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at) +VALUES(?, 1, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(zone_id, record_name) DO UPDATE SET active = 1, fqdn = excluded.fqdn, destination = excluded.destination, description = excluded.description, ttl = excluded.ttl, updated_at = excluded.updated_at`, + zoneID, record.RecordName, record.FQDN, record.Destination, record.Description, record.TTL, timestamp, timestamp); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) ListManagedTargets(ctx context.Context) ([]ManagedTarget, error) { + rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, +z.id, z.credential_id, z.provider_id, z.name, z.created_at, z.last_sync_at, z.last_error, +c.id, c.label, c.ciphertext, c.nonce, c.fingerprint, c.created_at +FROM targets t JOIN zones z ON z.id = t.zone_id JOIN credentials c ON c.id = z.credential_id WHERE t.active = 1 AND t.archived_at = 0 ORDER BY z.id, t.id`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []ManagedTarget + for rows.Next() { + var item ManagedTarget + var targetLastUpdated, targetCreated, zoneCreated, zoneSynced, credentialCreated int64 + var updateIPv4, updateIPv6, active int + err := rows.Scan( + &item.Target.ID, &item.Target.ZoneID, &item.Target.RecordName, &item.Target.FQDN, &updateIPv4, &updateIPv6, &active, &item.Target.StaticIPv4, &item.Target.StaticIPv6, &item.Target.Description, &item.Target.TTL, &targetLastUpdated, &item.Target.LastStatus, &item.Target.LastError, &targetCreated, + &item.Zone.ID, &item.Zone.CredentialID, &item.Zone.ProviderID, &item.Zone.Name, &zoneCreated, &zoneSynced, &item.Zone.LastError, + &item.Credential.ID, &item.Credential.Label, &item.Credential.Ciphertext, &item.Credential.Nonce, &item.Credential.Fingerprint, &credentialCreated, + ) + if err != nil { + return nil, err + } + item.Target.UpdateIPv4, item.Target.UpdateIPv6, item.Target.Active = intBool(updateIPv4), intBool(updateIPv6), intBool(active) + item.Target.LastUpdatedAt, item.Target.CreatedAt = fromUnix(targetLastUpdated), fromUnix(targetCreated) + item.Zone.CreatedAt, item.Zone.LastSyncAt = fromUnix(zoneCreated), fromUnix(zoneSynced) + item.Credential.CreatedAt = fromUnix(credentialCreated) + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) CountActiveTargets(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM targets WHERE active = 1 AND archived_at = 0`).Scan(&count) + return count, err +} + +func (s *Store) CreateUpdateRun(ctx context.Context) (int64, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO update_runs(started_at, status) VALUES(?, 'running')`, now()) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (s *Store) FinishUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error { + _, err := s.db.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id) + return err +} + +func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var runs []model.UpdateRun + for rows.Next() { + var run model.UpdateRun + var started, finished int64 + if err := rows.Scan(&run.ID, &started, &finished, &run.Status, &run.IPv4, &run.IPv6, &run.Summary); err != nil { + return nil, err + } + run.StartedAt, run.FinishedAt = fromUnix(started), fromUnix(finished) + runs = append(runs, run) + } + return runs, rows.Err() +} + +func (s *Store) FailedRuns(ctx context.Context) ([]model.UpdateRun, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs WHERE status IN ('error', 'partial') ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var runs []model.UpdateRun + for rows.Next() { + var run model.UpdateRun + var started, finished int64 + if err := rows.Scan(&run.ID, &started, &finished, &run.Status, &run.IPv4, &run.IPv6, &run.Summary); err != nil { + return nil, err + } + run.StartedAt, run.FinishedAt = fromUnix(started), fromUnix(finished) + runs = append(runs, run) + } + return runs, rows.Err() +} + +func (s *Store) ListTargetsWithErrors(ctx context.Context) ([]model.Target, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE last_error != '' ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTargets(rows) +} + +func (s *Store) ListZonesWithErrors(ctx context.Context) ([]model.Zone, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, credential_id, provider_id, name, created_at, last_sync_at, last_error FROM zones WHERE last_error != '' ORDER BY name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var zones []model.Zone + for rows.Next() { + zone, err := scanZone(rows) + if err != nil { + return nil, err + } + zones = append(zones, zone) + } + return zones, rows.Err() +} + +func (s *Store) CountErrors(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT + (SELECT COUNT(*) FROM targets WHERE last_error != '') + + (SELECT COUNT(*) FROM zones WHERE last_error != '') + + (SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count) + return count, err +} + +func (s *Store) ClearErrors(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `UPDATE targets SET last_error = '', last_status = CASE WHEN last_status = 'Fehler' THEN 'Zurückgesetzt' ELSE last_status END WHERE last_error != ''`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM audit_events WHERE kind IN ('update.error', 'update.partial')`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = '' WHERE last_run_status IN ('error', 'partial')`); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) AddAudit(ctx context.Context, kind, message string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message) + return err +} + +func (s *Store) RecentAudit(ctx context.Context, limit int) ([]model.AuditEvent, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, created_at, kind, message FROM audit_events ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var events []model.AuditEvent + for rows.Next() { + var event model.AuditEvent + var createdAt int64 + if err := rows.Scan(&event.ID, &createdAt, &event.Kind, &event.Message); err != nil { + return nil, err + } + event.CreatedAt = fromUnix(createdAt) + events = append(events, event) + } + return events, rows.Err() +} + +func (s *Store) CreateSession(ctx context.Context, session model.Session) (model.Session, error) { + result, err := s.db.ExecContext(ctx, `INSERT INTO sessions(user_id, token_hash, csrf_token, created_at, expires_at, reauth_until) VALUES(?, ?, ?, ?, ?, ?)`, session.UserID, session.TokenHash, session.CSRFToken, now(), unix(session.ExpiresAt), unix(session.ReauthUntil)) + if err != nil { + return session, err + } + id, err := result.LastInsertId() + if err != nil { + return session, err + } + session.ID = id + session.CreatedAt = time.Now().UTC() + return session, nil +} + +func (s *Store) GetSession(ctx context.Context, tokenHash string) (*model.Session, error) { + var session model.Session + var createdAt, expiresAt, reauthUntil int64 + err := s.db.QueryRowContext(ctx, `SELECT s.id, s.user_id, s.token_hash, s.csrf_token, s.created_at, s.expires_at, s.reauth_until, u.username, u.display_name, u.role +FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`, tokenHash, now()).Scan( + &session.ID, &session.UserID, &session.TokenHash, &session.CSRFToken, &createdAt, &expiresAt, &reauthUntil, &session.Username, &session.DisplayName, &session.Role, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + session.CreatedAt, session.ExpiresAt, session.ReauthUntil = fromUnix(createdAt), fromUnix(expiresAt), fromUnix(reauthUntil) + return &session, nil +} + +func (s *Store) SetSessionReauth(ctx context.Context, id int64, until time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET reauth_until = ? WHERE id = ?`, unix(until), id) + return err +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash) + return err +} + +func (s *Store) DeleteOtherSessions(ctx context.Context, userID, keepID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepID) + return err +} + +func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +func (s *Store) Prune(ctx context.Context) error { + cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour).Unix() + if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now()); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, `DELETE FROM update_runs WHERE started_at < ?`, cutoff); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, `DELETE FROM audit_events WHERE created_at < ?`, cutoff) + return err +} + +func scanCredential(scanner interface{ Scan(...any) error }) (*model.Credential, error) { + var credential model.Credential + var createdAt int64 + if err := scanner.Scan(&credential.ID, &credential.Label, &credential.Ciphertext, &credential.Nonce, &credential.Fingerprint, &createdAt); err != nil { + return nil, err + } + credential.CreatedAt = fromUnix(createdAt) + return &credential, nil +} + +func scanUser(scanner interface{ Scan(...any) error }) (model.User, error) { + var user model.User + var active int + var createdAt, updatedAt, lastLoginAt int64 + err := scanner.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &active, &createdAt, &updatedAt, &lastLoginAt) + user.Active = intBool(active) + user.CreatedAt, user.UpdatedAt, user.LastLoginAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastLoginAt) + return user, err +} + +func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) { + var zone model.Zone + var createdAt, lastSyncAt int64 + err := scanner.Scan(&zone.ID, &zone.CredentialID, &zone.ProviderID, &zone.Name, &createdAt, &lastSyncAt, &zone.LastError) + zone.CreatedAt, zone.LastSyncAt = fromUnix(createdAt), fromUnix(lastSyncAt) + return zone, err +} + +func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) { + var target model.Target + var updateIPv4, updateIPv6, active int + var lastUpdatedAt, createdAt, archivedAt int64 + err := scanner.Scan(&target.ID, &target.ZoneID, &target.RecordName, &target.FQDN, &updateIPv4, &updateIPv6, &active, &target.StaticIPv4, &target.StaticIPv6, &target.Description, &target.TTL, &lastUpdatedAt, &target.LastStatus, &target.LastError, &createdAt, &archivedAt) + target.UpdateIPv4, target.UpdateIPv6, target.Active = intBool(updateIPv4), intBool(updateIPv6), intBool(active) + target.LastUpdatedAt, target.CreatedAt, target.ArchivedAt = fromUnix(lastUpdatedAt), fromUnix(createdAt), fromUnix(archivedAt) + return target, err +} + +func scanTargets(rows *sql.Rows) ([]model.Target, error) { + var targets []model.Target + for rows.Next() { + target, err := scanTarget(rows) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + return targets, rows.Err() +} + +func scanCNAMERecord(scanner interface{ Scan(...any) error }) (model.CNAMERecord, error) { + var record model.CNAMERecord + var active int + var createdAt, updatedAt int64 + err := scanner.Scan(&record.ID, &record.ZoneID, &active, &record.RecordName, &record.FQDN, &record.Destination, &record.Description, &record.TTL, &createdAt, &updatedAt) + record.Active = intBool(active) + record.CreatedAt, record.UpdatedAt = fromUnix(createdAt), fromUnix(updatedAt) + return record, err +} + +func scanCNAMERecords(rows *sql.Rows) ([]model.CNAMERecord, error) { + var records []model.CNAMERecord + for rows.Next() { + record, err := scanCNAMERecord(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func now() int64 { return time.Now().UTC().Unix() } +func unix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UTC().Unix() +} +func fromUnix(value int64) time.Time { + if value <= 0 { + return time.Time{} + } + return time.Unix(value, 0).UTC() +} +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} +func intBool(value int) bool { return value != 0 } +func mustJSON(value []string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func uniqueIDs(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + result := make([]int64, 0, len(values)) + for _, value := range values { + if value > 0 { + if _, ok := seen[value]; !ok { + seen[value] = struct{}{} + result = append(result, value) + } + } + } + return result +} + +func idSet(values []int64) map[int64]bool { + result := make(map[int64]bool, len(values)) + for _, value := range values { + if value > 0 { + result[value] = true + } + } + return result +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..b7f587a --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,294 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" +) + +func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hash, err := secure.HashPassword("a secure password") + if err != nil { + t.Fatal(err) + } + if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil { + t.Fatal(err) + } + member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "fingerprint") + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "10", "example.de") + if err != nil { + t.Fatal(err) + } + home, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, Active: true, StaticIPv4: "203.0.113.10", TTL: 60}) + if err != nil { + t.Fatal(err) + } + if home.StaticIPv4 != "203.0.113.10" { + t.Fatalf("static IPv4 was not persisted: %#v", home) + } + other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60}) + if err != nil { + t.Fatal(err) + } + + if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil { + t.Fatal(err) + } + allowed, err := database.CanAccessTarget(ctx, member.ID, zone.ID, home.ID) + if err != nil || !allowed { + t.Fatalf("target-specific grant was not honored: allowed=%v err=%v", allowed, err) + } + allowed, err = database.CanAccessTarget(ctx, member.ID, zone.ID, other.ID) + if err != nil || allowed { + t.Fatalf("ungranted target was visible: allowed=%v err=%v", allowed, err) + } + managed, err := database.CanManageTarget(ctx, member.ID, zone.ID, home.ID) + if err != nil || !managed { + t.Fatalf("target management grant was not honored: managed=%v err=%v", managed, err) + } + targets, err := database.ListAccessibleTargets(ctx, member.ID, zone.ID) + if err != nil || len(targets) != 1 || targets[0].ID != home.ID { + t.Fatalf("expected one scoped target, got %#v (err=%v)", targets, err) + } + + if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}, ManageZoneIDs: []int64{zone.ID}, CreateTargetZoneIDs: []int64{zone.ID}}); err != nil { + t.Fatal(err) + } + targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID) + if err != nil || len(targets) != 2 { + t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err) + } + canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID) + if err != nil || !canCreate { + t.Fatalf("domain create grant was not honored: canCreate=%v err=%v", canCreate, err) + } + + if err := database.ArchiveTarget(ctx, home.ID); err != nil { + t.Fatal(err) + } + linked, err := database.ListTargets(ctx, zone.ID) + if err != nil || len(linked) != 1 || linked[0].ID != other.ID { + t.Fatalf("archived target remained linked: %#v, %v", linked, err) + } + archived, err := database.ListArchivedTargets(ctx, zone.ID) + if err != nil || len(archived) != 1 || archived[0].ID != home.ID || archived[0].ArchivedAt.IsZero() { + t.Fatalf("archived target was not retained: %#v, %v", archived, err) + } + imported, err := database.UpsertImportedTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, TTL: 60}) + if err != nil || imported { + t.Fatalf("provider re-import unexpectedly restored an archived target: imported=%v err=%v", imported, err) + } + if err := database.RestoreTarget(ctx, home.ID); err != nil { + t.Fatal(err) + } + restored, err := database.GetTarget(ctx, home.ID) + if err != nil || !restored.ArchivedAt.IsZero() || restored.Active { + t.Fatalf("target was not safely restored as inactive: %#v, %v", restored, err) + } +} + +func TestInactiveUserSessionIsRejected(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hash, _ := secure.HashPassword("a secure password") + user, err := database.CreateUser(ctx, "member", "Member", hash, "member") + if err != nil { + t.Fatal(err) + } + session, err := database.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: "token", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + loaded, err := database.GetSession(ctx, session.TokenHash) + if err != nil || loaded == nil || loaded.Username != "member" { + t.Fatalf("session was not resolved with user: %#v, %v", loaded, err) + } + if err := database.UpdateUserState(ctx, user.ID, "Member", "member", false); err != nil { + t.Fatal(err) + } + loaded, err = database.GetSession(ctx, session.TokenHash) + if err != nil || loaded != nil { + t.Fatalf("inactive user session was accepted: %#v, %v", loaded, err) + } +} + +func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "ipv6-fingerprint") + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "40", "example.de") + if err != nil { + t.Fatal(err) + } + target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, StaticIPv6: "2001:db8::10", TTL: 60}) + if err != nil { + t.Fatal(err) + } + targets, err := database.ListTargetsWithIPv6(ctx) + if err != nil || len(targets) != 1 || targets[0].ID != target.ID { + t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err) + } + if err := database.DisableIPv6ForTargets(ctx); err != nil { + t.Fatal(err) + } + target, err = database.GetTarget(ctx, target.ID) + if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" { + t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err) + } + if err := database.DeleteTarget(ctx, target.ID); err != nil { + t.Fatal(err) + } + if _, err := database.GetTarget(ctx, target.ID); err == nil { + t.Fatal("deleted target was still found") + } +} + +func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + settings, err := database.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if !settings.UpdaterEnabled || !settings.IPv6Enabled { + t.Fatalf("new installations must enable updater and IPv6 by default: %#v", settings) + } + if err := database.UpdateSettings(ctx, 600, false, false, []string{"https://v4.example"}, []string{"https://v6.example"}); err != nil { + t.Fatal(err) + } + settings, err = database.GetSettings(ctx) + if err != nil || settings.UpdaterEnabled || settings.IPv6Enabled || settings.UpdateIntervalSeconds != 600 { + t.Fatalf("updater settings were not persisted: %#v err=%v", settings, err) + } + + credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "error-fingerprint") + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "20", "errors.example") + if err != nil { + t.Fatal(err) + } + target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.errors.example", UpdateIPv4: true, Active: true, TTL: 60}) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateTargetResult(ctx, target.ID, "Fehler", "provider rejected update", false); err != nil { + t.Fatal(err) + } + if err := database.MarkZoneSynced(ctx, zone.ID, "zone unavailable"); err != nil { + t.Fatal(err) + } + runID, err := database.CreateUpdateRun(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.FinishUpdateRun(ctx, runID, "partial", "198.51.100.1", "", "one error"); err != nil { + t.Fatal(err) + } + if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil { + t.Fatal(err) + } + if count, err := database.CountErrors(ctx); err != nil || count != 3 { + t.Fatalf("unexpected error count: count=%d err=%v", count, err) + } + if err := database.ClearErrors(ctx); err != nil { + t.Fatal(err) + } + if count, err := database.CountErrors(ctx); err != nil || count != 0 { + t.Fatalf("errors were not cleared: count=%d err=%v", count, err) + } + clearedTarget, err := database.GetTarget(ctx, target.ID) + if err != nil || clearedTarget.LastError != "" || clearedTarget.LastStatus != "Zurückgesetzt" { + t.Fatalf("target error was not reset: %#v err=%v", clearedTarget, err) + } + settings, err = database.GetSettings(ctx) + if err != nil || settings.LastRunStatus != "" { + t.Fatalf("last run error state was not reset: %#v err=%v", settings, err) + } +} + +func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "cname-fingerprint") + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "30", "example.de") + if err != nil { + t.Fatal(err) + } + created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300}) + if err != nil { + t.Fatal(err) + } + created.Destination = "edge.example.net." + created.TTL = 600 + if err := database.UpdateCNAMERecord(ctx, created); err != nil { + t.Fatal(err) + } + updated, err := database.GetCNAMERecord(ctx, created.ID) + if err != nil || !updated.Active || updated.Destination != "edge.example.net." || updated.TTL != 600 { + t.Fatalf("CNAME update not persisted: %#v, %v", updated, err) + } + + if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil { + t.Fatal(err) + } + records, err := database.ListCNAMERecords(ctx, zone.ID) + if err != nil || len(records) != 2 { + t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err) + } + byName := make(map[string]model.CNAMERecord, len(records)) + for _, record := range records { + byName[record.RecordName] = record + } + if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 { + t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName) + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..7ad358e --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,2035 @@ +package web + +import ( + "context" + "crypto/subtle" + "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net" + "net/http" + "net/netip" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/idna" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" +) + +//go:embed templates/*.html static/* +var embeddedAssets embed.FS + +type Server struct { + config config.Config + store *store.Store + cipher *secure.Cipher + scheduler *scheduler.Scheduler + logger *slog.Logger + templates map[string]*template.Template + limiter *loginLimiter +} + +type viewData struct { + Title string + Page string + Mode string + CSRF string + Flash *flashMessage + Now time.Time + User model.User + IsAdmin bool + Dashboard model.Dashboard + Zones []model.Zone + Zone model.Zone + Targets []model.Target + ArchivedTargets []model.Target + CNAMERecords []model.CNAMERecord + Settings model.Settings + Token string + Form map[string]string + Users []model.User + ManagedUser model.User + AccessZones []accessZone + UpdaterEnabled bool + CanCreateTargets bool + ManageTargetIDs map[int64]bool + ErrorTargets []model.Target + ErrorZones []model.Zone + ErrorRuns []model.UpdateRun + ErrorCount int +} + +type flashMessage struct { + Message string + Kind string +} + +type accessZone struct { + Zone model.Zone + Targets []model.Target + DomainGranted bool + DomainManaged bool + CanCreateTargets bool + GrantedTargetIDs map[int64]bool + ManagedTargetIDs map[int64]bool +} + +func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) { + templates, err := loadTemplates() + if err != nil { + return nil, err + } + return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + static, _ := fs.Sub(embeddedAssets, "static") + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) + mux.HandleFunc("GET /healthz", s.health) + mux.HandleFunc("GET /readyz", s.ready) + mux.HandleFunc("GET /setup", s.setupPage) + mux.HandleFunc("POST /setup", s.setup) + mux.HandleFunc("GET /login", s.loginPage) + mux.HandleFunc("POST /login", s.login) + mux.Handle("POST /logout", s.authenticated(s.logout)) + mux.Handle("GET /", s.authenticated(s.dashboard)) + mux.Handle("GET /domains", s.authenticated(s.domains)) + mux.Handle("GET /domains/new", s.adminOnly(s.domainNew)) + mux.Handle("POST /domains", s.adminOnly(s.domainCreate)) + mux.Handle("GET /domains/{id}", s.authenticated(s.domainDetail)) + mux.Handle("POST /domains/{id}/import", s.adminOnly(s.domainImport)) + mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.domainTTLSave)) + mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.domainRemove)) + mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.domainDelete)) + mux.Handle("POST /domains/{id}/token", s.adminOnly(s.domainToken)) + mux.Handle("POST /targets", s.authenticated(s.targetCreate)) + mux.Handle("POST /targets/{id}/save", s.authenticated(s.targetSave)) + mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.targetDelete)) + mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.targetArchive)) + mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.targetRestore)) + mux.Handle("POST /cnames", s.adminOnly(s.cnameCreate)) + mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave)) + mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove)) + mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete)) + mux.Handle("GET /settings", s.adminOnly(s.settingsPage)) + mux.Handle("POST /settings", s.adminOnly(s.settingsSave)) + mux.Handle("POST /run", s.adminOnly(s.runNow)) + mux.Handle("GET /errors", s.adminOnly(s.errorsPage)) + mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) + mux.Handle("GET /security", s.authenticated(s.securityPage)) + mux.Handle("POST /security/password", s.authenticated(s.passwordChange)) + mux.Handle("GET /users", s.adminOnly(s.usersPage)) + mux.Handle("GET /users/new", s.adminOnly(s.userNewPage)) + mux.Handle("POST /users", s.adminOnly(s.userCreate)) + mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail)) + mux.Handle("POST /users/{id}/access", s.adminOnly(s.userAccessSave)) + mux.Handle("POST /users/{id}/state", s.adminOnly(s.userStateSave)) + mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset)) + mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI)) + mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI)) + return s.securityHeaders(mux) +} + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + +func (s *Server) ready(w http.ResponseWriter, r *http.Request) { + if _, err := s.store.GetSettings(r.Context()); err != nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ready\n")) +} + +func (s *Server) setupPage(w http.ResponseWriter, r *http.Request) { + exists, err := s.store.HasAdmin(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + if exists { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + s.render(w, "setup", viewData{Title: "Ersteinrichtung", Page: "setup", Mode: s.config.Mode, CSRF: s.publicCSRF(w, r), Now: time.Now(), Form: map[string]string{"username": "admin", "display_name": "Administrator"}}) +} + +func (s *Server) setup(w http.ResponseWriter, r *http.Request) { + exists, err := s.store.HasAdmin(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + if exists { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validPublicCSRF(r) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + form := map[string]string{"username": strings.TrimSpace(r.FormValue("username")), "display_name": strings.TrimSpace(r.FormValue("display_name"))} + username, usernameErr := normalizeUsername(form["username"]) + if usernameErr != nil { + s.renderError(w, "setup", usernameErr.Error(), s.publicCSRF(w, r), form) + return + } + if form["display_name"] == "" || len([]rune(form["display_name"])) > 80 { + s.renderError(w, "setup", "Bitte einen Anzeigenamen mit höchstens 80 Zeichen angeben.", s.publicCSRF(w, r), form) + return + } + password, confirm := r.FormValue("password"), r.FormValue("confirm_password") + if password != confirm { + s.renderError(w, "setup", "Die Passwörter stimmen nicht überein.", s.publicCSRF(w, r), form) + return + } + hash, err := secure.HashPassword(password) + if err != nil { + s.renderError(w, "setup", err.Error(), s.publicCSRF(w, r), form) + return + } + if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil { + if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + s.serverError(w, err) + return + } + admin, _, err := s.store.GetUserByUsername(r.Context(), username) + if err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "admin.setup", "Admin-Zugang eingerichtet") + if err := s.startSession(w, r.Context(), admin); err != nil { + s.serverError(w, err) + return + } + s.redirectNotice(w, r, "/", "Ersteinrichtung abgeschlossen.") +} + +func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { + exists, err := s.store.HasAdmin(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + if !exists { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + if session, _ := s.session(r.Context(), r); session != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.render(w, "login", viewData{Title: "Anmelden", Page: "login", Mode: s.config.Mode, CSRF: s.publicCSRF(w, r), Flash: notice(r), Now: time.Now()}) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validPublicCSRF(r) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + clientIP := remoteIP(r) + if !s.limiter.Allow(clientIP) { + s.renderError(w, "login", "Zu viele Anmeldeversuche. Bitte warte kurz.", s.publicCSRF(w, r), map[string]string{}) + return + } + username, usernameErr := normalizeUsername(r.FormValue("username")) + user, hash, err := s.store.GetUserByUsername(r.Context(), username) + if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) { + s.limiter.Fail(clientIP) + _ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung") + s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{}) + return + } + s.limiter.Reset(clientIP) + if err := s.startSession(w, r.Context(), user); err != nil { + s.serverError(w, err) + return + } + _ = s.store.MarkUserLoggedIn(r.Context(), user.ID) + _ = s.store.AddAudit(r.Context(), "auth.login", "Angemeldet: "+user.Username) + s.redirectNotice(w, r, "/", "Angemeldet.") +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) { + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + _ = s.store.DeleteSession(r.Context(), session.TokenHash) + s.clearSession(w) + s.redirectNotice(w, r, "/login", "Abgemeldet.") +} + +func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, session *model.Session) { + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + user := userFromSession(session) + zones, err := s.zonesForSession(r.Context(), session) + if err != nil { + s.serverError(w, err) + return + } + active, _ := s.activeTargetsForSession(r.Context(), session) + var runs []model.UpdateRun + var events []model.AuditEvent + errorCount := 0 + if session.Role == "admin" { + runs, _ = s.store.RecentRuns(r.Context(), 6) + events, _ = s.store.RecentAudit(r.Context(), 6) + errorCount, _ = s.store.CountErrors(r.Context()) + } + s.render(w, "dashboard", viewData{Title: "Übersicht", Page: "dashboard", CSRF: session.CSRFToken, Flash: notice(r), Now: time.Now(), User: user, IsAdmin: session.Role == "admin", Dashboard: model.Dashboard{Settings: settings, Zones: zones, ActiveTargets: active, RecentRuns: runs, RecentEvents: events, NextRunAt: s.scheduler.NextRunAt(), SchedulerRunning: s.scheduler.IsRunning(), ErrorCount: errorCount}}) +} + +func (s *Server) domains(w http.ResponseWriter, r *http.Request, session *model.Session) { + zones, err := s.zonesForSession(r.Context(), session) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, "domains", viewData{Title: "Domains", Page: "domains", CSRF: session.CSRFToken, Flash: notice(r), Zones: zones, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) +} + +func (s *Server) domainNew(w http.ResponseWriter, r *http.Request, session *model.Session) { + s.render(w, "domain_new", viewData{Title: "Domain verbinden", Page: "domains", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Now: time.Now(), Form: map[string]string{}}) +} + +func (s *Server) domainCreate(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + form := map[string]string{"label": strings.TrimSpace(r.FormValue("label")), "zone": strings.TrimSpace(r.FormValue("zone"))} + token := strings.TrimSpace(r.FormValue("token")) + zoneName, err := normalizeZone(form["zone"]) + if err != nil || token == "" { + if err == nil { + err = errors.New("Hetzner API-Token ist erforderlich") + } + s.renderError(w, "domain_new", err.Error(), session.CSRFToken, form) + return + } + zones, err := s.scheduler.ValidateToken(r.Context(), token) + if err != nil { + s.renderError(w, "domain_new", "Der API-Token konnte nicht validiert werden: "+safeMessage(err), session.CSRFToken, form) + return + } + providerID := "" + for _, candidate := range zones { + if strings.EqualFold(strings.TrimSuffix(candidate.Name, "."), zoneName) { + providerID = candidate.ID + break + } + } + if providerID == "" { + s.renderError(w, "domain_new", "Die Zone ist mit diesem Token nicht erreichbar. Prüfe den Domainnamen oder das Hetzner-Projekt.", session.CSRFToken, form) + return + } + if form["label"] == "" { + form["label"] = "Hetzner · " + zoneName + } + fingerprint := s.cipher.Fingerprint(token) + credential, err := s.store.FindCredentialByFingerprint(r.Context(), fingerprint) + if err != nil { + s.serverError(w, err) + return + } + if credential == nil { + ciphertext, nonce, encryptErr := s.cipher.Encrypt(token) + if encryptErr != nil { + s.serverError(w, encryptErr) + return + } + created, createErr := s.store.CreateCredential(r.Context(), form["label"], ciphertext, nonce, fingerprint) + if createErr != nil { + s.serverError(w, createErr) + return + } + credential = &created + } + zone, err := s.store.CreateZone(r.Context(), credential.ID, providerID, zoneName) + if err != nil { + s.renderError(w, "domain_new", "Domain konnte nicht hinzugefügt werden: "+safeMessage(err), session.CSRFToken, form) + return + } + imported, importErr := s.scheduler.SyncZone(r.Context(), zone.ID) + if importErr != nil { + s.logger.Warn("initial zone import failed", "zone", zone.Name, "error", importErr) + } + _ = s.store.AddAudit(r.Context(), "zone.connected", "Domain verbunden: "+zone.Name) + noticeText := fmt.Sprintf("%s verbunden; %d A-/AAAA-Ziele und die vorhandenen CNAMEs importiert.", zone.Name, imported) + if importErr != nil { + noticeText = zone.Name + " verbunden, der Import kann jetzt manuell erneut gestartet werden." + } + s.redirectNotice(w, r, "/domains/"+strconv.FormatInt(zone.ID, 10), noticeText) +} + +func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request, session *model.Session) { + zoneID, ok := pathID(w, r) + if !ok { + return + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + if !s.canAccessZone(r.Context(), session, zoneID) { + http.NotFound(w, r) + return + } + var targets []model.Target + if session.Role == "admin" { + targets, err = s.store.ListTargets(r.Context(), zoneID) + } else { + targets, err = s.store.ListAccessibleTargets(r.Context(), session.UserID, zoneID) + } + if err != nil { + s.serverError(w, err) + return + } + var archivedTargets []model.Target + if session.Role == "admin" { + archivedTargets, err = s.store.ListArchivedTargets(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + } + var cnameRecords []model.CNAMERecord + if session.Role == "admin" { + cnameRecords, err = s.store.ListCNAMERecords(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + } + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + canCreateTargets := session.Role == "admin" + manageTargetIDs := make(map[int64]bool, len(targets)) + if session.Role == "admin" { + for _, target := range targets { + manageTargetIDs[target.ID] = true + } + } else { + grants, grantErr := s.store.ListAccessGrants(r.Context(), session.UserID) + if grantErr != nil { + s.serverError(w, grantErr) + return + } + domainManaged := false + for _, grant := range grants { + if grant.ZoneID != zoneID { + continue + } + if grant.TargetID == nil { + domainManaged = grant.CanManageTargets + canCreateTargets = grant.CanCreateTargets + } else if grant.CanManageTargets { + manageTargetIDs[*grant.TargetID] = true + } + } + if domainManaged { + for _, target := range targets { + manageTargetIDs[target.ID] = true + } + } + } + s.render(w, "domain_detail", viewData{Title: zone.Name, Page: "domains", CSRF: session.CSRFToken, Flash: notice(r), Zone: zone, Targets: targets, ArchivedTargets: archivedTargets, CNAMERecords: cnameRecords, Settings: settings, CanCreateTargets: canCreateTargets, ManageTargetIDs: manageTargetIDs, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) +} + +func (s *Server) domainImport(w http.ResponseWriter, r *http.Request, session *model.Session) { + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, ok := pathID(w, r) + if !ok { + return + } + imported, err := s.scheduler.SyncZone(r.Context(), zoneID) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), "Import fehlgeschlagen: "+safeMessage(err), "error") + return + } + s.redirectNotice(w, r, domainURL(zoneID), fmt.Sprintf("Import abgeschlossen: %d neue A-/AAAA-Ziele; CNAMEs wurden synchronisiert.", imported)) +} + +func (s *Server) domainTTLSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, ok := pathID(w, r) + if !ok { + return + } + ttl, err := parseTTL(r.FormValue("ttl")) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + targets, err := s.store.ListTargets(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + cnames, err := s.store.ListCNAMERecords(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + changed, err := s.scheduler.SyncZoneTTL(r.Context(), zoneID, targets, cnames, ttl) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), "TTL konnte nicht für alle Records gesetzt werden: "+safeMessage(err), "error") + return + } + if err := s.store.UpdateZoneTargetTTLs(r.Context(), zoneID, ttl); err != nil { + _, _ = s.scheduler.SyncZone(r.Context(), zoneID) + s.serverError(w, err) + return + } + if err := s.store.UpdateZoneCNAMERecordTTLs(r.Context(), zoneID, ttl); err != nil { + _, _ = s.scheduler.SyncZone(r.Context(), zoneID) + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "zone.ttl.updated", fmt.Sprintf("%s: TTL für %d verwaltete Records auf %d Sekunden gesetzt durch %s", zone.Name, len(targets)+len(cnames), ttl, session.Username)) + s.redirectNotice(w, r, domainURL(zoneID), fmt.Sprintf("TTL für %d verwaltete Records auf %d Sekunden gesetzt (%d RRsets bei Hetzner geändert).", len(targets)+len(cnames), ttl, changed)) +} + +func (s *Server) domainRemove(w http.ResponseWriter, r *http.Request, session *model.Session) { + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, ok := pathID(w, r) + if !ok { + return + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.store.DeleteZone(r.Context(), zoneID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "zone.removed", "Verwaltung beendet: "+zone.Name) + s.redirectNotice(w, r, "/domains", "Die lokale Verwaltung für "+zone.Name+" wurde entfernt; Hetzner-DNS-Records bleiben erhalten.") +} + +func (s *Server) domainDelete(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, ok := pathID(w, r) + if !ok { + return + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + targets, err := s.store.ListTargets(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + archivedTargets, err := s.store.ListArchivedTargets(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + targets = append(targets, archivedTargets...) + cnames, err := s.store.ListCNAMERecords(r.Context(), zoneID) + if err != nil { + s.serverError(w, err) + return + } + deleted, err := s.scheduler.DeleteZoneRecords(r.Context(), zoneID, targets, cnames) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), "Domain konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") + return + } + if err := s.store.DeleteZone(r.Context(), zoneID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "zone.deleted", fmt.Sprintf("Domain vollständig gelöscht: %s, %d Hetzner-RRsets entfernt durch %s", zone.Name, deleted, session.Username)) + s.redirectNotice(w, r, "/domains", fmt.Sprintf("%s und alle Einträge der WebUI wurden gelöscht; %d zugehörige Hetzner-RRsets wurden entfernt.", zone.Name, deleted)) +} + +func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, ok := pathID(w, r) + if !ok { + return + } + hash, err := s.store.PasswordHash(r.Context(), session.UserID) + if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) { + s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error") + return + } + zone, credential, err := s.store.GetZoneWithCredential(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce) + if err != nil { + s.serverError(w, err) + return + } + _ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute)) + _ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name) + s.render(w, "token", viewData{Title: "API-Token anzeigen", Page: "domains", CSRF: session.CSRFToken, Zone: zone, Token: token, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) +} + +func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, err := strconv.ParseInt(r.FormValue("zone_id"), 10, 64) + if err != nil || zoneID < 1 { + http.Error(w, "Ungültige Domain", http.StatusBadRequest) + return + } + if session.Role != "admin" { + allowed, permissionErr := s.store.CanCreateTarget(r.Context(), session.UserID, zoneID) + if permissionErr != nil { + s.serverError(w, permissionErr) + return + } + if !allowed { + http.NotFound(w, r) + return + } + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + recordName, fqdn, err := normalizeRecord(r.FormValue("record_name"), zone.Name) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + if session.Role != "admin" && recordName == "@" { + s.redirectNotice(w, r, domainURL(zoneID), "Mitglieder dürfen nur Subdomains, nicht den Root-Record anlegen.", "error") + return + } + updateIPv4, updateIPv6 := checked(r, "update_ipv4"), checked(r, "update_ipv6") + if updateIPv6 && !settings.IPv6Enabled { + s.redirectNotice(w, r, domainURL(zoneID), "IPv6 ist global deaktiviert und kann für dieses Ziel nicht aktiviert werden.", "error") + return + } + ttl, err := parseTTL(r.FormValue("ttl")) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + description := strings.TrimSpace(r.FormValue("description")) + if len([]rune(description)) > 512 { + s.redirectNotice(w, r, domainURL(zoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") + return + } + activeRequested := checked(r, "active") + active := targetDynDNSActive(activeRequested, updateIPv4, updateIPv6) + activeAutoDisabled := activeRequested && !active + staticIPv4, staticIPv6 := "", "" + if !active { + staticIPv4, staticIPv6, err = parseStaticTargetIPs(r, updateIPv4, updateIPv6) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + } + target, err := s.store.CreateTarget(r.Context(), model.Target{ZoneID: zoneID, RecordName: recordName, FQDN: fqdn, UpdateIPv4: updateIPv4, UpdateIPv6: updateIPv6, Active: active, StaticIPv4: staticIPv4, StaticIPv6: staticIPv6, Description: description, TTL: ttl}) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), "Ziel konnte nicht angelegt werden: "+safeMessage(err), "error") + return + } + if !target.Active && hasStaticIP(target) { + updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target) + if syncErr != nil { + _ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false) + s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") + return + } + _ = s.store.UpdateTargetResult(r.Context(), target.ID, status, "", updated) + } + _ = s.store.AddAudit(r.Context(), "target.created", "DynDNS-Ziel angelegt: "+target.FQDN+" durch "+session.Username) + triggered := target.Active && s.triggerUpdateIfEnabled(r.Context()) + noticeText := "Ziel angelegt." + if activeAutoDisabled { + noticeText += " DynDNS wurde deaktiviert, weil keine IP-Familie ausgewählt ist." + } + if !target.Active && hasStaticIP(target) { + noticeText += " Feste IP gespeichert." + } + s.redirectNotice(w, r, domainURL(zoneID), noticeText+activeNotice(target.Active, triggered)) +} + +func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + targetID, ok := pathID(w, r) + if !ok { + return + } + target, err := s.store.GetTarget(r.Context(), targetID) + if err != nil { + http.NotFound(w, r) + return + } + if !s.canAccessTarget(r.Context(), session, target) { + http.NotFound(w, r) + return + } + if session.Role != "admin" { + allowed, permissionErr := s.store.CanManageTarget(r.Context(), session.UserID, target.ZoneID, target.ID) + if permissionErr != nil { + s.serverError(w, permissionErr) + return + } + if !allowed { + http.NotFound(w, r) + return + } + } + if !target.ArchivedAt.IsZero() { + http.NotFound(w, r) + return + } + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + wasActive := target.Active + previousIPv4, previousIPv6, previousTTL := target.UpdateIPv4, target.UpdateIPv6, target.TTL + if session.Role == "admin" { + target.UpdateIPv4 = checked(r, "update_ipv4") + if checked(r, "update_ipv6") && !settings.IPv6Enabled { + s.redirectNotice(w, r, domainURL(target.ZoneID), "IPv6 ist global deaktiviert und kann für dieses Ziel nicht aktiviert werden.", "error") + return + } + target.UpdateIPv6 = checked(r, "update_ipv6") + } + if !settings.IPv6Enabled { + target.UpdateIPv6 = false + target.StaticIPv6 = "" + } + activeRequested := checked(r, "active") + target.Active = targetDynDNSActive(activeRequested, target.UpdateIPv4, target.UpdateIPv6) + activeAutoDisabled := activeRequested && !target.Active + target.Description = strings.TrimSpace(r.FormValue("description")) + ttl, err := parseEditableTTL(r.FormValue("ttl"), target.TTL) + if err != nil { + s.redirectNotice(w, r, domainURL(target.ZoneID), err.Error(), "error") + return + } + target.TTL = ttl + if len([]rune(target.Description)) > 512 { + s.redirectNotice(w, r, domainURL(target.ZoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") + return + } + if target.Active { + target.StaticIPv4, target.StaticIPv6 = "", "" + } else { + target.StaticIPv4, target.StaticIPv6, err = parseStaticTargetIPs(r, target.UpdateIPv4, target.UpdateIPv6) + if err != nil { + s.redirectNotice(w, r, domainURL(target.ZoneID), err.Error(), "error") + return + } + } + deleted, err := s.scheduler.DeleteTargetFamilies(r.Context(), target, previousIPv4 && !target.UpdateIPv4, previousIPv6 && !target.UpdateIPv6) + if err != nil { + s.redirectNotice(w, r, domainURL(target.ZoneID), "A-/AAAA-Record konnte nicht bei Hetzner gelöscht werden: "+safeMessage(err), "error") + return + } + if previousTTL != target.TTL && (target.Active || !hasStaticIP(target)) { + if err := s.scheduler.SyncTargetTTL(r.Context(), target); err != nil { + s.redirectNotice(w, r, domainURL(target.ZoneID), "TTL konnte nicht bei Hetzner aktualisiert werden: "+safeMessage(err), "error") + return + } + } + if err := s.store.UpdateTarget(r.Context(), target); err != nil { + s.serverError(w, err) + return + } + if !target.Active && hasStaticIP(target) { + updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target) + if syncErr != nil { + _ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false) + s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") + return + } + _ = s.store.UpdateTargetResult(r.Context(), target.ID, status, "", updated) + } else if target.Active { + if err := s.scheduler.SyncTargetComment(r.Context(), target); err != nil { + s.logger.Warn("sync target comment", "target", target.FQDN, "error", err) + } + } + _ = s.store.AddAudit(r.Context(), "target.updated", "DynDNS-Ziel geändert: "+target.FQDN+" durch "+session.Username) + needsUpdate := target.Active && (!wasActive || previousIPv4 != target.UpdateIPv4 || previousIPv6 != target.UpdateIPv6 || previousTTL != target.TTL) + triggered := needsUpdate && s.triggerUpdateIfEnabled(r.Context()) + noticeText := "Ziel gespeichert." + if activeAutoDisabled { + noticeText += " DynDNS wurde deaktiviert, weil keine IP-Familie ausgewählt ist." + } + if deleted > 0 { + noticeText += " A-/AAAA-Record entfernt." + } + s.redirectNotice(w, r, domainURL(target.ZoneID), noticeText+activeNotice(needsUpdate, triggered)) +} + +func (s *Server) targetDelete(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + targetID, ok := pathID(w, r) + if !ok { + return + } + target, err := s.store.GetTarget(r.Context(), targetID) + if err != nil { + http.NotFound(w, r) + return + } + deleted, err := s.scheduler.DeleteTargetFamilies(r.Context(), target, true, true) + if err != nil { + s.redirectNotice(w, r, domainURL(target.ZoneID), "Ziel konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") + return + } + if err := s.store.DeleteTarget(r.Context(), target.ID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "target.deleted", "Ziel vollständig gelöscht: "+target.FQDN+" durch "+session.Username) + s.redirectNotice(w, r, domainURL(target.ZoneID), fmt.Sprintf("Ziel gelöscht; %d A-/AAAA-Record(s) bei Hetzner entfernt.", deleted)) +} + +func (s *Server) targetArchive(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + targetID, ok := pathID(w, r) + if !ok { + return + } + target, err := s.store.GetTarget(r.Context(), targetID) + if err != nil { + http.NotFound(w, r) + return + } + if !target.ArchivedAt.IsZero() { + s.redirectNotice(w, r, domainURL(target.ZoneID), "Die Verknüpfung ist bereits getrennt.", "info") + return + } + if err := s.store.ArchiveTarget(r.Context(), target.ID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "target.archived", "DynDNS-Verknüpfung getrennt: "+target.FQDN+" durch "+session.Username) + s.redirectNotice(w, r, domainURL(target.ZoneID), "Verknüpfung getrennt. Der Hetzner-DNS-Record wurde nicht verändert.") +} + +func (s *Server) targetRestore(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + targetID, ok := pathID(w, r) + if !ok { + return + } + target, err := s.store.GetTarget(r.Context(), targetID) + if err != nil { + http.NotFound(w, r) + return + } + if target.ArchivedAt.IsZero() { + s.redirectNotice(w, r, domainURL(target.ZoneID), "Das Ziel ist bereits verknüpft.", "info") + return + } + if err := s.store.RestoreTarget(r.Context(), target.ID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "target.restored", "DynDNS-Verknüpfung wiederhergestellt: "+target.FQDN+" durch "+session.Username) + s.redirectNotice(w, r, domainURL(target.ZoneID), "Verknüpfung wiederhergestellt. Das Ziel bleibt zunächst deaktiviert.") +} + +func (s *Server) cnameCreate(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + zoneID, err := strconv.ParseInt(r.FormValue("zone_id"), 10, 64) + if err != nil || zoneID < 1 { + http.Error(w, "Ungültige Domain", http.StatusBadRequest) + return + } + zone, err := s.store.GetZone(r.Context(), zoneID) + if err != nil { + http.NotFound(w, r) + return + } + recordName, fqdn, err := normalizeRecord(r.FormValue("record_name"), zone.Name) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + if recordName == "@" { + s.redirectNotice(w, r, domainURL(zoneID), "Ein CNAME kann nicht als Root-Record angelegt werden.", "error") + return + } + destination, err := normalizeCNAMEDestination(r.FormValue("destination")) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + ttl, err := parseTTL(r.FormValue("ttl")) + if err != nil { + s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") + return + } + description := strings.TrimSpace(r.FormValue("description")) + if len([]rune(description)) > 512 { + s.redirectNotice(w, r, domainURL(zoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") + return + } + record := model.CNAMERecord{ZoneID: zoneID, Active: checked(r, "active"), RecordName: recordName, FQDN: fqdn, Destination: destination, Description: description, TTL: ttl} + if record.Active { + if err := s.scheduler.CreateCNAME(r.Context(), record); err != nil { + s.redirectNotice(w, r, domainURL(zoneID), "CNAME konnte nicht angelegt werden: "+safeMessage(err), "error") + return + } + } + created, err := s.store.CreateCNAMERecord(r.Context(), record) + if err != nil { + _, _ = s.scheduler.SyncZone(r.Context(), zoneID) + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "cname.created", "CNAME angelegt: "+created.FQDN+" → "+created.Destination+" durch "+session.Username) + noticeText := "CNAME angelegt." + if !record.Active { + noticeText += " Der Hetzner-Record bleibt deaktiviert." + } + s.redirectNotice(w, r, domainURL(zoneID), noticeText) +} + +func (s *Server) cnameSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + recordID, ok := pathID(w, r) + if !ok { + return + } + previous, err := s.store.GetCNAMERecord(r.Context(), recordID) + if err != nil { + http.NotFound(w, r) + return + } + record := previous + record.Active = checked(r, "active") + record.Destination, err = normalizeCNAMEDestination(r.FormValue("destination")) + if err != nil { + s.redirectNotice(w, r, domainURL(record.ZoneID), err.Error(), "error") + return + } + record.TTL, err = parseEditableTTL(r.FormValue("ttl"), previous.TTL) + if err != nil { + s.redirectNotice(w, r, domainURL(record.ZoneID), err.Error(), "error") + return + } + record.Description = strings.TrimSpace(r.FormValue("description")) + if len([]rune(record.Description)) > 512 { + s.redirectNotice(w, r, domainURL(record.ZoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") + return + } + var providerErr error + switch { + case previous.Active && !record.Active: + providerErr = s.scheduler.DeleteCNAME(r.Context(), previous) + case !previous.Active && record.Active: + providerErr = s.scheduler.CreateCNAME(r.Context(), record) + case previous.Active && record.Active: + providerErr = s.scheduler.UpdateCNAME(r.Context(), previous, record) + } + if providerErr != nil { + s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME konnte nicht gespeichert werden: "+safeMessage(providerErr), "error") + return + } + if err := s.store.UpdateCNAMERecord(r.Context(), record); err != nil { + _, _ = s.scheduler.SyncZone(r.Context(), record.ZoneID) + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "cname.updated", fmt.Sprintf("CNAME geändert: %s, aktiv=%t durch %s", record.FQDN, record.Active, session.Username)) + state := "deaktiviert." + if record.Active { + state = "aktiviert." + } + s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME gespeichert und "+state) +} + +func (s *Server) cnameDelete(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + recordID, ok := pathID(w, r) + if !ok { + return + } + record, err := s.store.GetCNAMERecord(r.Context(), recordID) + if err != nil { + http.NotFound(w, r) + return + } + deleted, err := s.scheduler.DeleteZoneRecords(r.Context(), record.ZoneID, nil, []model.CNAMERecord{record}) + if err != nil { + s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") + return + } + if err := s.store.DeleteCNAMERecord(r.Context(), record.ID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "cname.deleted", "CNAME vollständig gelöscht: "+record.FQDN+" durch "+session.Username) + s.redirectNotice(w, r, domainURL(record.ZoneID), fmt.Sprintf("CNAME vollständig gelöscht; %d Hetzner-RRset entfernt.", deleted)) +} + +func (s *Server) cnameRemove(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + recordID, ok := pathID(w, r) + if !ok { + return + } + record, err := s.store.GetCNAMERecord(r.Context(), recordID) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.store.DeleteCNAMERecord(r.Context(), record.ID); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "cname.removed", "CNAME nur aus WebUI entfernt: "+record.FQDN+" durch "+session.Username) + s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME aus der WebUI entfernt; der Hetzner-Record blieb unverändert.") +} + +func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, "settings", viewData{Title: "Einstellungen", Page: "settings", CSRF: session.CSRFToken, Flash: notice(r), Settings: settings, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) +} + +func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + interval, err := strconv.Atoi(r.FormValue("interval")) + if err != nil || interval < 60 || interval > 86400 { + s.redirectNotice(w, r, "/settings", "Das Intervall muss zwischen 60 und 86.400 Sekunden liegen.", "error") + return + } + updaterEnabled, ipv6Enabled := checked(r, "updater_enabled"), checked(r, "ipv6_enabled") + current, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + ipv4, ipv6 := lines(r.FormValue("ipv4_endpoints")), lines(r.FormValue("ipv6_endpoints")) + if len(ipv4) == 0 { + s.redirectNotice(w, r, "/settings", "Für IPv4 ist mindestens ein HTTPS-Endpoint erforderlich.", "error") + return + } + if len(ipv6) == 0 { + ipv6 = current.IPv6Endpoints + } + if ipv6Enabled && len(ipv6) == 0 { + s.redirectNotice(w, r, "/settings", "Für aktiviertes IPv6 ist mindestens ein HTTPS-Endpoint erforderlich.", "error") + return + } + if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil { + s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error") + return + } + if ipv6Enabled { + if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil { + s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error") + return + } + } + deletedIPv6 := 0 + if !ipv6Enabled { + deletedIPv6, err = s.scheduler.DisableIPv6(r.Context()) + if err != nil { + s.redirectNotice(w, r, "/settings", "IPv6 konnte nicht vollständig deaktiviert werden: "+safeMessage(err), "error") + return + } + } + if err := s.store.UpdateSettings(r.Context(), interval, updaterEnabled, ipv6Enabled, ipv4, ipv6); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "settings.updated", fmt.Sprintf("Updater geändert: Dienst=%t, IPv6=%t, Intervall=%ds, entfernte AAAA=%d", updaterEnabled, ipv6Enabled, interval, deletedIPv6)) + s.scheduler.Trigger() + noticeText := "Einstellungen gespeichert." + if !ipv6Enabled { + noticeText += fmt.Sprintf(" %d AAAA-Record(s) entfernt und IPv6 bei allen Zielen deaktiviert.", deletedIPv6) + } + s.redirectNotice(w, r, "/settings", noticeText) +} + +func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.Session) { + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + if !settings.UpdaterEnabled { + s.redirectNotice(w, r, "/settings", "Der Updater ist deaktiviert. Aktiviere zuerst den Dienst.", "info") + return + } + s.scheduler.Trigger() + s.redirectNotice(w, r, "/", "Aktualisierung gestartet.") +} + +func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + targets, err := s.store.ListTargetsWithErrors(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + zones, err := s.store.ListZonesWithErrors(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + runs, err := s.store.FailedRuns(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorCount: len(targets) + len(zones) + len(runs), Now: time.Now()}) +} + +func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) { + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + if err := s.store.ClearErrors(r.Context()); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "errors.cleared", "Fehlerliste geleert durch "+session.Username) + s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.") +} + +func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) +} + +func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + hash, err := s.store.PasswordHash(r.Context(), session.UserID) + if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) { + s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error") + return + } + if r.FormValue("new_password") != r.FormValue("confirm_password") { + s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error") + return + } + newHash, err := secure.HashPassword(r.FormValue("new_password")) + if err != nil { + s.redirectNotice(w, r, "/security", err.Error(), "error") + return + } + if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil { + s.serverError(w, err) + return + } + _ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID) + _ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username) + s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.") +} + +func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + active, _ := s.store.CountActiveTargets(r.Context()) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "last_ipv4": settings.LastIPv4, "last_ipv6": settings.LastIPv6, "last_run_at": settings.LastRunAt, "last_run_status": settings.LastRunStatus, + "next_run_at": s.scheduler.NextRunAt(), "active_targets": active, "running": s.scheduler.IsRunning(), "enabled": settings.UpdaterEnabled, "ipv6_enabled": settings.IPv6Enabled, + }) +} + +func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{ + "next_run_unix": unixSeconds(s.scheduler.NextRunAt()), + "running": s.scheduler.IsRunning(), + "enabled": settings.UpdaterEnabled, + }) +} + +func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + users, err := s.store.ListUsers(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()}) +} + +func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()}) +} + +func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + form := map[string]string{"username": strings.TrimSpace(r.FormValue("username")), "display_name": strings.TrimSpace(r.FormValue("display_name")), "role": r.FormValue("role")} + username, err := normalizeUsername(form["username"]) + if err != nil { + s.renderUserNewError(w, session, err.Error(), form) + return + } + if form["display_name"] == "" || len([]rune(form["display_name"])) > 80 { + s.renderUserNewError(w, session, "Bitte einen Anzeigenamen mit höchstens 80 Zeichen angeben.", form) + return + } + if form["role"] != "admin" && form["role"] != "member" { + s.renderUserNewError(w, session, "Ungültige Rolle.", form) + return + } + if r.FormValue("password") != r.FormValue("confirm_password") { + s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form) + return + } + hash, err := secure.HashPassword(r.FormValue("password")) + if err != nil { + s.renderUserNewError(w, session, err.Error(), form) + return + } + user, err := s.store.CreateUser(r.Context(), username, form["display_name"], hash, form["role"]) + if err != nil { + s.renderUserNewError(w, session, "Benutzer konnte nicht angelegt werden: "+safeMessage(err), form) + return + } + _ = s.store.AddAudit(r.Context(), "user.created", "Benutzer angelegt: "+user.Username+" durch "+session.Username) + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(user.ID, 10), "Benutzer angelegt. Weise jetzt Domains oder einzelne Hosts zu.") +} + +func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *model.Session) { + userID, ok := pathID(w, r) + if !ok { + return + } + managedUser, err := s.store.GetUser(r.Context(), userID) + if err != nil { + http.NotFound(w, r) + return + } + zones, err := s.store.ListZones(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + grants, err := s.store.ListAccessGrants(r.Context(), userID) + if err != nil { + s.serverError(w, err) + return + } + domainGrants := make(map[int64]bool) + domainManaged := make(map[int64]bool) + createTargetGrants := make(map[int64]bool) + targetGrants := make(map[int64]bool) + managedTargetGrants := make(map[int64]bool) + for _, grant := range grants { + if grant.TargetID == nil { + domainGrants[grant.ZoneID] = true + domainManaged[grant.ZoneID] = grant.CanManageTargets + createTargetGrants[grant.ZoneID] = grant.CanCreateTargets + } else { + targetGrants[*grant.TargetID] = true + managedTargetGrants[*grant.TargetID] = grant.CanManageTargets + } + } + accessZones := make([]accessZone, 0, len(zones)) + for _, zone := range zones { + targets, targetErr := s.store.ListTargets(r.Context(), zone.ID) + if targetErr != nil { + s.serverError(w, targetErr) + return + } + accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants}) + } + s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()}) +} + +func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + userID, ok := pathID(w, r) + if !ok { + return + } + managedUser, err := s.store.GetUser(r.Context(), userID) + if err != nil { + http.NotFound(w, r) + return + } + if managedUser.IsAdmin() { + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Admins haben immer vollständigen Zugriff; Einzelrechte sind nicht nötig.", "info") + return + } + selection := store.AccessSelection{} + for key, values := range r.Form { + if len(values) == 0 || values[0] == "" { + continue + } + if raw, found := strings.CutPrefix(key, "zone_"); found { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + selection.ZoneIDs = append(selection.ZoneIDs, id) + } + } + if raw, found := strings.CutPrefix(key, "target_"); found { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + selection.TargetIDs = append(selection.TargetIDs, id) + } + } + if raw, found := strings.CutPrefix(key, "manage_zone_"); found { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + selection.ManageZoneIDs = append(selection.ManageZoneIDs, id) + } + } + if raw, found := strings.CutPrefix(key, "manage_target_"); found { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + selection.ManageTargetIDs = append(selection.ManageTargetIDs, id) + } + } + if raw, found := strings.CutPrefix(key, "create_zone_"); found { + if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { + selection.CreateTargetZoneIDs = append(selection.CreateTargetZoneIDs, id) + } + } + } + if err := s.store.ReplaceAccessGrants(r.Context(), userID, selection); err != nil { + s.serverError(w, err) + return + } + _ = s.store.AddAudit(r.Context(), "user.access_updated", "Zugriffe geändert: "+managedUser.Username+" durch "+session.Username) + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Zugriffsrechte gespeichert.") +} + +func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + userID, ok := pathID(w, r) + if !ok { + return + } + managedUser, err := s.store.GetUser(r.Context(), userID) + if err != nil { + http.NotFound(w, r) + return + } + displayName := strings.TrimSpace(r.FormValue("display_name")) + role, active := r.FormValue("role"), checked(r, "active") + if displayName == "" || len([]rune(displayName)) > 80 || (role != "admin" && role != "member") { + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error") + return + } + if managedUser.IsAdmin() && (role != "admin" || !active) { + count, countErr := s.store.CountActiveAdmins(r.Context()) + if countErr != nil { + s.serverError(w, countErr) + return + } + if count <= 1 { + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error") + return + } + } + if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil { + s.serverError(w, err) + return + } + if role != managedUser.Role || active != managedUser.Active { + _ = s.store.DeleteUserSessions(r.Context(), userID) + } + _ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username) + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.") +} + +func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + userID, ok := pathID(w, r) + if !ok { + return + } + managedUser, err := s.store.GetUser(r.Context(), userID) + if err != nil { + http.NotFound(w, r) + return + } + if r.FormValue("password") != r.FormValue("confirm_password") { + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Die Passwörter stimmen nicht überein.", "error") + return + } + hash, err := secure.HashPassword(r.FormValue("password")) + if err != nil { + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), err.Error(), "error") + return + } + if err := s.store.SetUserPassword(r.Context(), userID, hash); err != nil { + s.serverError(w, err) + return + } + _ = s.store.DeleteUserSessions(r.Context(), userID) + _ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username) + s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.") +} + +func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := s.session(r.Context(), r) + if err != nil { + s.serverError(w, err) + return + } + if session == nil { + s.redirectNotice(w, r, "/login", "Bitte melde dich an.", "info") + return + } + next(w, r, session) + }) +} + +func (s *Server) adminOnly(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler { + return s.authenticated(func(w http.ResponseWriter, r *http.Request, session *model.Session) { + if session.Role != "admin" { + http.Error(w, "Diese Aktion erfordert Administratorrechte.", http.StatusForbidden) + return + } + next(w, r, session) + }) +} + +func (s *Server) zonesForSession(ctx context.Context, session *model.Session) ([]model.Zone, error) { + if session.Role == "admin" { + return s.store.ListZones(ctx) + } + return s.store.ListAccessibleZones(ctx, session.UserID) +} + +func (s *Server) activeTargetsForSession(ctx context.Context, session *model.Session) (int, error) { + if session.Role == "admin" { + return s.store.CountActiveTargets(ctx) + } + return s.store.CountActiveAccessibleTargets(ctx, session.UserID) +} + +func (s *Server) canAccessZone(ctx context.Context, session *model.Session, zoneID int64) bool { + if session.Role == "admin" { + return true + } + allowed, err := s.store.CanAccessZone(ctx, session.UserID, zoneID) + return err == nil && allowed +} + +func (s *Server) canAccessTarget(ctx context.Context, session *model.Session, target model.Target) bool { + if session.Role == "admin" { + return true + } + allowed, err := s.store.CanAccessTarget(ctx, session.UserID, target.ZoneID, target.ID) + return err == nil && allowed +} + +func userFromSession(session *model.Session) model.User { + return model.User{ID: session.UserID, Username: session.Username, DisplayName: session.DisplayName, Role: session.Role, Active: true} +} + +func (s *Server) session(ctx context.Context, r *http.Request) (*model.Session, error) { + cookie, err := r.Cookie("dyndns_session") + if err != nil || cookie.Value == "" { + return nil, nil + } + return s.store.GetSession(ctx, secure.HashToken(cookie.Value)) +} + +func (s *Server) startSession(w http.ResponseWriter, ctx context.Context, user model.User) error { + token, err := secure.RandomToken(32) + if err != nil { + return err + } + csrf, err := secure.RandomToken(32) + if err != nil { + return err + } + _, err = s.store.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: secure.HashToken(token), CSRFToken: csrf, ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL)}) + if err != nil { + return err + } + http.SetCookie(w, &http.Cookie{Name: "dyndns_session", Value: token, Path: "/", MaxAge: int(s.config.SessionTTL.Seconds()), HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) + return nil +} + +func (s *Server) clearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: "dyndns_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) +} + +func (s *Server) publicCSRF(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie("dyndns_public_csrf"); err == nil && len(cookie.Value) >= 32 { + return cookie.Value + } + token, err := secure.RandomToken(24) + if err != nil { + return "" + } + http.SetCookie(w, &http.Cookie{Name: "dyndns_public_csrf", Value: token, Path: "/", MaxAge: 3600, HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) + return token +} + +func (s *Server) validPublicCSRF(r *http.Request) bool { + cookie, err := r.Cookie("dyndns_public_csrf") + if err != nil || cookie.Value == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(r.FormValue("csrf"))) == 1 +} + +func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool { + return subtle.ConstantTimeCompare([]byte(session.CSRFToken), []byte(r.FormValue("csrf"))) == 1 +} + +func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if err := r.ParseForm(); err != nil { + http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest) + return err + } + return nil +} + +func (s *Server) triggerUpdateIfEnabled(ctx context.Context) bool { + settings, err := s.store.GetSettings(ctx) + if err != nil || !settings.UpdaterEnabled { + return false + } + s.scheduler.Trigger() + return true +} + +func (s *Server) render(w http.ResponseWriter, name string, data viewData) { + if data.Mode == "" { + data.Mode = s.config.Mode + } + if data.Page != "setup" && data.Page != "login" { + if settings, err := s.store.GetSettings(context.Background()); err == nil { + data.UpdaterEnabled = settings.UpdaterEnabled + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := s.templates[name].ExecuteTemplate(w, name, data); err != nil { + s.logger.Error("render template", "template", name, "error", err) + } +} + +func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string, form map[string]string) { + s.render(w, name, viewData{Title: "Eingabe prüfen", Page: name, Mode: s.config.Mode, CSRF: csrf, Flash: newFlash(message, "error"), Form: form, Now: time.Now()}) +} + +func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) { + s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) +} + +func (s *Server) serverError(w http.ResponseWriter, err error) { + s.logger.Error("request failed", "error", err) + http.Error(w, "Interner Fehler. Details stehen nicht in der Oberfläche.", http.StatusInternalServerError) +} + +func (s *Server) redirectNotice(w http.ResponseWriter, r *http.Request, target, message string, kinds ...string) { + kind := "success" + if len(kinds) > 0 { + kind = kinds[0] + } + location, err := url.Parse(target) + if err != nil { + s.serverError(w, err) + return + } + query := location.Query() + query.Set("notice", strings.TrimSpace(message)) + query.Set("notice_kind", normalizeFlashKind(kind)) + location.RawQuery = query.Encode() + http.Redirect(w, r, location.String(), http.StatusSeeOther) +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} + +func loadTemplates() (map[string]*template.Template, error) { + pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "settings", "errors", "security", "token", "users", "user_new", "user_detail"} + functions := template.FuncMap{ + "formatTime": func(value time.Time) string { + if value.IsZero() { + return "–" + } + return value.Local().Format("02.01.2006 · 15:04") + }, + "formatEndpoints": func(values []string) string { return strings.Join(values, "\n") }, + "unixSeconds": unixSeconds, + "initial": func(value string) string { + for _, character := range strings.TrimSpace(value) { + return strings.ToUpper(string(character)) + } + return "U" + }, + "statusClass": statusClass, + "ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} }, + "supportedTTL": supportedTTL, + "families": func(target model.Target) string { + var result []string + if target.UpdateIPv4 { + result = append(result, "A") + } + if target.UpdateIPv6 { + result = append(result, "AAAA") + } + return strings.Join(result, " + ") + }, + } + result := make(map[string]*template.Template, len(pages)) + for _, page := range pages { + templateSet, err := template.New("layout").Funcs(functions).ParseFS(embeddedAssets, "templates/layout.html", "templates/"+page+".html") + if err != nil { + return nil, fmt.Errorf("parse %s template: %w", page, err) + } + result[page] = templateSet + } + return result, nil +} + +func unixSeconds(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} + +func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + http.Error(w, "Ungültige Kennung", http.StatusBadRequest) + return 0, false + } + return id, true +} + +func normalizeZone(raw string) (string, error) { + zone, err := idna.Lookup.ToASCII(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")) + if err != nil || strings.Count(zone, ".") < 1 || len(zone) > 253 { + return "", errors.New("Bitte eine gültige Domain angeben, z. B. example.de") + } + return zone, nil +} + +func normalizeUsername(raw string) (string, error) { + username := strings.ToLower(strings.TrimSpace(raw)) + if len(username) < 3 || len(username) > 40 { + return "", errors.New("Benutzername muss 3 bis 40 Zeichen lang sein") + } + for _, character := range username { + if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '_' && character != '-' && character != '.' { + return "", errors.New("Benutzername darf nur Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich enthalten") + } + } + return username, nil +} + +func normalizeRecord(raw, zone string) (string, string, error) { + value := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") + if value == "" || value == "@" { + return "@", zone, nil + } + if strings.HasSuffix(value, "."+zone) { + value = strings.TrimSuffix(value, "."+zone) + } + value, err := idna.Lookup.ToASCII(value) + if err != nil || value == "" || len(value) > 253 || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { + return "", "", errors.New("Bitte eine gültige Subdomain angeben") + } + for _, label := range strings.Split(value, ".") { + if len(label) == 0 || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return "", "", errors.New("Bitte eine gültige Subdomain angeben") + } + } + return value, value + "." + zone, nil +} + +func normalizeCNAMEDestination(raw string) (string, error) { + value := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") + value, err := idna.Lookup.ToASCII(value) + if err != nil || value == "" || value == "@" || len(value) > 253 || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") || net.ParseIP(value) != nil { + return "", errors.New("Bitte ein gültiges CNAME-Ziel als Domain angeben") + } + for _, label := range strings.Split(value, ".") { + if len(label) == 0 || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return "", errors.New("Bitte ein gültiges CNAME-Ziel als Domain angeben") + } + } + return value + ".", nil +} + +func parseStaticTargetIPs(r *http.Request, updateIPv4, updateIPv6 bool) (string, string, error) { + staticIPv4, staticIPv6 := "", "" + var err error + if updateIPv4 { + if strings.TrimSpace(r.FormValue("static_ipv4")) == "" { + return "", "", errors.New("Für IPv4 ist bei deaktiviertem DynDNS eine feste IPv4-Adresse erforderlich") + } + staticIPv4, err = normalizeStaticIP(r.FormValue("static_ipv4"), 4) + if err != nil { + return "", "", err + } + } + if updateIPv6 { + if strings.TrimSpace(r.FormValue("static_ipv6")) == "" { + return "", "", errors.New("Für IPv6 ist bei deaktiviertem DynDNS eine feste IPv6-Adresse erforderlich") + } + staticIPv6, err = normalizeStaticIP(r.FormValue("static_ipv6"), 6) + if err != nil { + return "", "", err + } + } + return staticIPv4, staticIPv6, nil +} + +func targetDynDNSActive(requested, updateIPv4, updateIPv6 bool) bool { + return requested && (updateIPv4 || updateIPv6) +} + +func normalizeStaticIP(raw string, family int) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + address, err := netip.ParseAddr(value) + if err != nil { + return "", fmt.Errorf("Bitte eine gültige IPv%d-Adresse angeben", family) + } + address = address.Unmap() + if (family == 4 && !address.Is4()) || (family == 6 && !address.Is6()) { + return "", fmt.Errorf("Bitte eine gültige IPv%d-Adresse angeben", family) + } + return address.String(), nil +} + +func hasStaticIP(target model.Target) bool { + return target.StaticIPv4 != "" || target.StaticIPv6 != "" +} + +func parseTTL(raw string) (int, error) { + ttl, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || !supportedTTL(ttl) { + return 0, errors.New("TTL muss 60, 300, 600, 1800, 3600 oder 86400 Sekunden betragen") + } + return ttl, nil +} + +func parseEditableTTL(raw string, current int) (int, error) { + ttl, err := parseTTL(raw) + if err == nil { + return ttl, nil + } + preserved, parseErr := strconv.Atoi(strings.TrimSpace(raw)) + if parseErr == nil && preserved == current && preserved >= 60 { + return preserved, nil + } + return 0, err +} + +func supportedTTL(ttl int) bool { + switch ttl { + case 60, 300, 600, 1800, 3600, 86400: + return true + default: + return false + } +} + +func lines(raw string) []string { + seen := make(map[string]bool) + var result []string + for _, line := range strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' }) { + line = strings.TrimSpace(line) + if line != "" && !seen[line] { + seen[line] = true + result = append(result, line) + } + } + return result +} + +func checked(r *http.Request, key string) bool { + return r.FormValue(key) == "on" || r.FormValue(key) == "true" || r.FormValue(key) == "1" +} +func domainURL(id int64) string { return "/domains/" + strconv.FormatInt(id, 10) } +func activeNotice(updateNeeded, triggered bool) string { + if triggered { + return " Die Aktualisierung wurde direkt gestartet." + } + if updateNeeded { + return " Der Updater ist deaktiviert; die Synchronisierung startet nach dem Aktivieren des Dienstes." + } + return "" +} +func notice(r *http.Request) *flashMessage { + return newFlash(r.URL.Query().Get("notice"), r.URL.Query().Get("notice_kind")) +} +func newFlash(message, kind string) *flashMessage { + message = strings.TrimSpace(message) + if message == "" { + return nil + } + return &flashMessage{Message: message, Kind: normalizeFlashKind(kind)} +} +func normalizeFlashKind(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "success": + return "success" + case "error": + return "error" + default: + return "info" + } +} +func safeMessage(err error) string { + if err == nil { + return "" + } + message := strings.TrimSpace(err.Error()) + if len(message) > 240 { + return message[:240] + } + return message +} +func statusClass(value string) string { + value = strings.ToLower(value) + switch { + case strings.Contains(value, "ok"), strings.Contains(value, "unverändert"), strings.Contains(value, "aktualisiert"), strings.Contains(value, "angelegt"): + return "status-good" + case strings.Contains(value, "error"), strings.Contains(value, "fehler"), strings.Contains(value, "partial"): + return "status-bad" + default: + return "status-warn" + } +} +func remoteIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "unknown" + } + if address, err := netip.ParseAddr(host); err == nil { + return address.String() + } + return "unknown" +} + +type loginLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time +} + +func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} } +func (l *loginLimiter) Allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + cutoff := time.Now().Add(-15 * time.Minute) + values := l.attempts[ip] + kept := values[:0] + for _, value := range values { + if value.After(cutoff) { + kept = append(kept, value) + } + } + l.attempts[ip] = kept + return len(kept) < 5 +} +func (l *loginLimiter) Fail(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + l.attempts[ip] = append(l.attempts[ip], time.Now()) +} +func (l *loginLimiter) Reset(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, ip) +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..92f4e4f --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,216 @@ +package web + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" +) + +func TestInputNormalizers(t *testing.T) { + t.Parallel() + username, err := normalizeUsername("Alice_Admin") + if err != nil || username != "alice_admin" { + t.Fatalf("username normalization failed: %q, %v", username, err) + } + if _, err := normalizeUsername("not allowed!"); err == nil { + t.Fatal("invalid username accepted") + } + zone, err := normalizeZone("BÜCHER.example.") + if err != nil || zone != "xn--bcher-kva.example" { + t.Fatalf("zone normalization failed: %q, %v", zone, err) + } + record, fqdn, err := normalizeRecord("home", "example.de") + if err != nil || record != "home" || fqdn != "home.example.de" { + t.Fatalf("record normalization failed: %q %q %v", record, fqdn, err) + } + destination, err := normalizeCNAMEDestination("BÜCHER.example.") + if err != nil || destination != "xn--bcher-kva.example." { + t.Fatalf("CNAME destination normalization failed: %q %v", destination, err) + } + if _, err := normalizeCNAMEDestination("198.51.100.20"); err == nil { + t.Fatal("IP address accepted as CNAME destination") + } + if ttl, err := parseTTL("300"); err != nil || ttl != 300 { + t.Fatalf("TTL parsing failed: %d %v", ttl, err) + } + if _, err := parseTTL("120"); err == nil { + t.Fatal("unsupported TTL accepted") + } + if ttl, err := parseEditableTTL("120", 120); err != nil || ttl != 120 { + t.Fatalf("existing unsupported TTL could not be preserved: %d %v", ttl, err) + } + staticIPv4, err := normalizeStaticIP("203.0.113.10", 4) + if err != nil || staticIPv4 != "203.0.113.10" { + t.Fatalf("static IPv4 normalization failed: %q %v", staticIPv4, err) + } + if _, err := normalizeStaticIP("203.0.113.10", 6); err == nil { + t.Fatal("IPv4 accepted as static IPv6") + } + missingStaticIP := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=")) + missingStaticIP.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if _, _, err := parseStaticTargetIPs(missingStaticIP, true, false); err == nil { + t.Fatal("inactive DynDNS target accepted enabled IPv4 without a static address") + } + staticAddresses := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=203.0.113.20&static_ipv6=2001%3Adb8%3A%3A20")) + staticAddresses.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ipv4, ipv6, err := parseStaticTargetIPs(staticAddresses, true, true) + if err != nil || ipv4 != "203.0.113.20" || ipv6 != "2001:db8::20" { + t.Fatalf("valid static addresses rejected: ipv4=%q ipv6=%q err=%v", ipv4, ipv6, err) + } + if targetDynDNSActive(true, false, false) { + t.Fatal("DynDNS remained active without an address family") + } + if !targetDynDNSActive(true, true, false) { + t.Fatal("IPv4-only DynDNS was rejected") + } +} + +func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) { + t.Parallel() + if got := unixSeconds(time.Time{}); got != 0 { + t.Fatalf("zero time must be exposed as unix 0, got %d", got) + } + value := time.Date(2026, time.July, 11, 12, 30, 0, 0, time.UTC) + if got := unixSeconds(value); got != value.Unix() { + t.Fatalf("unexpected unix time: got %d, want %d", got, value.Unix()) + } +} + +func TestNoticeUsesValidatedKind(t *testing.T) { + t.Parallel() + request := httptest.NewRequest(http.MethodGet, "/login?notice=Passwort+nicht+korrekt.¬ice_kind=error", nil) + got := notice(request) + if got == nil || got.Message != "Passwort nicht korrekt." || got.Kind != "error" { + t.Fatalf("unexpected notice: %#v", got) + } + + request = httptest.NewRequest(http.MethodGet, "/login?notice=Hinweis¬ice_kind=not-a-valid-kind", nil) + got = notice(request) + if got == nil || got.Kind != "info" { + t.Fatalf("invalid kind must fall back to info: %#v", got) + } + + if got := notice(httptest.NewRequest(http.MethodGet, "/login", nil)); got != nil { + t.Fatalf("empty notice must not render a toast: %#v", got) + } +} + +func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) { + t.Parallel() + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/settings", nil) + server := &Server{} + server.redirectNotice(response, request, "/domains?view=active", "Gespeichert.") + + if response.Code != http.StatusSeeOther { + t.Fatalf("unexpected status: got %d, want %d", response.Code, http.StatusSeeOther) + } + location, err := response.Result().Location() + if err != nil { + t.Fatalf("parse redirect location: %v", err) + } + if got := location.Query().Get("view"); got != "active" { + t.Fatalf("existing query lost: %q", got) + } + if got := location.Query().Get("notice"); got != "Gespeichert." { + t.Fatalf("unexpected notice: %q", got) + } + if got := location.Query().Get("notice_kind"); got != "success" { + t.Fatalf("unexpected notice kind: %q", got) + } +} + +func TestLoginErrorRendersAsToast(t *testing.T) { + t.Parallel() + templates, err := loadTemplates() + if err != nil { + t.Fatalf("load templates: %v", err) + } + var rendered bytes.Buffer + data := viewData{ + Title: "Eingabe prüfen", + Page: "login", + Flash: newFlash("Passwort nicht korrekt.", "error"), + } + if err := templates["login"].ExecuteTemplate(&rendered, "login", data); err != nil { + t.Fatalf("render login: %v", err) + } + body := rendered.String() + for _, expected := range []string{`class="toast toast-error"`, `data-toast`, `role="alert"`, `Passwort nicht korrekt.`} { + if !strings.Contains(body, expected) { + t.Errorf("rendered login is missing %q", expected) + } + } + if strings.Contains(body, `class="flash"`) { + t.Error("legacy in-flow flash message is still rendered") + } +} + +func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) { + t.Parallel() + templates, err := loadTemplates() + if err != nil { + t.Fatalf("load templates: %v", err) + } + var rendered bytes.Buffer + data := viewData{ + Title: "example.de", + Page: "domains", + CSRF: "csrf", + IsAdmin: true, + Zone: model.Zone{ID: 1, Name: "example.de"}, + Settings: model.Settings{IPv6Enabled: true}, + Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}}, + CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}}, + } + if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil { + t.Fatalf("render domain detail: %v", err) + } + body := rendered.String() + statusAt := strings.Index(body, `A unverändert`) + timeAt := strings.Index(body, `class="target-meta">