From 81def77e3c835a97c223703f0ea66ad8f9842423 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 10 Oct 2026 02:26:47 +0200 Subject: [PATCH] fix: harden DynDNS recovery and publish v1.5.1 --- AGENTS.md | 6 +- Dockerfile | 4 +- README.md | 67 ++++- UPDATE.md | 28 +- cmd/dyndns-web/main.go | 21 +- compose.yml | 2 +- go.mod | 4 +- internal/appupdate/manager.go | 83 ++++-- internal/appupdate/manager_test.go | 130 ++++++++- internal/buildinfo/version.go | 2 +- internal/diagnostics/diagnostics.go | 329 +++++++++++++++++++++++ internal/diagnostics/diagnostics_test.go | 91 +++++++ internal/hetzner/client.go | 114 +++++++- internal/hetzner/client_test.go | 103 ++++++- internal/ipresolve/resolver.go | 126 ++++++--- internal/ipresolve/resolver_test.go | 112 ++++++++ internal/netresolve/resolver.go | 158 +++++++++++ internal/netresolve/resolver_test.go | 120 +++++++++ internal/pihole/client.go | 21 +- internal/scheduler/scheduler.go | 162 +++++++---- internal/scheduler/scheduler_test.go | 195 +++++++++++++- internal/store/store.go | 44 +++ internal/store/store_test.go | 43 +++ internal/updateagent/controller.go | 15 +- internal/updateagent/controller_test.go | 27 ++ internal/updateagent/docker.go | 73 +++-- internal/updateagent/docker_test.go | 133 +++++++++ internal/updateprotocol/protocol.go | 2 + internal/web/diagnostics.go | 70 +++++ internal/web/diagnostics_test.go | 109 ++++++++ internal/web/pihole.go | 54 ++-- internal/web/server.go | 74 +++-- internal/web/server_test.go | 115 +++++++- internal/web/templates/errors.html | 3 +- internal/web/templates/layout.html | 4 +- internal/web/templates/pihole.html | 6 +- internal/web/templates/system.html | 15 +- 37 files changed, 2415 insertions(+), 250 deletions(-) create mode 100644 internal/diagnostics/diagnostics.go create mode 100644 internal/diagnostics/diagnostics_test.go create mode 100644 internal/netresolve/resolver.go create mode 100644 internal/netresolve/resolver_test.go create mode 100644 internal/web/diagnostics.go create mode 100644 internal/web/diagnostics_test.go diff --git a/AGENTS.md b/AGENTS.md index dde0bdd..4524831 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewu ```bash docker run --rm \ -v "$PWD":/src \ - -w /src golang:1.25.13-alpine \ + -w /src golang:1.26.9-alpine \ gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go ``` @@ -88,7 +88,7 @@ docker run --rm \ -v "$PWD":/src \ -v /tmp/dyndns-go-mod:/go/pkg/mod \ -v /tmp/dyndns-go-build:/root/.cache/go-build \ - -w /src golang:1.25.13-alpine \ + -w /src golang:1.26.9-alpine \ sh -c 'apk add --no-cache build-base >/dev/null && go vet ./... && go test -race -count=1 ./...' ``` @@ -99,7 +99,7 @@ docker run --rm \ -v "$PWD":/src \ -v /tmp/dyndns-go-mod:/go/pkg/mod \ -v /tmp/dyndns-go-build:/root/.cache/go-build \ - -w /src golang:1.25.13-alpine \ + -w /src golang:1.26.9-alpine \ sh -c 'apk add --no-cache build-base >/dev/null && go run golang.org/x/vuln/cmd/govulncheck@latest ./...' ``` diff --git a/Dockerfile b/Dockerfile index 77893f4..4d6c7d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # syntax=docker/dockerfile:1.7 -ARG VERSION=v1.5.0 +ARG VERSION=v1.5.1 ARG MINIMUM_UPDATE_VERSION=v1.4.0 ARG MINIMUM_AGENT_PROTOCOL=2 ARG RUNTIME_CONTRACT_VERSION=2 ARG DATABASE_CONTRACT_VERSION=1 ARG SCHEMA_VERSION=11 -FROM golang:1.25.13-alpine AS build +FROM golang:1.26.9-alpine AS build ARG VERSION diff --git a/README.md b/README.md index db73d96..08a0768 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] @@ -61,7 +61,7 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose, Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md). -Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Gitea-Benutzername und wahlweise Passwort oder Token werden einmalig in der Weboberfläche hinterlegt; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. +Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Öffentliche Releases und öffentliche Container-Images funktionieren ohne Anmeldung. Für private Repositories oder Images können Gitea-Benutzername und Passwort oder Token einmalig hinterlegt werden; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. `v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an. @@ -94,8 +94,69 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü - eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung - lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen -Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen. +Die Pi-hole-Seite zeigt sofort den gespeicherten Stand. **Records neu einlesen** aktualisiert ihn mit einer begrenzten API-Abfrage. Eine nicht mehr erreichbare Verbindung kann über **Lokale Verbindung entfernen** entfernt werden. Der Container muss die verbundene Pi-hole-Adresse für API-Aktionen direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen. Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`. Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen. + +## Netzwerkausfälle und Diagnose + +Der Build und die Container-Prüfungen verwenden Go 1.26.9. Damit sind auch +die vom Sicherheitscheck gemeldeten HTTP-/Template-Probleme der bisherigen +Go-1.25.13-Basis behoben (siehe etwa [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)). + +Nach fehlgeschlagenen DynDNS-Läufen folgen weitere Versuche nach 5, 10, 20, +40 und höchstens 60 Sekunden; nach einem erfolgreichen Lauf gilt wieder das +konfigurierte Intervall. Ein IP-Endpunkt hat einschließlich DNS, TLS und +Antwort höchstens zehn Sekunden Zeit. IPv4 und IPv6 werden unabhängig und +parallel ermittelt. Fehlgeschlagene Abfragen liefern keine alte IP für ein +DNS-Update; vorhandene DNS-Records bleiben dann bestehen. + +Hetzner-Änderungen gelten erst dann als erfolgreich, wenn der asynchrone +API-Auftrag `success` meldet; `running`, spätere Fehler und Zeitüberschreitungen +werden nicht als Erfolg gespeichert. Die Bestätigung wird bis zu 20 Sekunden +abgewartet. Ein Fehler beim A-Record verhindert die Aktualisierung des +AAAA-Records nicht und umgekehrt; bestätigte Teiländerungen bleiben sichtbar. +Siehe den [Hetzner-API-Vertrag für Actions](https://docs.hetzner.cloud/reference/cloud#actions). + +WebUI-Änderungen an DNS-Zielen und automatische Läufe werden vollständig +nacheinander ausgeführt, damit ein alter Lauf keine gelöschten Ziele neu +anlegt oder feste Adressen überschreibt. Dauert ein laufender Vorgang länger +als fünf Sekunden, wird die neue WebUI-Änderung mit einer Meldung zum erneuten +Senden abgewiesen. Unveränderte IP-Endpunkte brauchen beim Speichern der +Updater-Einstellungen keine erneute DNS-Prüfung. Neue Endpunkte werden weiter +geprüft (höchstens acht je IP-Familie). + +Laufhistorie und letzter Status werden gemeinsam gespeichert, auch nach einem +kontrollierten Abbruch. Beim Prozessstart werden durch einen Absturz offene +Läufe als unterbrochen markiert. Der aktive Updater beginnt automatisch mit +einer neuen Prüfung; beim Herunterfahren wird sein Abschluss abgewartet. + +Bei einem vorübergehenden Fehler des System-DNS (Timeout/SERVFAIL) werden +**nur** `api.ipify.org`, `api6.ipify.org`, `ifconfig.co` und +`api.hetzner.cloud` zusätzlich direkt über Cloudflare und Quad9 aufgelöst +(`1.1.1.1`, `9.9.9.9`, `2606:4700:4700::1111`, `2620:fe::fe`, Port 53). +System-DNS und die parallelen Ausweichabfragen haben jeweils zwei Sekunden. +Jede neue Abfrage beginnt wieder mit System-DNS. Negative DNS-Antworten +(NXDOMAIN), eigene Endpunkte und lokale Pi-hole-Namen erhalten keinen solchen +Ausweichweg. HTTPS-Zertifikate und öffentliche IP-Endpunkte werden weiterhin +geprüft. Dieser Mechanismus ersetzt keine funktionierende Route oder +Firewall-Freigabe auf dem Produktionsserver. + +Unter **Fehler → Diagnose-Logs herunterladen** können Administratoren einen +JSON-Bericht exportieren. Er enthält Version, letzte Laufstatus, IP-Abfragen, +DNS-Ausweichversuche, Fehlertypen, Wartezeiten, Wiederherstellung und +DNS-/IPv4-Routing-Daten. Die Ereignisse werden im bestehenden Daten-Volume in +zwei Dateien zu höchstens je 4 MiB mit Dateirechten `0600` gespeichert und +überleben Container-Neustarts. Bei langen Ausfällen wird der Netzwerkzustand +beim ersten Fehler und anschließend alle 15 Minuten festgehalten. + +Der Export enthält ausschließlich festgelegte technische Felder. Rohmeldungen, +Audit-Texte, Zugangsdaten, Cookies, Sitzungs-IDs, HTTP-Header, URL-Pfade und +Query-Parameter werden nicht übernommen. IP-Adressen und Netzwerkrouten sind +zur Diagnose enthalten. Die Aufzeichnung beginnt mit dieser Funktion; alte +Läufe sind nur mit Zeit und Status enthalten. Der lokale Healthcheck bestätigt +die Erreichbarkeit der Web-App, nicht die Erreichbarkeit von Internet/Hetzner. +Bei `no_route` müssen auf Prod Gateway, Docker-Bridge, Forwarding/NAT und +Firewall geprüft werden; ein Container-Neustart allein belegt keine Behebung. diff --git a/UPDATE.md b/UPDATE.md index 7b025ab..e47482c 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -1,19 +1,37 @@ # Domain Control aktualisieren -Die aktuelle Compose-Version ist `v1.5.0`. Datenbank, Einstellungen und +Die aktuelle Compose-Version ist `v1.5.1`. Datenbank, Einstellungen und verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei den folgenden Verfahren erhalten. ## Direkt in der Weboberfläche 1. Als Administrator anmelden und **Update** öffnen. -2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder einen - Token mit Lesezugriff auf Repository und Container-Paket hinterlegen. +2. Bei öffentlichen Releases und Images ist keine Anmeldung erforderlich. + Für private Repositories oder Container-Pakete Gitea-Benutzername und + Passwort oder einen Token mit den entsprechenden Leserechten hinterlegen. 3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen. 4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen Container wiederherzustellen. +Die Wiederherstellung greift auch dann, wenn Docker den bisherigen Container +bereits gestoppt hat, aber die Antwort auf den Stopp-Aufruf verloren geht. +Healthcheck-Zeitlimits schließen blockierte Docker-Anfragen ein. + +Jede Release-Prüfung und jeder Image-Download beginnt ohne Anmeldung. Nur bei +fehlendem Zugriff werden gespeicherte Zugangsdaten verwendet. Der Wechsel +zwischen öffentlich und privat wird bei jedem Vorgang erneut berücksichtigt; +die Sichtbarkeit des Git-Repositories und des Container-Pakets wird getrennt +geprüft. Ein nicht sichtbares Release (HTTP 404) kann auch bedeuten, dass noch +kein Release veröffentlicht wurde. + +Ältere Update-Agenten mit Protokoll 2 verlangen noch Zugangsdaten. Für anonyme +Image-Downloads muss der Agent einmal gemeinsam mit der Web-App per Compose +auf eine Version mit dieser Funktion aktualisiert werden. Die Web-App erkennt +diese Fähigkeit im Agent-Status; die bestehenden Kompatibilitätsgrenzen bleiben +erhalten. Mit gespeichertem Zugang bleiben ältere kompatible Agenten nutzbar. + Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden DynDNS-Dienst nicht. Direkte Sprünge über mehrere Versionen sind möglich, wenn das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese @@ -45,7 +63,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1 update-agent: image: *image ``` @@ -79,7 +97,7 @@ Die erwartete Antwort lautet `ok`. ## Portainer oder Dockge 1. Den bestehenden Stack bearbeiten. -2. Nur den Tag der zentralen Image-Zeile auf `v1.5.0` setzen. +2. Nur den Tag der zentralen Image-Zeile auf `v1.5.1` setzen. 3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. 4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go index f7b7928..17ed274 100644 --- a/cmd/dyndns-web/main.go +++ b/cmd/dyndns-web/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log/slog" "net/http" "os" @@ -15,6 +16,7 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" "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" @@ -74,6 +76,10 @@ func run(logger *slog.Logger) error { if err != nil { return err } + if err := diagnostics.Configure(filepath.Join(cfg.DataDir, "diagnostics.jsonl"), logger); err != nil { + logger.Warn("diagnostic log unavailable") + } + diagnostics.Record(diagnostics.Event{Code: "startup", Network: diagnostics.CurrentNetwork()}) cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile) if err != nil { return err @@ -84,6 +90,9 @@ func run(logger *slog.Logger) error { return err } defer database.Close() + if err := database.RecoverInterruptedRuns(ctx); err != nil { + return fmt.Errorf("recover interrupted update runs: %w", err) + } if err := database.Prune(ctx); err != nil { logger.Warn("prune old local data", "error", err) } @@ -98,7 +107,9 @@ func run(logger *slog.Logger) error { return err } if !settings.IPv6Enabled { - deleted, cleanupErr := updater.DisableIPv6(ctx) + cleanupCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + deleted, cleanupErr := updater.DisableIPv6(cleanupCtx) + cancel() if cleanupErr != nil { logger.Warn("enforce disabled IPv6", "error", cleanupErr) } else if deleted > 0 { @@ -128,6 +139,14 @@ func run(logger *slog.Logger) error { appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() updater.Start(appCtx) + defer func() { + stop() + waitCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := updater.Wait(waitCtx); err != nil { + logger.Warn("waiting for updater shutdown", "error", err) + } + }() softwareUpdates.Start(appCtx) go pruneStore(appCtx, database, logger) httpServer := &http.Server{ diff --git a/compose.yml b/compose.yml index 39fe7db..ba04c74 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] diff --git a/go.mod b/go.mod index 7e51998..1a663a3 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface -go 1.25.0 +go 1.26.0 -toolchain go1.25.13 +toolchain go1.26.9 require ( github.com/mattn/go-sqlite3 v1.14.47 diff --git a/internal/appupdate/manager.go b/internal/appupdate/manager.go index e25765e..79b929f 100644 --- a/internal/appupdate/manager.go +++ b/internal/appupdate/manager.go @@ -14,13 +14,14 @@ import ( "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" "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" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) -var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet") +var ErrCredentialMissing = errors.New("Release ist nicht öffentlich abrufbar (privates Repository oder kein veröffentlichtes Release); für ein privates Repository den Update-Zugang hinterlegen") const ( agentUnavailableRetryInterval = 2 * time.Second @@ -198,18 +199,19 @@ func (m *Manager) CheckNow(ctx context.Context) error { if err != nil { return err } - if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 { - m.setStoredState(settings) - return ErrCredentialMissing + // Public releases must remain usable even with missing or stale credentials. + release, err := m.fetchLatest(ctx, "", "") + if errors.Is(err, ErrCredentialMissing) && settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 { + credential, decryptErr := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + if decryptErr != nil { + return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")) + } + release, err = m.fetchLatest(ctx, settings.RegistryUsername, credential) } - credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) - if err != nil { - return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")) - } - release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential) if err != nil { return m.recordFailure(ctx, err) } + diagnostics.Record(diagnostics.Event{Code: "release_check_ok", Target: "git"}) checkedAt := time.Now().UTC() if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil { return err @@ -247,14 +249,22 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { if err != nil { return err } - credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) - if err != nil || settings.RegistryUsername == "" { - return ErrCredentialMissing + var username, credential string + if settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 { + credential, err = m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + if err == nil { + username = settings.RegistryUsername + } else if !status.AnonymousPull { + return errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden") + } + } + if username == "" && !status.AnonymousPull { + return errors.New("für Updates ohne Anmeldung muss der Update-Agent einmal mit Docker Compose aktualisiert werden") } if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { return err } - request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential} + request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: username, RegistryToken: credential} if err := m.agent.Start(ctx, request); err != nil { cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) m.clearDesiredVersion(cleanupCtx, version) @@ -278,7 +288,7 @@ func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, erro m.state.AgentVersion = status.AgentVersion m.state.AgentProtocol = status.Protocol m.state.AgentCompatible = agentCompatible(status) - m.state.ComposeSyncRequired = !m.state.AgentCompatible + m.state.ComposeSyncRequired = !m.state.AgentCompatible || (!m.state.CredentialConfigured && !status.AnonymousPull) } else { m.state.AgentCompatible = false m.state.ComposeSyncRequired = false @@ -299,7 +309,7 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string) if err != nil { return releaseResponse{}, err } - if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + if needsAuthentication(response.StatusCode) && username != "" && credential != "" { response.Body.Close() response, err = m.fetchLatestWithAuth(ctx, username, credential, false) if err != nil { @@ -307,11 +317,14 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string) } } defer response.Body.Close() - if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { - return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt") + if needsAuthentication(response.StatusCode) { + if username == "" || credential == "" { + return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: ErrCredentialMissing} + } + return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: errors.New("Gitea-Zugang abgelehnt oder kein sichtbares veröffentlichtes Release vorhanden")} } if response.StatusCode != http.StatusOK { - return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode) + return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)} } var release releaseResponse if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil { @@ -324,25 +337,46 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string) return release, nil } +func needsAuthentication(status int) bool { + // Gitea also conceals private repositories using HTTP 404. + return status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound +} + func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) if err != nil { return nil, errors.New("Update-Endpunkt ist ungültig") } request.Header.Set("Accept", "application/json") - if basic { - request.SetBasicAuth(username, credential) - } else { - request.Header.Set("Authorization", "token "+credential) + if username != "" && credential != "" { + if basic { + request.SetBasicAuth(username, credential) + } else { + request.Header.Set("Authorization", "token "+credential) + } } request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version) response, err := m.httpClient.Do(request) if err != nil { - return nil, errors.New("Git-Server ist momentan nicht erreichbar") + return nil, &releaseFetchError{err: err} } return response, nil } +type releaseFetchError struct{ err error } + +func (e *releaseFetchError) Error() string { return "Git-Server ist momentan nicht erreichbar" } +func (e *releaseFetchError) Unwrap() error { return e.err } + +type releaseStatusError struct { + status int + err error +} + +func (e *releaseStatusError) Error() string { return e.err.Error() } +func (e *releaseStatusError) Unwrap() error { return e.err } +func (e *releaseStatusError) HTTPStatusCode() int { return e.status } + func (m *Manager) safeReleaseURL(raw string) string { apiURL, apiErr := url.Parse(m.apiURL) releaseURL, releaseErr := url.Parse(raw) @@ -353,6 +387,7 @@ func (m *Manager) safeReleaseURL(raw string) string { } func (m *Manager) recordFailure(ctx context.Context, checkErr error) error { + diagnostics.Record(diagnostics.Event{Code: "release_check_failed", Target: "git", Failure: diagnostics.Failure(checkErr), HTTPStatus: diagnostics.HTTPStatus(checkErr)}) checkedAt := time.Now().UTC() if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil { return err @@ -383,7 +418,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) { state.AgentStatus = m.state.AgentStatus state.AgentVersion = m.state.AgentVersion state.AgentProtocol = m.state.AgentProtocol - state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible + state.ComposeSyncRequired = state.AgentAvailable && (!state.AgentCompatible || (!state.CredentialConfigured && !state.AgentStatus.AnonymousPull)) m.state = state m.stateMu.Unlock() } diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go index 7cb55c1..2c59bdc 100644 --- a/internal/appupdate/manager_test.go +++ b/internal/appupdate/manager_test.go @@ -4,11 +4,13 @@ import ( "context" "encoding/base64" "errors" + "fmt" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -65,6 +67,7 @@ func (a *fakeUpdateAgent) counts() (status, start int) { func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Parallel() + nextVersion := nextReleaseVersion(t) const credential = " test-password-or-token-that-must-stay-secret " var unavailable atomic.Bool api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -78,7 +81,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`)) + _, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":%q,"draft":false,"prerelease":false}`, nextVersion, "https://example.invalid/releases/"+nextVersion) })) defer api.Close() @@ -111,7 +114,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal(err) } state := manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" { + if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError != "" || state.CredentialUsername != "registry-user" { t.Fatalf("new release was not detected: %#v", state) } settings, err := database.GetSoftwareUpdateSettings(ctx) @@ -127,7 +130,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal("unavailable Gitea endpoint was reported as successful") } state = manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" { + if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError == "" { t.Fatalf("temporary outage removed the last known update reminder: %#v", state) } } @@ -184,12 +187,13 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) { func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) { t.Parallel() + nextVersion := nextReleaseVersion(t) agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)} manager, database := updateReadyManager(t, agent) if manager.State().AgentAvailable { t.Fatal("test precondition failed: cached agent state is already available") } - if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err != nil { + if err := manager.RequestUpdate(context.Background(), nextVersion); err != nil { t.Fatalf("live agent was rejected because of stale cached state: %v", err) } statusCalls, startCalls := agent.counts() @@ -200,13 +204,14 @@ func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.DesiredVersion != "v1.5.1" { + if settings.DesiredVersion != nextVersion { t.Fatalf("target version was not persisted: %#v", settings) } } func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) { t.Parallel() + nextVersion := nextReleaseVersion(t) tests := []struct { name string status updateprotocol.Status @@ -222,7 +227,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) t.Parallel() agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr} manager, database := updateReadyManager(t, agent) - if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err == nil { + if err := manager.RequestUpdate(context.Background(), nextVersion); err == nil { t.Fatal("unsafe update request was accepted") } _, startCalls := agent.counts() @@ -242,6 +247,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) func TestRequestUpdateSerializesParallelClicks(t *testing.T) { t.Parallel() + nextVersion := nextReleaseVersion(t) agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)} manager, _ := updateReadyManager(t, agent) results := make(chan error, 2) @@ -249,7 +255,7 @@ func TestRequestUpdateSerializesParallelClicks(t *testing.T) { for index := 0; index < 2; index++ { go func() { <-start - results <- manager.RequestUpdate(context.Background(), "v1.5.1") + results <- manager.RequestUpdate(context.Background(), nextVersion) }() } close(start) @@ -337,6 +343,7 @@ func compatibleAgentStatus(phase string) updateprotocol.Status { func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) { t.Helper() + nextVersion := nextReleaseVersion(t) ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) if err != nil { @@ -363,7 +370,7 @@ func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil { t.Fatal(err) } - if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.5.1", "https://git.example/releases/v1.5.1", time.Now()); err != nil { + if err := database.RecordSoftwareUpdateSuccess(ctx, nextVersion, "https://git.example/releases/"+nextVersion, time.Now()); err != nil { t.Fatal(err) } settings, err := database.GetSoftwareUpdateSettings(ctx) @@ -385,3 +392,110 @@ func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool } t.Fatal("condition did not become true in time") } + +func TestRepositoryCanSwitchPublicPrivateAndBack(t *testing.T) { + t.Parallel() + nextVersion := nextReleaseVersion(t) + var private atomic.Bool + var authenticated atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !private.Load() { + if r.Header.Get("Authorization") != "" { + t.Error("credentials sent to public repository") + } + } else { + user, password, ok := r.BasicAuth() + if !ok || user != "registry-user" || password != "registry-password" { + http.NotFound(w, r) + return + } + authenticated.Add(1) + } + _, _ = fmt.Fprintf(w, `{"tag_name":%q,"draft":false,"prerelease":false}`, nextVersion) + })) + defer api.Close() + manager, database := updateReadyManager(t, &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}) + manager.apiURL = api.URL + manager.httpClient = api.Client() + ctx := context.Background() + if err := database.SaveSoftwareUpdateCredential(ctx, "", []byte{}, []byte{}); err != nil { + t.Fatal(err) + } + if err := manager.CheckNow(ctx); err != nil { + t.Fatalf("public release requires login: %v", err) + } + private.Store(true) + if err := manager.CheckNow(ctx); !errors.Is(err, ErrCredentialMissing) { + t.Fatalf("missing private access not explained: %v", err) + } + if !manager.State().UpdateAvailable { + t.Fatal("visibility change removed last known release") + } + if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil { + t.Fatal(err) + } + if err := manager.CheckNow(ctx); err != nil { + t.Fatalf("private 404 did not retry credentials: %v", err) + } + private.Store(false) + if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("corrupt"), []byte("corrupt")); err != nil { + t.Fatal(err) + } + if err := manager.CheckNow(ctx); err != nil { + t.Fatalf("public check depends on old secret: %v", err) + } + if authenticated.Load() != 1 { + t.Fatalf("unexpected authenticated requests: %d", authenticated.Load()) + } +} + +func TestAnonymousUpdateRequiresCapableAgent(t *testing.T) { + t.Parallel() + nextVersion := nextReleaseVersion(t) + for _, capable := range []bool{false, true} { + t.Run(strconv.FormatBool(capable), func(t *testing.T) { + status := compatibleAgentStatus(updateprotocol.PhaseIdle) + status.AnonymousPull = capable + agent := &fakeUpdateAgent{status: status} + manager, database := updateReadyManager(t, agent) + if err := database.SaveSoftwareUpdateCredential(context.Background(), "", []byte{}, []byte{}); err != nil { + t.Fatal(err) + } + settings, err := database.GetSoftwareUpdateSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + manager.setStoredState(settings) + err = manager.RequestUpdate(context.Background(), nextVersion) + if !capable { + if err == nil || !strings.Contains(err.Error(), "Compose") || !manager.State().ComposeSyncRequired { + t.Fatalf("legacy agent not handled: %v", err) + } + if len(agent.requests) != 0 { + t.Fatal("legacy agent received incompatible anonymous request") + } + } else { + if err != nil { + t.Fatal(err) + } + if len(agent.requests) != 1 || agent.requests[0].RegistryUsername != "" || agent.requests[0].RegistryToken != "" { + t.Fatal("anonymous request not forwarded") + } + } + }) + } +} + +// Keep update scenarios newer than the source fallback after every release bump. +func nextReleaseVersion(t *testing.T) string { + t.Helper() + parts := strings.Split(strings.TrimPrefix(buildinfo.Version, "v"), ".") + if len(parts) != 3 { + t.Fatalf("unexpected build version %q", buildinfo.Version) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + t.Fatal(err) + } + return fmt.Sprintf("v%s.%s.%d", parts[0], parts[1], patch+1) +} diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 1634978..baa6da5 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -18,4 +18,4 @@ const ( // Version is replaced through -ldflags for release images. The source fallback // keeps local builds and tests honest about the currently checked-out release. -var Version = "v1.5.0" +var Version = "v1.5.1" diff --git a/internal/diagnostics/diagnostics.go b/internal/diagnostics/diagnostics.go new file mode 100644 index 0000000..d209b0c --- /dev/null +++ b/internal/diagnostics/diagnostics.go @@ -0,0 +1,329 @@ +// Package diagnostics records only explicitly selected technical fields. Raw +// errors, request headers, URLs, credentials and arbitrary log text are excluded. +package diagnostics + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/netip" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +const maxFileSize = 4 << 20 + +type Event struct { + Time time.Time `json:"time"` + Code string `json:"event"` + Address string `json:"address,omitempty"` + DNSResolver string `json:"dns_resolver,omitempty"` + Status string `json:"status,omitempty"` + Target string `json:"target,omitempty"` + Family string `json:"family,omitempty"` + Failure string `json:"failure,omitempty"` + RunID int64 `json:"run_id,omitempty"` + TargetID int64 `json:"target_id,omitempty"` + ActionID int64 `json:"action_id,omitempty"` + Attempt int `json:"attempt,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + RetrySeconds int64 `json:"retry_seconds,omitempty"` + HTTPStatus int `json:"http_status,omitempty"` + Network *Network `json:"network,omitempty"` +} + +type Route struct { + Interface string `json:"interface"` + Destination string `json:"destination"` + Gateway string `json:"gateway"` + Mask string `json:"mask"` +} + +type Network struct { + Nameservers []string `json:"nameservers"` + DockerUpstreams []string `json:"docker_upstreams,omitempty"` + Routes []Route `json:"ipv4_routes"` +} + +type Recorder struct { + mu sync.Mutex + path string + logger *slog.Logger +} + +var active atomic.Pointer[Recorder] + +func Configure(path string, logger *slog.Logger) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + err = file.Chmod(0600) + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + active.Store(&Recorder{path: path, logger: logger}) + return nil +} + +func Record(event Event) { + if recorder := active.Load(); recorder != nil { + recorder.Record(event) + } +} + +func Snapshot() []Event { + if recorder := active.Load(); recorder != nil { + return recorder.Snapshot() + } + return nil +} + +func (r *Recorder) Record(event Event) { + event.Time = time.Now().UTC() + event = sanitize(event) + if event.Code == "" { + return + } + r.logger.Info("diagnostic", "event", event.Code, "target", event.Target, "family", event.Family, "failure", event.Failure, "run_id", event.RunID, "target_id", event.TargetID, "action_id", event.ActionID, "attempt", event.Attempt, "duration_ms", event.DurationMS, "retry_seconds", event.RetrySeconds, "http_status", event.HTTPStatus, "address", event.Address, "dns_resolver", event.DNSResolver, "status", event.Status, "network", event.Network) + encoded, err := json.Marshal(event) + if err != nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if info, err := os.Stat(r.path); err == nil && info.Size()+int64(len(encoded)+1) > maxFileSize { + if err := os.Rename(r.path, r.path+".1"); err != nil { + r.logger.Warn("diagnostic log rotation failed") + return + } + } + file, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + r.logger.Warn("diagnostic log unavailable") + return + } + _, err = file.Write(append(encoded, '\n')) + closeErr := file.Close() + if err != nil || closeErr != nil { + r.logger.Warn("diagnostic log write failed") + } +} + +func (r *Recorder) Snapshot() []Event { + r.mu.Lock() + defer r.mu.Unlock() + var events []Event + for _, path := range []string{r.path + ".1", r.path} { + file, err := os.Open(path) + if err != nil { + continue + } + scanner := bufio.NewScanner(io.LimitReader(file, maxFileSize)) + scanner.Buffer(make([]byte, 4096), 64<<10) + for scanner.Scan() { + var event Event + if json.Unmarshal(scanner.Bytes(), &event) == nil { + event = sanitize(event) + if event.Code != "" { + events = append(events, event) + } + } + } + file.Close() + } + return events +} + +func allowed(value string, values ...string) string { + for _, v := range values { + if value == v { + return value + } + } + return "" +} + +func sanitize(e Event) Event { + if ip, err := netip.ParseAddr(e.Address); err == nil && ip.Zone() == "" { + e.Address = ip.String() + } else { + e.Address = "" + } + if ip, err := netip.ParseAddr(e.DNSResolver); err == nil && ip.Zone() == "" { + e.DNSResolver = ip.String() + } else { + e.DNSResolver = "" + } + e.Status = allowed(e.Status, "ok", "partial", "error") + e.Code = allowed(e.Code, "startup", "dns_fallback_query", "update_run_finished", "provider_update_failed", "dns_fallback", "dns_fallback_ok", "dns_failed", "ip_fetch_ok", "ip_fetch_failed", "update_failed", "update_recovered", "pihole_refresh_ok", "pihole_refresh_failed", "release_check_ok", "release_check_failed") + e.Target = allowed(e.Target, "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud", "custom_endpoint", "pihole", "git", "updater") + e.Family = allowed(e.Family, "ip", "ip4", "ip6") + e.Failure = allowed(e.Failure, "dns_timeout", "dns_not_found", "dns_server_failure", "dns_error", "no_route", "connection_refused", "connection_reset", "timeout", "canceled", "tls", "http", "provider_action", "other") + if e.Network != nil { + n := *e.Network + n.Nameservers = validIPs(n.Nameservers) + n.DockerUpstreams = validIPs(n.DockerUpstreams) + n.Routes = nil + for _, route := range e.Network.Routes { + if validInterface(route.Interface) && len(validIPs([]string{route.Destination, route.Gateway, route.Mask})) == 3 { + n.Routes = append(n.Routes, route) + } + } + e.Network = &n + } + return e +} + +func Target(host string) string { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if allowed(host, "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud") != "" { + return host + } + return "custom_endpoint" +} + +func Failure(err error) string { + if err == nil { + return "" + } + if ActionID(err) > 0 { + return "provider_action" + } + if HTTPStatus(err) != 0 { + return "http" + } + var unknown x509.UnknownAuthorityError + var hostname x509.HostnameError + var invalid x509.CertificateInvalidError + var record tls.RecordHeaderError + if errors.As(err, &unknown) || errors.As(err, &hostname) || errors.As(err, &invalid) || errors.As(err, &record) { + return "tls" + } + var dns *net.DNSError + if errors.As(err, &dns) { + if dns.IsNotFound { + return "dns_not_found" + } + if dns.IsTimeout { + return "dns_timeout" + } + if dns.IsTemporary { + return "dns_server_failure" + } + return "dns_error" + } + switch { + case errors.Is(err, syscall.EHOSTUNREACH), errors.Is(err, syscall.ENETUNREACH): + return "no_route" + case errors.Is(err, syscall.ECONNREFUSED): + return "connection_refused" + case errors.Is(err, syscall.ECONNRESET): + return "connection_reset" + case errors.Is(err, context.Canceled): + return "canceled" + } + var timeout net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &timeout) && timeout.Timeout()) { + return "timeout" + } + return "other" +} + +func ActionID(err error) int64 { + var action interface{ ProviderActionID() int64 } + if errors.As(err, &action) { + return action.ProviderActionID() + } + return 0 +} + +func HTTPStatus(err error) int { + var status interface{ HTTPStatusCode() int } + if errors.As(err, &status) { + return status.HTTPStatusCode() + } + return 0 +} + +func CurrentNetwork() *Network { + n := &Network{} + if data, err := os.ReadFile("/etc/resolv.conf"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "nameserver" { + n.Nameservers = append(n.Nameservers, validIPs(fields[1:])...) + } + if strings.HasPrefix(line, "# ExtServers:") { + n.DockerUpstreams = append(n.DockerUpstreams, validIPs(strings.Fields(strings.NewReplacer("[", " ", "]", " ", ",", " ").Replace(strings.TrimPrefix(line, "# ExtServers:"))))...) + } + } + } + if data, err := os.ReadFile("/proc/net/route"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) < 8 || !validInterface(f[0]) { + continue + } + flags, _ := strconv.ParseUint(f[3], 16, 32) + if flags&1 == 0 { + continue + } + route := Route{f[0], routeIP(f[1]), routeIP(f[2]), routeIP(f[7])} + if route.Destination != "" && route.Gateway != "" && route.Mask != "" { + n.Routes = append(n.Routes, route) + } + } + } + return n +} + +func routeIP(raw string) string { + b, err := hex.DecodeString(raw) + if err != nil || len(b) != 4 { + return "" + } + value := binary.LittleEndian.Uint32(b) + return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}).String() +} +func validInterface(s string) bool { + if s == "" || len(s) > 32 { + return false + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { + return false + } + } + return true +} +func validIPs(values []string) []string { + var result []string + for _, v := range values { + if ip, err := netip.ParseAddr(v); err == nil && ip.Zone() == "" { + result = append(result, ip.String()) + } + } + return result +} diff --git a/internal/diagnostics/diagnostics_test.go b/internal/diagnostics/diagnostics_test.go new file mode 100644 index 0000000..1fb9545 --- /dev/null +++ b/internal/diagnostics/diagnostics_test.go @@ -0,0 +1,91 @@ +package diagnostics + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestExportPersistsOnlyAllowlistedFields(t *testing.T) { + t.Parallel() + var output bytes.Buffer + path := filepath.Join(t.TempDir(), "diagnostics.jsonl") + r := &Recorder{path: path, logger: slog.New(slog.NewJSONHandler(&output, nil))} + const secret = "password-token-cookie-SID-secret" + r.Record(Event{Code: "ip_fetch_failed", Target: "https://user:" + secret + "@private.test/?token=" + secret, Failure: secret, Address: secret, Family: secret, Network: &Network{Nameservers: []string{"127.0.0.11", secret}, DockerUpstreams: []string{secret}, Routes: []Route{{secret, secret, secret, secret}}}}) + r.Record(Event{Code: secret, Target: secret}) + r.Record(Event{Code: "ip_fetch_failed", Address: "fe80::1%" + secret, DNSResolver: "fe80::2%" + secret, Network: &Network{Nameservers: []string{"fe80::3%" + secret}}}) + reloaded := &Recorder{path: path, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + encoded, err := json.Marshal(reloaded.Snapshot()) + if err != nil { + t.Fatal(err) + } + file, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, data := range []string{string(encoded), string(file), output.String()} { + if strings.Contains(data, secret) || strings.Contains(data, "private.test") { + t.Fatalf("secret escaped allowlist: %s", data) + } + } + if !strings.Contains(string(encoded), "127.0.0.11") || !strings.Contains(string(encoded), "ip_fetch_failed") { + t.Fatal("technical evidence lost") + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("unsafe diagnostic permissions: %v %v", info, err) + } +} + +func TestDiagnosticRotationIsBoundedAndReloadable(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "diagnostics.jsonl") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err = file.Truncate(maxFileSize); err != nil { + t.Fatal(err) + } + file.Close() + r := &Recorder{path: path, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + r.Record(Event{Code: "update_recovered", Attempt: 20}) + info, err := os.Stat(path + ".1") + if err != nil || info.Size() != maxFileSize { + t.Fatalf("rotation: %v %v", info, err) + } + events := r.Snapshot() + if len(events) != 1 || events[0].Attempt != 20 { + t.Fatalf("new event lost after rotation: %#v", events) + } +} + +func TestNetworkFailuresAndRouteDecoding(t *testing.T) { + t.Parallel() + for _, test := range []struct { + err error + want string + }{ + {&net.DNSError{IsTemporary: true}, "dns_server_failure"}, + {&net.DNSError{IsTimeout: true}, "dns_timeout"}, + {syscall.EHOSTUNREACH, "no_route"}, + {syscall.ECONNREFUSED, "connection_refused"}, + {context.DeadlineExceeded, "timeout"}, + } { + if got := Failure(test.err); got != test.want { + t.Errorf("failure=%s want %s", got, test.want) + } + } + if got := routeIP("010012AC"); got != "172.18.0.1" { + t.Fatalf("default gateway decoded incorrectly: %s", got) + } +} diff --git a/internal/hetzner/client.go b/internal/hetzner/client.go index 432d035..5314e24 100644 --- a/internal/hetzner/client.go +++ b/internal/hetzner/client.go @@ -11,11 +11,15 @@ import ( "strconv" "strings" "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve" ) type Client struct { - baseURL string - http *http.Client + baseURL string + http *http.Client + actionPollInterval time.Duration + actionTimeout time.Duration } type Zone struct { @@ -44,6 +48,8 @@ type APIError struct { Message string } +func (e *APIError) HTTPStatusCode() int { return e.Status } + func (e *APIError) Error() string { if e.Code != "" { return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message) @@ -52,10 +58,16 @@ func (e *APIError) Error() string { } func New(baseURL string) *Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = netresolve.New().DialContext + transport.IdleConnTimeout = 30 * time.Second return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), + baseURL: strings.TrimRight(baseURL, "/"), + actionPollInterval: 500 * time.Millisecond, + actionTimeout: 20 * time.Second, http: &http.Client{ - Timeout: 15 * time.Second, + Transport: transport, + Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, @@ -64,6 +76,8 @@ func New(baseURL string) *Client { } func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() var result []Zone for page := 1; ; page++ { var response struct { @@ -87,12 +101,17 @@ func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) { if response.Meta.Pagination.NextPage == nil { break } + if *response.Meta.Pagination.NextPage <= page { + return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page) + } page = *response.Meta.Pagination.NextPage - 1 } return result, nil } func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() var result []RRSet for page := 1; ; page++ { var response struct { @@ -111,6 +130,9 @@ func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, if response.Meta.Pagination.NextPage == nil { break } + if *response.Meta.Pagination.NextPage <= page { + return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page) + } page = *response.Meta.Pagination.NextPage - 1 } return result, nil @@ -123,25 +145,25 @@ func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordTyp 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) + return c.doAction(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body) } 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) + return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body) } 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) + return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body) } 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) + return c.doAction(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil) } func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error { @@ -160,7 +182,74 @@ func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, 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) + return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body) +} + +// ActionError reports a failed asynchronous operation even when its HTTP +// request was accepted successfully. +type ActionError struct { + ID int64 + Code string + Message string +} + +func (e *ActionError) ProviderActionID() int64 { return e.ID } + +func (e *ActionError) Error() string { + return fmt.Sprintf("Hetzner action %d failed (%s): %s", e.ID, e.Code, e.Message) +} + +type actionResponse struct { + Action *struct { + ID int64 `json:"id"` + Status string `json:"status"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } `json:"action"` +} + +func (c *Client) doAction(ctx context.Context, token, method, path string, body any) error { + ctx, cancel := context.WithTimeout(ctx, c.actionTimeout) + defer cancel() + var response actionResponse + if err := c.doJSON(ctx, token, method, path, body, &response); err != nil { + return err + } + var actionID int64 + for { + action := response.Action + if action == nil || action.ID <= 0 || (actionID != 0 && action.ID != actionID) { + return fmt.Errorf("Hetzner API returned a missing or invalid action") + } + actionID = action.ID + switch action.Status { + case "success": + return nil + case "error": + failure := &ActionError{ID: action.ID, Code: "unknown", Message: "operation failed"} + if action.Error != nil { + failure.Code, failure.Message = action.Error.Code, action.Error.Message + } + return failure + case "running": + default: + return fmt.Errorf("Hetzner action %d returned an invalid status", actionID) + } + timer := time.NewTimer(c.actionPollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return fmt.Errorf("wait for Hetzner action %d: %w", actionID, ctx.Err()) + case <-timer.C: + } + response = actionResponse{} + // Poll the accepted operation; never repeat a write whose outcome is unknown. + if err := c.doJSON(ctx, token, http.MethodGet, "/zones/actions/"+strconv.FormatInt(actionID, 10), nil, &response); err != nil { + return fmt.Errorf("check Hetzner action %d: %w", actionID, err) + } + } } func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error { @@ -186,14 +275,17 @@ func (c *Client) doJSON(ctx context.Context, token, method, path string, request return fmt.Errorf("request Hetzner API: %w", err) } defer resp.Body.Close() - content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + content, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1)) if err != nil { return fmt.Errorf("read Hetzner API response: %w", err) } + if len(content) > 1<<20 { + return fmt.Errorf("Hetzner API response exceeds size limit") + } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return decodeAPIError(resp.StatusCode, content) } - if responseBody != nil && len(content) > 0 { + if responseBody != nil { if err := json.Unmarshal(content, responseBody); err != nil { return fmt.Errorf("decode Hetzner API response: %w", err) } diff --git a/internal/hetzner/client_test.go b/internal/hetzner/client_test.go index bc08c5f..df76fa7 100644 --- a/internal/hetzner/client_test.go +++ b/internal/hetzner/client_test.go @@ -3,11 +3,107 @@ package hetzner import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" ) +func TestDNSWritesWaitForActionCompletion(t *testing.T) { + operations := map[string]func(*Client) error{ + "create": func(c *Client) error { + return c.CreateRRSet(context.Background(), "token", "123", "home", "A", 60, nil) + }, + "records": func(c *Client) error { + return c.SetRRSetRecords(context.Background(), "token", "123", "home", "A", nil) + }, + "ttl": func(c *Client) error { return c.ChangeRRSetTTL(context.Background(), "token", "123", "home", "A", 60) }, + "comments": func(c *Client) error { + return c.UpdateRecordComments(context.Background(), "token", "123", "home", "A", nil) + }, + "delete": func(c *Client) error { return c.DeleteRRSet(context.Background(), "token", "123", "home", "A") }, + } + for name, operation := range operations { + t.Run(name, func(t *testing.T) { + for _, status := range []string{"success", "error"} { + t.Run(status, func(t *testing.T) { + var writes, polls int + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes++ + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`)) + return + } + if r.URL.Path != "/zones/actions/42" || r.Header.Get("Authorization") != "Bearer token" { + t.Errorf("incorrect action polling request: %s", r.URL.Path) + } + polls++ + if polls == 1 { + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`)) + } else { + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"` + status + `","error":{"code":"action_failed","message":"failed later"}}}`)) + } + })) + defer api.Close() + client := New(api.URL) + client.actionPollInterval = time.Millisecond + err := operation(client) + if status == "success" && err != nil { + t.Fatal(err) + } + var actionErr *ActionError + if status == "error" && (!errors.As(err, &actionErr) || actionErr.Code != "action_failed") { + t.Fatalf("lost action failure: %v", err) + } + if status == "error" && (diagnostics.Failure(err) != "provider_action" || diagnostics.ActionID(err) != 42) { + t.Fatal("action failure missing from safe diagnostics") + } + if writes != 1 || polls != 2 { + t.Fatalf("returned too early or repeated write: writes=%d polls=%d", writes, polls) + } + }) + } + }) + } +} + +func TestActionWaitIsBoundedAndRejectsMalformedResponses(t *testing.T) { + for _, body := range []string{"", `{}`, `{"action":{"id":42,"status":"unknown"}}`, `{"action":{"status":"running"}}`, `{"action":{"id":42,"status":"running"}}`} { + t.Run(body, func(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) })) + defer api.Close() + client := New(api.URL) + client.actionPollInterval = time.Millisecond + client.actionTimeout = 30 * time.Millisecond + if err := client.DeleteRRSet(context.Background(), "token", "123", "home", "A"); err == nil { + t.Fatal("unconfirmed operation reported success") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.DeleteRRSet(ctx, "token", "123", "home", "A"); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } + }) + } +} + +func TestPaginationRejectsRepeatedPage(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"zones":[],"rrsets":[],"meta":{"pagination":{"next_page":1}}}`)) + })) + defer api.Close() + client := New(api.URL) + if _, err := client.ListZones(context.Background(), "token"); err == nil { + t.Fatal("repeated zones page accepted") + } + if _, err := client.ListRRSets(context.Background(), "token", "123"); err == nil { + t.Fatal("repeated RRsets page accepted") + } +} + func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) { t.Parallel() type capturedRequest struct { @@ -33,7 +129,7 @@ func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"action":{"status":"running"}}`)) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() @@ -77,6 +173,7 @@ func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) { body["path"] = r.URL.Path captured <- body w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() @@ -115,6 +212,7 @@ func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) { body["path"] = r.URL.Path captured <- body w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() @@ -133,7 +231,8 @@ func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) { captured := make(chan *http.Request, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { captured <- r - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) })) defer api.Close() diff --git a/internal/ipresolve/resolver.go b/internal/ipresolve/resolver.go index cd7dd53..2490a7b 100644 --- a/internal/ipresolve/resolver.go +++ b/internal/ipresolve/resolver.go @@ -12,6 +12,9 @@ import ( "net/url" "strings" "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve" ) type Result struct { @@ -22,10 +25,15 @@ type Result struct { } type Resolver struct { - resolver *net.Resolver + resolver interface { + LookupNetIP(context.Context, string, string) ([]netip.Addr, error) + } + clientFor func(*url.URL, []netip.Addr) *http.Client } -func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} } +func New() *Resolver { + return &Resolver{resolver: netresolve.New(), clientFor: newEndpointClient} +} func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result { return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true) @@ -33,9 +41,19 @@ func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []s func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result { result := Result{} + // Each family gets independent endpoint deadlines. + var ipv6Result chan Result + if ipv6Enabled { + ipv6Result = make(chan Result, 1) + go func() { + ip, err := r.resolveFamily(ctx, ipv6Endpoints, false) + ipv6Result <- Result{IPv6: ip, IPv6Err: err} + }() + } result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true) if ipv6Enabled { - result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false) + ipv6 := <-ipv6Result + result.IPv6, result.IPv6Err = ipv6.IPv6, ipv6.IPv6Err } return result } @@ -56,57 +74,59 @@ func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 b if len(endpoints) == 0 { return "", errors.New("no endpoints configured") } - var failures []string + var failures []error for _, endpoint := range endpoints { + if err := ctx.Err(); err != nil { + return "", err + } + started := time.Now() value, err := r.resolveOne(ctx, endpoint, ipv4) + family := "ip6" + if ipv4 { + family = "ip4" + } + code := "ip_fetch_ok" + if err != nil { + code = "ip_fetch_failed" + } + diagnostics.Record(diagnostics.Event{Code: code, Target: diagnostics.Target(safeEndpointName(endpoint)), Family: family, Failure: diagnostics.Failure(err), DurationMS: time.Since(started).Milliseconds(), Address: value, HTTPStatus: diagnostics.HTTPStatus(err)}) if err == nil { return value, nil } - failures = append(failures, safeEndpointName(endpoint)+": "+err.Error()) + failures = append(failures, fmt.Errorf("%s: %w", safeEndpointName(endpoint), err)) } family := "IPv6" if ipv4 { family = "IPv4" } - return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; ")) + return "", &familyError{family: family, failures: failures} } +type familyError struct { + family string + failures []error +} + +func (e *familyError) Error() string { + messages := make([]string, len(e.failures)) + for i, err := range e.failures { + messages[i] = err.Error() + } + return fmt.Sprintf("all %s endpoints failed: %s", e.family, strings.Join(messages, "; ")) +} +func (e *familyError) Unwrap() []error { return e.failures } + func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) { + // DNS, dialing, TLS and the response body share one deadline. + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() 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 - }, - } + client := r.clientFor(u, ips) + defer client.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return "", err @@ -118,7 +138,7 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("returned HTTP %d", resp.StatusCode) + return "", responseError(resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1025)) if err != nil { @@ -140,6 +160,30 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin return address.String(), nil } +type responseError int + +func (e responseError) Error() string { return fmt.Sprintf("returned HTTP %d", e) } +func (e responseError) HTTPStatusCode() int { return int(e) } + +func newEndpointClient(u *url.URL, ips []netip.Addr) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()}, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return netresolve.DialAddresses(ctx, "tcp", ips, "443") + }, + }, + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + 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 { @@ -161,7 +205,11 @@ func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) ( } return u, []netip.Addr{literal}, nil } - addresses, err := r.resolver.LookupNetIP(ctx, "ip", host) + family := "ip6" + if ipv4 { + family = "ip4" + } + addresses, err := r.resolver.LookupNetIP(ctx, family, host) if err != nil { return nil, nil, fmt.Errorf("resolve host: %w", err) } diff --git a/internal/ipresolve/resolver_test.go b/internal/ipresolve/resolver_test.go index 321668d..8e07b88 100644 --- a/internal/ipresolve/resolver_test.go +++ b/internal/ipresolve/resolver_test.go @@ -2,8 +2,16 @@ package ipresolve import ( "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" "net/netip" + "net/url" + "sync/atomic" "testing" + "time" ) func TestPublicAddress(t *testing.T) { @@ -32,3 +40,107 @@ func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) { t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result) } } + +type lookupFunc func(context.Context, string, string) ([]netip.Addr, error) + +func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { + return f(ctx, network, host) +} + +func TestResolverRecoversWithFreshIPAndFallbackEndpoint(t *testing.T) { + t.Parallel() + var current atomic.Value + current.Store("1.1.1.1") + api := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/unavailable" { + http.Error(w, "offline", http.StatusServiceUnavailable) + return + } + _, _ = io.WriteString(w, current.Load().(string)) + })) + defer api.Close() + r := New() + var offline atomic.Bool + r.resolver = lookupFunc(func(ctx context.Context, network, host string) ([]netip.Addr, error) { + if network != "ip4" { + t.Errorf("unexpected family: %s", network) + } + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 10*time.Second { + t.Error("DNS is outside the endpoint deadline") + } + if offline.Load() { + return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} + } + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil + }) + r.clientFor = func(u *url.URL, ips []netip.Addr) *http.Client { + client := newEndpointClient(u, ips) + transport := client.Transport.(*http.Transport) + transport.TLSClientConfig.RootCAs = api.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", api.Listener.Addr().String()) + } + return client + } + endpoints := []string{"https://example.com/unavailable", "https://example.com/ip"} + for _, step := range []struct { + offline bool + ip string + }{{false, "1.1.1.1"}, {true, ""}, {false, "9.9.9.9"}} { + offline.Store(step.offline) + if step.ip != "" { + current.Store(step.ip) + } + result := r.ResolveWithIPv6(context.Background(), endpoints, nil, false) + if result.IPv4 != step.ip || (result.IPv4Err != nil) != step.offline { + t.Fatalf("outage recovery: %#v", result) + } + } +} + +func TestIPFamiliesResolveIndependently(t *testing.T) { + t.Parallel() + r := New() + started := make(chan string, 2) + proceed := make(chan struct{}) + r.resolver = lookupFunc(func(ctx context.Context, network, _ string) ([]netip.Addr, error) { + started <- network + select { + case <-proceed: + return nil, errors.New("offline") + case <-ctx.Done(): + return nil, ctx.Err() + } + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan Result, 1) + go func() { done <- r.Resolve(ctx, []string{"https://example.com"}, []string{"https://example.com"}) }() + families := map[string]bool{} + for range 2 { + select { + case family := <-started: + families[family] = true + case <-ctx.Done(): + t.Fatal("family resolution blocked another family") + } + } + close(proceed) + result := <-done + if !families["ip4"] || !families["ip6"] || result.IPv4Err == nil || result.IPv6Err == nil { + t.Fatalf("unexpected family result: %#v", result) + } +} + +func TestPrivateEndpointStillRejected(t *testing.T) { + t.Parallel() + r := New() + r.resolver = lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("10.0.20.110")}, nil + }) + for _, endpoint := range []string{"https://example.com/ip", "https://127.0.0.1/ip", "http://example.com", "https://user:secret@example.com", "https://example.com?token=secret", "https://example.com:8443"} { + if _, _, err := r.parseAndResolve(context.Background(), endpoint, true); err == nil { + t.Fatalf("unsafe endpoint accepted: %s", endpoint) + } + } +} diff --git a/internal/netresolve/resolver.go b/internal/netresolve/resolver.go new file mode 100644 index 0000000..21f5fc3 --- /dev/null +++ b/internal/netresolve/resolver.go @@ -0,0 +1,158 @@ +// Package netresolve provides bounded DNS lookups for the public DynDNS services. +package netresolve + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strings" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" +) + +type lookup interface { + LookupNetIP(context.Context, string, string) ([]netip.Addr, error) +} + +type Resolver struct { + system lookup + fallbacks []lookup + timeout time.Duration +} + +func New() *Resolver { + r := &Resolver{system: net.DefaultResolver, timeout: 2 * time.Second} + // Literal addresses keep this recovery path independent of Docker/Pi-hole + // DNS. Both providers can also be reached from IPv6-only networks. + for _, server := range []string{"1.1.1.1", "9.9.9.9", "2606:4700:4700::1111", "2620:fe::fe"} { + r.fallbacks = append(r.fallbacks, &directResolver{server: server, resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(server, "53")) + }, + }}) + } + return r +} + +type directResolver struct { + server string + resolver *net.Resolver +} + +func (r *directResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { + started := time.Now() + ips, err := r.resolver.LookupNetIP(ctx, network, host) + // A canceled losing query is normal when another resolver already succeeded. + if !errors.Is(ctx.Err(), context.Canceled) { + diagnostics.Record(diagnostics.Event{Code: "dns_fallback_query", Target: diagnostics.Target(host), Family: network, DNSResolver: r.server, Failure: diagnostics.Failure(err), DurationMS: time.Since(started).Milliseconds()}) + } + return ips, err +} + +func publicService(host string) bool { + switch strings.ToLower(strings.TrimSuffix(host, ".")) { + case "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud": + return true + } + return false +} + +func (r *Resolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { + lookupCtx, cancel := context.WithTimeout(ctx, r.timeout) + addresses, err := r.system.LookupNetIP(lookupCtx, network, host) + cancel() + if err == nil || ctx.Err() != nil || !publicService(host) || !temporaryDNSFailure(err) { + return addresses, err + } + + diagnostics.Record(diagnostics.Event{Code: "dns_fallback", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) + // Do not cache failures or prefer a previous fallback permanently. Every + // lookup checks the configured resolver again after connectivity returns. + fallbackCtx, stop := context.WithTimeout(ctx, r.timeout) + defer stop() + type result struct { + addresses []netip.Addr + err error + } + results := make(chan result, len(r.fallbacks)) + for _, fallback := range r.fallbacks { + go func() { + // An absolute name avoids sending local DNS search suffixes outside. + addresses, err := fallback.LookupNetIP(fallbackCtx, network, strings.TrimSuffix(host, ".")+".") + results <- result{addresses, err} + }() + } + for range r.fallbacks { + select { + case result := <-results: + if result.err == nil && len(result.addresses) > 0 { + diagnostics.Record(diagnostics.Event{Code: "dns_fallback_ok", Target: diagnostics.Target(host), Family: network}) + return result.addresses, nil + } + case <-fallbackCtx.Done(): + if ctx.Err() != nil { + return nil, ctx.Err() + } + diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) + return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback timed out", err) + } + } + diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) + return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback unavailable", err) +} + +func temporaryDNSFailure(err error) bool { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return !dnsErr.IsNotFound && (dnsErr.IsTimeout || dnsErr.IsTemporary) + } + return errors.Is(err, context.DeadlineExceeded) +} + +// DialContext preserves ordinary resolution for custom API URLs and local hosts. +func (r *Resolver) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || !publicService(host) { + return (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext(ctx, network, address) + } + family := "ip" + if strings.HasSuffix(network, "4") { + family = "ip4" + } else if strings.HasSuffix(network, "6") { + family = "ip6" + } + addresses, err := r.LookupNetIP(ctx, family, host) + if err != nil { + return nil, err + } + return DialAddresses(ctx, network, addresses, port) +} + +// DialAddresses connects only to the already resolved addresses. Sharing the +// connect budget allows later addresses a chance even if the first one stalls. +func DialAddresses(ctx context.Context, network string, addresses []netip.Addr, port string) (net.Conn, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + var lastErr error + for index, address := range addresses { + if err := ctx.Err(); err != nil { + return nil, err + } + attemptCtx, stop := context.WithTimeout(ctx, time.Until(deadline)/time.Duration(len(addresses)-index)) + conn, err := (&net.Dialer{KeepAlive: 30 * time.Second}).DialContext(attemptCtx, network, net.JoinHostPort(address.String(), port)) + stop() + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("host has no addresses") + } + return nil, lastErr +} diff --git a/internal/netresolve/resolver_test.go b/internal/netresolve/resolver_test.go new file mode 100644 index 0000000..ccb484f --- /dev/null +++ b/internal/netresolve/resolver_test.go @@ -0,0 +1,120 @@ +package netresolve + +import ( + "context" + "errors" + "net" + "net/netip" + "sync/atomic" + "testing" + "time" +) + +type lookupFunc func(context.Context, string, string) ([]netip.Addr, error) + +func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { + return f(ctx, network, host) +} + +func TestDNSOutageFallbackAndReturnToSystem(t *testing.T) { + t.Parallel() + var healthy atomic.Bool + var fallbacks atomic.Int32 + r := &Resolver{timeout: time.Second} + r.system = lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { + if network != "ip4" || host != "api.ipify.org" { + t.Errorf("unexpected query %s %s", network, host) + } + if healthy.Load() { + return []netip.Addr{netip.MustParseAddr("1.0.0.1")}, nil + } + return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} + }) + r.fallbacks = []lookup{lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { + fallbacks.Add(1) + if network != "ip4" || host != "api.ipify.org." { + t.Errorf("fallback must use absolute family-specific query: %s %s", network, host) + } + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil + })} + for _, recovered := range []bool{false, true} { + healthy.Store(recovered) + ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org") + want := "1.1.1.1" + if recovered { + want = "1.0.0.1" + } + if err != nil || len(ips) != 1 || ips[0].String() != want { + t.Fatalf("DNS recovery: %v %v", ips, err) + } + } + if fallbacks.Load() != 1 { + t.Fatal("system recovery did not stop fallback use") + } +} + +func TestSlowDNSCannotBlockWorkingFallback(t *testing.T) { + t.Parallel() + slow := lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { <-ctx.Done(); return nil, ctx.Err() }) + r := &Resolver{system: slow, timeout: 20 * time.Millisecond, fallbacks: []lookup{slow, lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil + })}} + started := time.Now() + ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org") + if err != nil || len(ips) != 1 || time.Since(started) > time.Second { + t.Fatalf("blocked fallback: %v %v", ips, err) + } + r.fallbacks = []lookup{slow} + if _, err = r.LookupNetIP(context.Background(), "ip4", "api.ipify.org"); err == nil { + t.Fatal("total outage was hidden") + } +} + +func TestDNSFallbackDoesNotOverrideNegativeAnswersOrLocalNames(t *testing.T) { + t.Parallel() + for _, test := range []struct { + host string + err error + }{ + {"api.ipify.org", &net.DNSError{IsNotFound: true}}, + {"api.ipify.org", errors.New("permanent DNS error")}, + {"pi.hole", &net.DNSError{IsTemporary: true}}, + {"private.example", &net.DNSError{IsTemporary: true}}, + } { + r := &Resolver{timeout: time.Second, system: lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { return nil, test.err }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { + t.Error("unexpected external query") + return nil, nil + })}} + if _, err := r.LookupNetIP(context.Background(), "ip4", test.host); !errors.Is(err, test.err) { + t.Fatalf("original error lost: %v", err) + } + } +} + +func TestCanceledDNSDoesNotStartFallback(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := &Resolver{timeout: time.Second, system: lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { return nil, ctx.Err() }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { + t.Error("fallback after cancellation") + return nil, nil + })}} + if _, err := r.LookupNetIP(ctx, "ip6", "api6.ipify.org"); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestDialAddressesTriesNextAddress(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + _, port, _ := net.SplitHostPort(listener.Addr().String()) + conn, err := DialAddresses(context.Background(), "tcp4", []netip.Addr{netip.MustParseAddr("127.0.0.2"), netip.MustParseAddr("127.0.0.1")}, port) + if err != nil { + t.Fatal(err) + } + conn.Close() +} diff --git a/internal/pihole/client.go b/internal/pihole/client.go index 72cb517..5c1496b 100644 --- a/internal/pihole/client.go +++ b/internal/pihole/client.go @@ -28,6 +28,8 @@ type APIError struct { Message string } +func (e *APIError) HTTPStatusCode() int { return e.Status } + func (e *APIError) Error() string { message := strings.TrimSpace(e.Message) if message == "" { @@ -98,7 +100,7 @@ func (c *Client) BaseURL() string { return c.baseURL } func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) { var hosts []string - err := c.withSession(ctx, password, func(sid string) error { + err := c.withSession(ctx, password, func(ctx context.Context, sid string) error { var response struct { Config struct { DNS struct { @@ -116,13 +118,13 @@ func (c *Client) ListHosts(ctx context.Context, password string) ([]string, erro } func (c *Client) AddHost(ctx context.Context, password, value string) error { - return c.withSession(ctx, password, func(sid string) error { + return c.withSession(ctx, password, func(ctx context.Context, sid string) error { return c.addHost(ctx, sid, value) }) } func (c *Client) DeleteHost(ctx context.Context, password, value string) error { - return c.withSession(ctx, password, func(sid string) error { + return c.withSession(ctx, password, func(ctx context.Context, sid string) error { return c.deleteHost(ctx, sid, value) }) } @@ -131,7 +133,7 @@ func (c *Client) DeleteHost(ctx context.Context, password, value string) error { // Pi-hole session. Missing values are accepted so an interrupted deletion can // safely be retried. func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error { - return c.withSession(ctx, password, func(sid string) error { + return c.withSession(ctx, password, func(ctx context.Context, sid string) error { for _, value := range values { if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil { return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err) @@ -145,7 +147,7 @@ func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacemen if previous == replacement { return nil } - return c.withSession(ctx, password, func(sid string) error { + return c.withSession(ctx, password, func(ctx context.Context, sid string) error { if err := c.deleteHost(ctx, sid, previous); err != nil { return err } @@ -167,7 +169,12 @@ func (c *Client) deleteHost(ctx context.Context, sid, value string) error { return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent) } -func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error { +func (c *Client) withSession(ctx context.Context, password string, operation func(context.Context, string) error) error { + // Authentication and the operation must finish before the web server's + // write deadline, including when a Pi-hole disappears between requests. + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + defer c.http.CloseIdleConnections() sid, err := c.authenticate(ctx, password) if err != nil { return err @@ -177,7 +184,7 @@ func (c *Client) withSession(ctx context.Context, password string, operation fun defer cancel() _ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound) }() - return operation(sid) + return operation(ctx, sid) } func (c *Client) authenticate(ctx context.Context, password string) (string, error) { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3a21b68..848ec3e 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" "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" @@ -21,7 +22,10 @@ var ( ErrDisabled = errors.New("the updater is disabled") ) -const defaultRetryInitial = 5 * time.Second +const ( + defaultRetryInitial = 5 * time.Second + defaultRetryMaximum = time.Minute +) type ipResolver interface { ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result @@ -37,6 +41,8 @@ type Scheduler struct { running atomic.Bool nextRun atomic.Int64 wake chan struct{} + changes chan struct{} + done chan struct{} retryMin time.Duration retryMax time.Duration } @@ -49,14 +55,18 @@ func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Re cipher: cipher, logger: logger, wake: make(chan struct{}, 1), + changes: make(chan struct{}, 1), + done: make(chan struct{}), retryMin: defaultRetryInitial, } } func (s *Scheduler) Start(ctx context.Context) { go func() { + defer close(s.done) defer s.nextRun.Store(0) consecutiveFailures := 0 + var failureSince, networkLoggedAt time.Time for { settings, err := s.store.GetSettings(ctx) if err != nil { @@ -86,10 +96,24 @@ func (s *Scheduler) Start(ctx context.Context) { if settings.UpdaterEnabled { _, runErr := s.Run(ctx) if runErr == nil { + if consecutiveFailures > 0 { + diagnostics.Record(diagnostics.Event{Code: "update_recovered", Target: "updater", Attempt: consecutiveFailures, DurationMS: time.Since(failureSince).Milliseconds()}) + } consecutiveFailures = 0 - } else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) { + } else if errors.Is(runErr, ErrRunInProgress) { + retryAfter = time.Second + } else if !errors.Is(runErr, ErrDisabled) { consecutiveFailures++ + if consecutiveFailures == 1 { + failureSince = time.Now() + } retryAfter = s.retryDelay(consecutiveFailures, wait) + event := diagnostics.Event{Code: "update_failed", Target: "updater", Failure: diagnostics.Failure(runErr), Attempt: consecutiveFailures, RetrySeconds: int64(retryAfter / time.Second)} + if consecutiveFailures == 1 || time.Since(networkLoggedAt) >= 15*time.Minute { + event.Network = diagnostics.CurrentNetwork() + networkLoggedAt = time.Now() + } + diagnostics.Record(event) s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter) } } else { @@ -112,7 +136,6 @@ func (s *Scheduler) Start(ctx context.Context) { timer.Stop() return case <-s.wake: - consecutiveFailures = 0 if !timer.Stop() { select { case <-timer.C: @@ -130,8 +153,12 @@ func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.D if minimum <= 0 { minimum = defaultRetryInitial } - if s.retryMax > 0 && s.retryMax < maximum { - maximum = s.retryMax + retryMax := s.retryMax + if retryMax <= 0 { + retryMax = defaultRetryMaximum + } + if retryMax < maximum { + maximum = retryMax } if maximum < minimum { maximum = minimum @@ -167,11 +194,40 @@ func (s *Scheduler) NextRunAt() time.Time { return time.Unix(value, 0).UTC() } +// LockChanges serializes a complete WebUI mutation (provider and database) +// with updater snapshots. Callers must hold it until the local change is saved. +func (s *Scheduler) LockChanges(ctx context.Context) (func(), error) { + if err := ctx.Err(); err != nil { + return nil, err + } + select { + case s.changes <- struct{}{}: + return func() { <-s.changes }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *Scheduler) Wait(ctx context.Context) error { + select { + case <-s.done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func (s *Scheduler) Run(ctx context.Context) (string, error) { if !s.running.CompareAndSwap(false, true) { return "", ErrRunInProgress } defer s.running.Store(false) + select { + case s.changes <- struct{}{}: + defer func() { <-s.changes }() + default: + return "", ErrRunInProgress + } settings, err := s.store.GetSettings(ctx) if err != nil { @@ -234,6 +290,7 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) { } if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil { failed += len(group) + diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", RunID: runID, Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)}) 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) @@ -261,11 +318,16 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) { } 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) + // Persist cancellation too; using the canceled run context leaves history + // stuck at "running". Keep cleanup bounded during shutdown. + finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := s.store.CompleteUpdateRun(finishCtx, runID, status, ipv4, ipv6, summary); err != nil { + s.logger.Error("persist update result", "run_id", runID, "error", err) + runErr = errors.Join(runErr, fmt.Errorf("Updater-Ergebnis konnte nicht gespeichert werden: %w", err)) + status = "error" } + diagnostics.Record(diagnostics.Event{Code: "update_run_finished", Target: "updater", RunID: runID, Status: status, Failure: diagnostics.Failure(runErr), HTTPStatus: diagnostics.HTTPStatus(runErr)}) return summary, runErr } @@ -287,46 +349,53 @@ func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.Managed } 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) + if targetErr != nil { + *failed = *failed + 1 + diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", TargetID: item.Target.ID, ActionID: diagnostics.ActionID(targetErr), Failure: diagnostics.Failure(targetErr), HTTPStatus: diagnostics.HTTPStatus(targetErr)}) + _ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), targetUpdated) + continue + } + if err := s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated); err != nil { + return err + } } 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 + var familyErrors []error 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 + for _, family := range []struct { + enabled bool + recordType, ip string + }{ + {target.UpdateIPv4, "A", ipv4}, + {target.UpdateIPv6, "AAAA", ipv6}, + } { + if !family.enabled { + continue } - updated = updated || changed - states = append(states, state) - } - if target.UpdateIPv6 { - if !ipv6Enabled { + if family.recordType == "AAAA" && !ipv6Enabled { states = append(states, "AAAA global deaktiviert") + continue + } + changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, family.recordType)], family.recordType, family.ip) + updated = updated || changed + if err != nil { + familyErrors = append(familyErrors, fmt.Errorf("%s: %w", family.recordType, err)) + states = append(states, family.recordType+" Fehler") } 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 + return updated, strings.Join(states, " · "), errors.Join(familyErrors...) } func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) { @@ -360,7 +429,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon 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 + return ttlChanged, "", err } status := recordType + " Kommentar synchronisiert" if ttlChanged { @@ -374,7 +443,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon 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 + return ttlChanged, "", err } status := recordType + " aktualisiert" if ttlChanged { @@ -551,28 +620,12 @@ func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) ( 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 { + target.UpdateIPv4 = target.UpdateIPv4 && target.StaticIPv4 != "" + target.UpdateIPv6 = target.UpdateIPv6 && target.StaticIPv6 != "" + if !target.UpdateIPv4 && !target.UpdateIPv6 { return false, "Keine feste IP eingetragen", nil } - return updated, strings.Join(states, " · "), nil + return s.syncTarget(ctx, token, zone, target, byKey, target.StaticIPv4, target.StaticIPv6, true) } // DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local @@ -859,6 +912,11 @@ func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, st } func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + if len(endpoints) > 8 { + return errors.New("höchstens acht IP-Endpoints pro Familie erlaubt") + } for _, endpoint := range endpoints { if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil { return fmt.Errorf("%s: %w", endpoint, err) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 5e603a6..59f7808 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -22,6 +23,187 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) +type resolverFunc func(context.Context) ipresolve.Result + +func (f resolverFunc) ResolveWithIPv6(ctx context.Context, _, _ []string, _ bool) ipresolve.Result { + return f(ctx) +} +func (f resolverFunc) ValidateEndpoint(context.Context, string) error { return nil } + +func TestOneFailedFamilyDoesNotBlockTheOther(t *testing.T) { + for _, protectedFamily := range []string{"A", "AAAA"} { + t.Run(protectedFamily, func(t *testing.T) { + var writes atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/"+protectedFamily+"/") { + t.Error("wrote protected family") + } + writes.Add(1) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) + })) + defer api.Close() + ttl := 60 + rrsets := map[string]hetzner.RRSet{} + for _, family := range []string{"A", "AAAA"} { + rrset := hetzner.RRSet{Name: "home", Type: family, TTL: &ttl, Records: []hetzner.Record{{Value: "old"}}} + rrset.Protection.Change = family == protectedFamily + rrsets[rrKey("home", family)] = rrset + } + schedule := New(nil, hetzner.New(api.URL), nil, nil, nil) + changed, status, err := schedule.syncTarget(context.Background(), "token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", TTL: 60, UpdateIPv4: true, UpdateIPv6: true}, rrsets, "198.51.100.42", "2001:db8::42", true) + if !changed || err == nil || !strings.Contains(err.Error(), protectedFamily+":") || !strings.Contains(status, "aktualisiert") || writes.Load() != 1 { + t.Fatalf("partial update lost: changed=%v status=%q err=%v writes=%d", changed, status, err, writes.Load()) + } + }) + } +} + +func TestRunPersistsConfirmedPartialTargetChanges(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + database, err := store.Open(ctx, filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + cipher, err := secure.LoadOrCreateCipher(filepath.Join(dir, "secrets", "key")) + if err != nil { + t.Fatal(err) + } + ciphertext, nonce, err := cipher.Encrypt("token") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("token")) + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "123", "example.org") + if err != nil { + t.Fatal(err) + } + target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.org", Active: true, UpdateIPv4: true, UpdateIPv6: true, TTL: 60}) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(ctx, 300, true, true, nil, nil); err != nil { + t.Fatal(err) + } + var writes atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"protection":{"change":true},"records":[{"value":"198.51.100.1"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::1"}]}]}`)) + } else { + if r.URL.Path != "/zones/123/rrsets/home/AAAA/actions/set_records" { + t.Errorf("unexpected write: %s", r.URL.Path) + } + writes.Add(1) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) + } + })) + defer api.Close() + worker := New(database, hetzner.New(api.URL), nil, cipher, slog.New(slog.NewTextHandler(io.Discard, nil))) + worker.resolver = resolverFunc(func(context.Context) ipresolve.Result { + return ipresolve.Result{IPv4: "198.51.100.42", IPv6: "2001:db8::42"} + }) + summary, err := worker.Run(ctx) + if err == nil || summary != "1 Ziel(e) aktualisiert, 1 Fehler" || writes.Load() != 1 { + t.Fatalf("incorrect partial result: %q %v writes=%d", summary, err, writes.Load()) + } + target, err = database.GetTarget(ctx, target.ID) + if err != nil || target.LastUpdatedAt.IsZero() || target.LastError == "" { + t.Fatalf("confirmed change or error lost: %#v %v", target, err) + } + runs, err := database.RecentRuns(ctx, 1) + if err != nil || len(runs) != 1 || runs[0].Status != "partial" { + t.Fatalf("wrong partial run status: %#v %v", runs, err) + } +} + +func TestCanceledRunFinishesHistoryAndReleasesWorker(t *testing.T) { + database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + settings, err := database.GetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { + t.Fatal(err) + } + schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + ctx, cancel := context.WithCancel(context.Background()) + schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { cancel(); return ipresolve.Result{IPv4Err: context.Canceled} }) + if _, err := schedule.Run(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("lost cancellation: %v", err) + } + runs, err := database.RecentRuns(context.Background(), 10) + if err != nil || len(runs) != 1 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || schedule.IsRunning() { + t.Fatalf("canceled run stuck: %#v err=%v", runs, err) + } + schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { return ipresolve.Result{IPv4: "198.51.100.42"} }) + if _, err := schedule.Run(context.Background()); err != nil { + t.Fatalf("worker did not recover: %v", err) + } +} + +func TestDNSChangesAndUpdaterCannotOverlap(t *testing.T) { + database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + settings, err := database.GetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { + t.Fatal(err) + } + schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + unlock, err := schedule.LockChanges(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := schedule.Run(context.Background()); !errors.Is(err, ErrRunInProgress) { + t.Fatalf("updater entered active edit: %v", err) + } + unlock() + entered := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + schedule.resolver = resolverFunc(func(ctx context.Context) ipresolve.Result { + close(entered) + <-ctx.Done() + return ipresolve.Result{IPv4Err: ctx.Err()} + }) + schedule.Start(ctx) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("worker did not start") + } + waitCtx, stop := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer stop() + if release, err := schedule.LockChanges(waitCtx); err == nil { + release() + t.Fatal("edit entered active updater") + } + cancel() + shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), time.Second) + defer stopShutdown() + if err := schedule.Wait(shutdownCtx); err != nil { + t.Fatal(err) + } + release, err := schedule.LockChanges(shutdownCtx) + if err != nil { + t.Fatalf("shutdown left lock held: %v", err) + } + release() +} + type retryResolver struct { calls atomic.Int32 recovered chan struct{} @@ -238,7 +420,7 @@ func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t } } -func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) { +func TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(t *testing.T) { t.Parallel() schedule := &Scheduler{retryMin: 5 * time.Second} tests := []struct { @@ -253,7 +435,7 @@ func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) { {failures: 20, want: time.Minute}, } for _, test := range tests { - if got := schedule.retryDelay(test.failures, time.Minute); got != test.want { + if got := schedule.retryDelay(test.failures, time.Hour); got != test.want { t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want) } } @@ -296,6 +478,7 @@ func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) { } changes <- body.TTL w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) default: http.Error(w, "unexpected request", http.StatusBadRequest) } @@ -359,6 +542,7 @@ func TestSyncStaticTargetWritesManualAddress(t *testing.T) { 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) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) default: http.Error(w, "unexpected request", http.StatusBadRequest) } @@ -424,6 +608,7 @@ func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) { 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) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) default: http.Error(w, "unexpected request", http.StatusBadRequest) } @@ -492,7 +677,8 @@ func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) { return } deletions <- r.URL.Path - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) default: http.Error(w, "unexpected request", http.StatusBadRequest) } @@ -573,7 +759,8 @@ func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) { return } deletions <- r.URL.Path - w.WriteHeader(http.StatusNoContent) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`)) default: http.Error(w, "unexpected request", http.StatusBadRequest) } diff --git a/internal/store/store.go b/internal/store/store.go index 9bf4db9..3d27563 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1435,6 +1435,50 @@ func (s *Store) FinishUpdateRun(ctx context.Context, id int64, status, ipv4, ipv return err } +// CompleteUpdateRun commits history, dashboard state and audit together. +func (s *Store) CompleteUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6), last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status); err != nil { + return err + } + if status != "ok" { + if _, err := tx.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), "update."+status, summary); err != nil { + return err + } + } + return tx.Commit() +} + +// RecoverInterruptedRuns is called once at process startup, before workers start. +func (s *Store) RecoverInterruptedRuns(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = 'error', summary = 'Durch Neustart unterbrochen; erneute Prüfung folgt automatisch' WHERE status = 'running'`, now()) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count > 0 { + if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = 'error' WHERE id = 1`); err != nil { + return err + } + } + return tx.Commit() +} + 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 { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dd560fd..2fdc3ba 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -14,6 +14,49 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" ) +func TestRunCompletionIsAtomicAndInterruptedRunsRecover(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + id, err := database.CreateUpdateRun(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := database.db.Exec(`CREATE TRIGGER reject_state BEFORE UPDATE ON settings BEGIN SELECT RAISE(FAIL, 'storage failure'); END`); err != nil { + t.Fatal(err) + } + if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err == nil { + t.Fatal("storage failure silently ignored") + } + runs, err := database.RecentRuns(ctx, 1) + if err != nil || runs[0].Status != "running" { + t.Fatalf("partially committed run: %#v %v", runs, err) + } + if _, err := database.db.Exec(`DROP TRIGGER reject_state`); err != nil { + t.Fatal(err) + } + if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err != nil { + t.Fatal(err) + } + if _, err := database.CreateUpdateRun(ctx); err != nil { + t.Fatal(err) + } + if err := database.RecoverInterruptedRuns(ctx); err != nil { + t.Fatal(err) + } + runs, err = database.RecentRuns(ctx, 10) + if err != nil || len(runs) != 2 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || runs[1].Status != "ok" { + t.Fatalf("incorrect restart recovery: %#v %v", runs, err) + } + settings, err := database.GetSettings(ctx) + if err != nil || settings.LastIPv4 != "198.51.100.42" || settings.LastRunStatus != "error" { + t.Fatalf("restart corrupted state: %#v %v", settings, err) + } +} + func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/updateagent/controller.go b/internal/updateagent/controller.go index 2075367..585cea2 100644 --- a/internal/updateagent/controller.go +++ b/internal/updateagent/controller.go @@ -32,10 +32,11 @@ func NewController(runner Runner, logger *slog.Logger) *Controller { func agentStatus(phase string) updateprotocol.Status { return updateprotocol.Status{ - Phase: phase, - UpdatedAt: time.Now().UTC(), - AgentVersion: buildinfo.Version, - Protocol: buildinfo.UpdateProtocolVersion, + Phase: phase, + AnonymousPull: true, + UpdatedAt: time.Now().UTC(), + AgentVersion: buildinfo.Version, + Protocol: buildinfo.UpdateProtocolVersion, } } @@ -69,7 +70,9 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) { request.Version = strings.TrimSpace(request.Version) request.CurrentVersion = strings.TrimSpace(request.CurrentVersion) request.RegistryUsername = strings.TrimSpace(request.RegistryUsername) - if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 { + anonymous := request.RegistryUsername == "" && request.RegistryToken == "" + credentialValid := request.RegistryUsername != "" && len(request.RegistryUsername) <= 128 && len(request.RegistryToken) >= 8 && len(request.RegistryToken) <= 1024 + if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || (!anonymous && !credentialValid) { http.Error(w, "invalid update request", http.StatusBadRequest) return } @@ -90,7 +93,7 @@ func (c *Controller) begin(version string) bool { return false } now := time.Now().UTC() - c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion} + c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion, AnonymousPull: true} return true } diff --git a/internal/updateagent/controller_test.go b/internal/updateagent/controller_test.go index 0618895..19b3e0e 100644 --- a/internal/updateagent/controller_test.go +++ b/internal/updateagent/controller_test.go @@ -103,3 +103,30 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { t.Fatalf("update did not complete: %#v", controller.Status()) } } + +func TestControllerAcceptsAnonymousButRejectsPartialCredentials(t *testing.T) { + t.Parallel() + runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})} + defer close(runner.release) + controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil))) + for _, credentials := range []string{`,"registry_username":"user"`, `,"registry_token":"secret-token"`} { + response := httptest.NewRecorder() + controller.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"v1.5.0"`+credentials+`}`))) + if response.Code != 400 { + t.Fatal("partial credentials accepted") + } + } + response := httptest.NewRecorder() + controller.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"v1.5.0"}`))) + if response.Code != 202 || !controller.Status().AnonymousPull { + t.Fatalf("anonymous update rejected: %d", response.Code) + } + select { + case request := <-runner.started: + if request.RegistryToken != "" || request.RegistryUsername != "" { + t.Fatal("anonymous request changed") + } + case <-time.After(time.Second): + t.Fatal("anonymous runner not started") + } +} diff --git a/internal/updateagent/docker.go b/internal/updateagent/docker.go index f70503e..d39a1f9 100644 --- a/internal/updateagent/docker.go +++ b/internal/updateagent/docker.go @@ -104,6 +104,9 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques targetReference := r.imageRepository + ":" + request.Version progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft") if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil { + if errors.Is(err, errRegistryAuthentication) { + return &updateError{message: "Container-Image ist nicht öffentlich zugänglich oder der Zugang wurde abgelehnt. Bitte einen gültigen Gitea-Zugang mit Paket-Leserechten hinterlegen; der Dienst läuft unverändert weiter", err: err} + } return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err} } targetImage, err := r.docker.inspectImage(ctx, targetReference) @@ -137,10 +140,12 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques }() progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet") + // A lost Docker response does not mean the stop was not executed. Arm + // rollback first so a stopped application is restarted on this error path. + oldStopped = true if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil { return err } - oldStopped = true if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil { return err } @@ -315,9 +320,23 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS return running[0], nil } +var errRegistryAuthentication = errors.New("registry authentication required") + func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error { - authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]}) - headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}} + // Public packages must work without credentials, including stale saved ones. + err := c.pullImageOnce(ctx, repository, tag, "", "") + if errors.Is(err, errRegistryAuthentication) && username != "" && credential != "" { + return c.pullImageOnce(ctx, repository, tag, username, credential) + } + return err +} + +func (c *dockerClient) pullImageOnce(ctx context.Context, repository, tag, username, credential string) error { + var headers http.Header + if username != "" && credential != "" { + authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]}) + headers = http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}} + } query := url.Values{"fromImage": {repository}, "tag": {tag}} response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true) if err != nil { @@ -325,21 +344,49 @@ func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return dockerStatusError(response) + err := dockerStatusError(response) + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || registryAuthFailure(err.Error()) { + return fmt.Errorf("%w: %v", errRegistryAuthentication, err) + } + return err } scanner := bufio.NewScanner(response.Body) scanner.Buffer(make([]byte, 64<<10), 1<<20) for scanner.Scan() { var event struct { - Error string `json:"error"` + Error string `json:"error"` + ErrorDetail struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"errorDetail"` } - if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Error != "" { - return errors.New(event.Error) + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + return errors.New("Docker returned invalid pull progress") + } + message := event.Error + if message == "" { + message = event.ErrorDetail.Message + } + if message != "" { + if event.ErrorDetail.Code == http.StatusUnauthorized || event.ErrorDetail.Code == http.StatusForbidden || registryAuthFailure(message) { + return fmt.Errorf("%w: %s", errRegistryAuthentication, message) + } + return errors.New(message) } } return scanner.Err() } +func registryAuthFailure(message string) bool { + message = strings.ToLower(message) + for _, marker := range []string{"unauthorized", "authentication required", "access denied", "access to the resource is denied", "pull access denied", "no basic auth credentials"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} + func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) { response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true) if err != nil { @@ -805,9 +852,9 @@ func (c *dockerClient) removeContainer(ctx context.Context, id string, force boo } func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error { - deadline := time.NewTimer(timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() ticker := time.NewTicker(time.Second) - defer deadline.Stop() defer ticker.Stop() consecutiveHealthy := 0 startedAt := time.Now() @@ -815,8 +862,6 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, exp select { case <-ctx.Done(): return ctx.Err() - case <-deadline.C: - return errors.New("replacement container did not become healthy in time") case <-ticker.C: inspected, err := c.inspectContainer(ctx, id) if err != nil { @@ -859,16 +904,14 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, exp } func (c *dockerClient) waitCurrentHealthy(ctx context.Context, id, expectedImageID string, timeout, pollInterval time.Duration) (containerInspect, error) { - deadline := time.NewTimer(timeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() ticker := time.NewTicker(pollInterval) - defer deadline.Stop() defer ticker.Stop() for { select { case <-ctx.Done(): return containerInspect{}, ctx.Err() - case <-deadline.C: - return containerInspect{}, errors.New("current container did not become healthy in time") case <-ticker.C: inspected, err := c.inspectContainer(ctx, id) if err != nil { diff --git a/internal/updateagent/docker_test.go b/internal/updateagent/docker_test.go index 8b662fe..dd27b36 100644 --- a/internal/updateagent/docker_test.go +++ b/internal/updateagent/docker_test.go @@ -2,9 +2,11 @@ package updateagent import ( "context" + "encoding/base64" "encoding/json" "errors" "io" + "log/slog" "net/http" "strconv" "strings" @@ -13,8 +15,85 @@ import ( "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) +func TestLostStopResponseRestartsPreviousContainer(t *testing.T) { + current := compatibleImage("v1.4.0", "v1.4.0") + current.ID = "sha256:current" + target := compatibleImage("v1.5.0", "v1.4.0") + var config map[string]json.RawMessage + if err := json.Unmarshal(current.Config, &config); err != nil { + t.Fatal(err) + } + config["Image"] = json.RawMessage(`"registry.example/app:v1.4.0"`) + containerConfig, _ := json.Marshal(config) + stopped, restarted := false, false + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + var body any + status := http.StatusOK + switch strings.TrimPrefix(r.URL.Path, "/v1.0") { + case "/version": + body = map[string]string{"ApiVersion": "1.0"} + case "/containers/json": + body = []containerSummary{{ID: "old", State: "running"}} + case "/containers/old/json": + body = map[string]any{"Id": "old", "Name": "/app", "Image": current.ID, "Config": json.RawMessage(containerConfig), "HostConfig": map[string]any{}, "State": map[string]any{"Running": !stopped, "Health": map[string]string{"Status": "healthy"}}} + case "/images/sha256:current/json": + body = current + case "/images/registry.example/app:v1.5.0/json": + body = target + case "/images/create": + body = map[string]string{"status": "downloaded"} + case "/containers/old/stop": + stopped = true + return nil, io.ErrUnexpectedEOF + case "/containers/old/start": + stopped = false + restarted = true + status = http.StatusNoContent + default: + t.Errorf("unexpected Docker call: %s %s", r.Method, r.URL.Path) + status = http.StatusInternalServerError + } + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}, nil + }) + runner := &DockerRunner{docker: &dockerClient{client: &http.Client{Transport: transport}}, imageRepository: "registry.example/app", logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + err := runner.Update(context.Background(), updateprotocol.Request{Version: "v1.5.0", CurrentVersion: "v1.4.0"}, func(string, string) {}) + if !errors.Is(err, io.ErrUnexpectedEOF) || !restarted || stopped { + t.Fatalf("lost stop response left service down: restarted=%v stopped=%v err=%v", restarted, stopped, err) + } +} + +func TestHealthDeadlineCancelsStalledDockerRequest(t *testing.T) { + for _, replacement := range []bool{false, true} { + t.Run(strconv.FormatBool(replacement), func(t *testing.T) { + var requests atomic.Int32 + client := &dockerClient{client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests.Add(1) + <-r.Context().Done() + return nil, r.Context().Err() + })}} + // Parent outlives the health deadline; the request must use the latter. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var err error + if replacement { + err = client.waitHealthy(ctx, "id", "", "", 1200*time.Millisecond) + } else { + _, err = client.waitCurrentHealthy(ctx, "id", "", 30*time.Millisecond, time.Millisecond) + } + if !errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil || requests.Load() != 1 { + t.Fatalf("health deadline did not cancel the Docker request: %v requests=%d", err, requests.Load()) + } + }) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { @@ -258,3 +337,57 @@ func dockerTestClient(health func() string) *dockerClient { }) return &dockerClient{client: &http.Client{Transport: transport}, apiPrefix: "/v1.0"} } + +func TestImagePullUsesAnonymousThenOptionalCredentials(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, first string + status int + credentials bool + wantCalls int + wantErr bool + }{ + {"public", `{"status":"download complete"}`, 200, false, 1, false}, + {"public with stale credential", `{"status":"download complete"}`, 200, true, 1, false}, + {"private stream error", `{"error":"unauthorized: authentication required"}`, 200, true, 2, false}, + {"private status", `{"message":"denied"}`, 401, true, 2, false}, + {"private errorDetail", `{"errorDetail":{"code":403,"message":"denied"}}`, 200, true, 2, false}, + {"private no access", `{"error":"unauthorized"}`, 200, false, 1, true}, + {"network error", `{"error":"connection timed out"}`, 200, true, 1, true}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + client := &dockerClient{client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + status, body := test.status, test.first + if calls == 1 { + if r.Header.Get("X-Registry-Auth") != "" { + t.Error("first pull sent credentials") + } + } else { + raw, err := base64.URLEncoding.DecodeString(r.Header.Get("X-Registry-Auth")) + if err != nil { + t.Fatal(err) + } + var auth map[string]string + if err = json.Unmarshal(raw, &auth); err != nil { + t.Fatal(err) + } + if auth["username"] != "user" || auth["password"] != "test-password" { + t.Error("missing private pull credentials") + } + status, body = 200, `{"status":"download complete"}` + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })}} + username, password := "", "" + if test.credentials { + username, password = "user", "test-password" + } + err := client.pullImage(context.Background(), "registry.example/app", "v2.0.0", username, password) + if (err != nil) != test.wantErr || calls != test.wantCalls { + t.Fatalf("pull calls=%d err=%v", calls, err) + } + }) + } +} diff --git a/internal/updateprotocol/protocol.go b/internal/updateprotocol/protocol.go index 42dcdfe..6d5093f 100644 --- a/internal/updateprotocol/protocol.go +++ b/internal/updateprotocol/protocol.go @@ -30,6 +30,8 @@ type Request struct { } type Status struct { + // Optional capability; older v2 agents require registry credentials. + AnonymousPull bool `json:"anonymous_pull,omitempty"` Phase string `json:"phase"` TargetVersion string `json:"target_version,omitempty"` Message string `json:"message,omitempty"` diff --git a/internal/web/diagnostics.go b/internal/web/diagnostics.go new file mode 100644 index 0000000..e3b6138 --- /dev/null +++ b/internal/web/diagnostics.go @@ -0,0 +1,70 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/netip" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" +) + +// Only this allowlisted report is downloadable, never raw Docker logs, stored +// error messages, audit messages, configuration, credentials or session data. +func (s *Server) downloadDiagnostics(w http.ResponseWriter, r *http.Request, _ *model.Session) { + settings, err := s.store.GetSettings(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + runs, err := s.store.RecentRuns(r.Context(), 100) + if err != nil { + s.serverError(w, err) + return + } + type run struct { + ID int64 `json:"id"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` + } + report := struct { + Format int `json:"format"` + Version string `json:"version"` + ExportedAt time.Time `json:"exported_at"` + Network *diagnostics.Network `json:"current_network"` + UpdaterEnabled bool `json:"updater_enabled"` + IPv6Enabled bool `json:"ipv6_enabled"` + IntervalSeconds int `json:"interval_seconds"` + LastIPv4 string `json:"last_ipv4,omitempty"` + LastIPv6 string `json:"last_ipv6,omitempty"` + Runs []run `json:"recent_runs"` + Events []diagnostics.Event `json:"events"` + }{ + Format: 1, Version: buildinfo.Version, ExportedAt: time.Now().UTC(), Network: diagnostics.CurrentNetwork(), + UpdaterEnabled: settings.UpdaterEnabled, IPv6Enabled: settings.IPv6Enabled, IntervalSeconds: settings.UpdateIntervalSeconds, + Events: diagnostics.Snapshot(), + } + if ip, err := netip.ParseAddr(settings.LastIPv4); err == nil && ip.Zone() == "" { + report.LastIPv4 = ip.String() + } + if ip, err := netip.ParseAddr(settings.LastIPv6); err == nil && ip.Zone() == "" { + report.LastIPv6 = ip.String() + } + for _, item := range runs { + status := "unknown" + switch item.Status { + case "running", "ok", "error", "partial": + status = item.Status + } + report.Runs = append(report.Runs, run{item.ID, item.StartedAt, status}) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="domain-control-diagnostics.json"`) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(report) +} diff --git a/internal/web/diagnostics_test.go b/internal/web/diagnostics_test.go new file mode 100644 index 0000000..c80f9f1 --- /dev/null +++ b/internal/web/diagnostics_test.go @@ -0,0 +1,109 @@ +package web + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" +) + +func TestPiHolePageAndRemovalNeverContactUnavailablePiHole(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unavailable", 503) })) + defer api.Close() + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: []byte("secret"), Nonce: []byte("secret")}) + if err != nil { + t.Fatal(err) + } + if _, err = database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.10 nas.lan"}); err != nil { + t.Fatal(err) + } + if err = database.MarkPiHoleSynced(ctx, integration.ID, "connection timed out"); err != nil { + t.Fatal(err) + } + templates, err := loadTemplates() + if err != nil { + t.Fatal(err) + } + server := &Server{store: database, templates: templates, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + session := &model.Session{CSRFToken: "csrf", Username: "admin", Role: "admin"} + response := httptest.NewRecorder() + server.piHolePage(response, httptest.NewRequest(http.MethodGet, "/pihole", nil), session) + if response.Code != 200 || !strings.Contains(response.Body.String(), "nas.lan") || !strings.Contains(response.Body.String(), "Lokale Verbindung entfernen") { + t.Fatalf("offline page unavailable: %d", response.Code) + } + id := strconv.FormatInt(integration.ID, 10) + request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/remove", strings.NewReader(url.Values{"csrf": {"csrf"}}.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetPathValue("id", id) + response = httptest.NewRecorder() + server.piHoleRemove(response, request, session) + if response.Code != http.StatusSeeOther { + t.Fatalf("offline removal failed: %d", response.Code) + } + integrations, err := database.ListPiHoleIntegrations(ctx) + if err != nil || len(integrations) != 0 { + t.Fatal("connection not removed") + } + if requests.Load() != 0 { + t.Fatalf("offline page/removal contacted Pi-hole %d times", requests.Load()) + } +} + +func TestDiagnosticDownloadExcludesStoredSecretsAndFreeText(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) + } + defer database.Close() + const secret = "git-hetzner-pihole-cookie-password-secret" + if err = database.SaveSoftwareUpdateCredential(ctx, secret, []byte(secret), []byte(secret)); err != nil { + t.Fatal(err) + } + if err = database.AddAudit(ctx, "arbitrary", secret); err != nil { + t.Fatal(err) + } + id, err := database.CreateUpdateRun(ctx) + if err != nil { + t.Fatal(err) + } + if err = database.FinishUpdateRun(ctx, id, "error", secret, secret, "https://user:"+secret+"@host/?token="+secret); err != nil { + t.Fatal(err) + } + server := &Server{store: database, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + response := httptest.NewRecorder() + server.downloadDiagnostics(response, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil), &model.Session{Role: "admin"}) + if response.Code != 200 || !strings.Contains(response.Header().Get("Content-Disposition"), "attachment") || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("invalid download: %d", response.Code) + } + if strings.Contains(response.Body.String(), secret) { + t.Fatal("secret included in download") + } + if !strings.Contains(response.Body.String(), `"status": "error"`) { + t.Fatal("run status missing") + } + anonymous := httptest.NewRecorder() + server.Handler().ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil)) + if anonymous.Code == 200 { + t.Fatal("anonymous diagnostic access accepted") + } +} diff --git a/internal/web/pihole.go b/internal/web/pihole.go index 86ad56f..3b7a5cc 100644 --- a/internal/web/pihole.go +++ b/internal/web/pihole.go @@ -13,6 +13,7 @@ import ( "golang.org/x/net/idna" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" @@ -44,18 +45,12 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod var view *piHoleView if len(integrations) > 0 { integration := integrations[0] - current := piHoleView{Integration: integration} - client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID) - var links []model.PiHoleRecordLink - if accessErr == nil { - var hosts []string - hosts, accessErr = client.ListHosts(r.Context(), password) - if accessErr == nil { - links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts) - } - } - if accessErr != nil { - links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID) + current := piHoleView{Integration: integration, FetchError: integration.LastError} + // Navigation and removing an obsolete connection must work offline. + links, err := s.store.ListPiHoleRecordLinks(r.Context(), integration.ID) + if err != nil { + s.serverError(w, err) + return } for _, link := range links { record := parsePiHoleRecord(link.Value) @@ -68,15 +63,6 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod current.ArchivedRecords = append(current.ArchivedRecords, record) } } - if accessErr != nil { - current.FetchError = safeMessage(accessErr) - current.Integration.LastError = current.FetchError - _ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError) - } else { - current.Integration.LastError = "" - current.Integration.LastSyncAt = time.Now().UTC() - _ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "") - } sortPiHoleRecords(current.Records) sortPiHoleRecords(current.ArchivedRecords) view = ¤t @@ -122,7 +108,8 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session * s.redirectNotice(w, r, "/pihole", err.Error(), "error") return } - if _, err := client.ListHosts(r.Context(), password); err != nil { + hosts, err := client.ListHosts(r.Context(), password) + if err != nil { s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error") return } @@ -140,6 +127,10 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session * s.redirectNotice(w, r, "/pihole", message, "error") return } + if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts); err != nil { + s.serverError(w, err) + return + } _ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "") _ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username) s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.") @@ -180,7 +171,8 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error") return } - if _, err := client.ListHosts(r.Context(), password); err != nil { + hosts, err := client.ListHosts(r.Context(), password) + if err != nil { s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error") return } @@ -194,6 +186,10 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error") return } + if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts); err != nil { + s.serverError(w, err) + return + } _ = s.store.MarkPiHoleSynced(r.Context(), id, "") _ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username) s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.") @@ -254,16 +250,24 @@ func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session * if !ok { return } - client, password, err := s.piHoleAccess(r.Context(), id) + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + client, password, err := s.piHoleAccess(ctx, id) + var hosts []string if err == nil { - _, err = client.ListHosts(r.Context(), password) + hosts, err = client.ListHosts(ctx, password) + } + if err == nil { + _, err = s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts) } if err != nil { + diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_failed", Target: "pihole", Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)}) message := safeMessage(err) _ = s.store.MarkPiHoleSynced(r.Context(), id, message) s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error") return } + diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_ok", Target: "pihole"}) _ = s.store.MarkPiHoleSynced(r.Context(), id, "") s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.") } diff --git a/internal/web/server.go b/internal/web/server.go index 859676c..f0b39ca 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,6 +14,7 @@ import ( "net/http" "net/netip" "net/url" + "slices" "strconv" "strings" "sync" @@ -144,22 +145,22 @@ func (s *Server) Handler() http.Handler { 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("POST /domains", s.adminOnly(s.dnsChange(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("POST /domains/{id}/import", s.adminOnly(s.dnsChange(s.domainImport))) + mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.dnsChange(s.domainTTLSave))) + mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.dnsChange(s.domainRemove))) + mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.dnsChange(s.domainDelete))) + mux.Handle("POST /domains/{id}/token", s.adminOnly(s.dnsChange(s.domainToken))) + mux.Handle("POST /targets", s.authenticated(s.dnsChange(s.targetCreate))) + mux.Handle("POST /targets/{id}/save", s.authenticated(s.dnsChange(s.targetSave))) + mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.dnsChange(s.targetDelete))) + mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.dnsChange(s.targetArchive))) + mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.dnsChange(s.targetRestore))) + mux.Handle("POST /cnames", s.adminOnly(s.dnsChange(s.cnameCreate))) + mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.dnsChange(s.cnameSave))) + mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.dnsChange(s.cnameRemove))) + mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.dnsChange(s.cnameDelete))) mux.Handle("GET /pihole", s.adminOnly(s.piHolePage)) mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect)) mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave)) @@ -173,13 +174,14 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /pihole/{id}/records/archive", s.adminOnly(s.piHoleRecordArchive)) mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore)) mux.Handle("GET /settings", s.adminOnly(s.settingsPage)) - mux.Handle("POST /settings", s.adminOnly(s.settingsSave)) + mux.Handle("POST /settings", s.adminOnly(s.dnsChange(s.settingsSave))) mux.Handle("POST /run", s.adminOnly(s.runNow)) mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage)) mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave)) mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck)) mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart)) mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart)) + mux.Handle("GET /errors/diagnostics", s.adminOnly(s.downloadDiagnostics)) mux.Handle("GET /errors", s.adminOnly(s.errorsPage)) mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) mux.Handle("GET /account", s.authenticated(s.accountPage)) @@ -818,7 +820,7 @@ func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *m 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.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated) s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") return } @@ -932,7 +934,7 @@ func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *mod 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.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated) s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") return } @@ -1261,11 +1263,13 @@ func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request, session *m 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 !slices.Equal(ipv4, current.IPv4Endpoints) { + 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 ipv6Enabled && !slices.Equal(ipv6, current.IPv6Endpoints) { if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil { s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error") return @@ -1364,7 +1368,7 @@ func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, ses cancel() if err != nil { if errors.Is(err, appupdate.ErrCredentialMissing) { - s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info") + s.redirectNotice(w, r, "/system#update-access", appupdate.ErrCredentialMissing.Error(), "info") return } s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error") @@ -1847,6 +1851,30 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.") } +// The lock spans reading the old target, remote changes and saving the new +// configuration. Locking individual HTTP calls still permits stale worker writes. +func (s *Server) dnsChange(next func(http.ResponseWriter, *http.Request, *model.Session)) func(http.ResponseWriter, *http.Request, *model.Session) { + return func(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ültiges CSRF-Token", http.StatusForbidden) + return + } + waitCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + unlock, err := s.scheduler.LockChanges(waitCtx) + if err != nil { + w.Header().Set("Retry-After", "5") + http.Error(w, "Eine DNS-Aktualisierung läuft noch. Bitte die Änderung in Kürze erneut senden.", http.StatusServiceUnavailable) + return + } + defer unlock() + next(w, r, session) + } +} + 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) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 5c1c6f8..5b81d2f 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "log/slog" "mime/multipart" "net/http" @@ -27,6 +28,76 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) +func TestDNSMutationHoldsUpdaterLockAndRequiresCSRF(t *testing.T) { + worker := scheduler.New(nil, nil, nil, nil, nil) + server := &Server{scheduler: worker} + session := &model.Session{CSRFToken: "valid"} + called := false + handler := server.dnsChange(func(w http.ResponseWriter, r *http.Request, _ *model.Session) { + called = true + if _, err := worker.Run(r.Context()); !errors.Is(err, scheduler.ErrRunInProgress) { + t.Fatalf("updater entered mutation: %v", err) + } + w.WriteHeader(http.StatusNoContent) + }) + for _, csrf := range []string{"wrong", "valid"} { + request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf="+csrf)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + handler(response, request, session) + if csrf == "wrong" && (called || response.Code != http.StatusForbidden) { + t.Fatal("invalid CSRF entered mutation") + } + if csrf == "valid" && (!called || response.Code != http.StatusNoContent) { + t.Fatal("valid mutation was blocked") + } + } + waitCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + unlock, err := worker.LockChanges(waitCtx) + if err != nil { + t.Fatal("mutation did not release lock") + } + defer unlock() + called = false + request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf=valid")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ctx, stop := context.WithCancel(context.Background()) + stop() + response := httptest.NewRecorder() + handler(response, request.WithContext(ctx), session) + if called || response.Code != http.StatusServiceUnavailable { + t.Fatal("canceled edit entered lock") + } +} + +func TestUnchangedEndpointsCanBeSavedWhileDNSIsUnavailable(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + settings, err := database.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(ctx, 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { + t.Fatal(err) + } + // No resolver/provider: unchanged endpoints must not require a network call. + server := &Server{store: database, scheduler: scheduler.New(database, nil, nil, nil, nil)} + form := url.Values{"csrf": {"valid"}, "interval": {"600"}, "ipv4_endpoints": {strings.Join(settings.IPv4Endpoints, "\n")}, "ipv6_endpoints": {strings.Join(settings.IPv6Endpoints, "\n")}} + request := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + server.dnsChange(server.settingsSave)(response, request, &model.Session{CSRFToken: "valid", Role: "admin"}) + settings, err = database.GetSettings(ctx) + if err != nil || response.Code != http.StatusSeeOther || settings.UpdaterEnabled || settings.UpdateIntervalSeconds != 600 { + t.Fatalf("offline settings save failed: status=%d enabled=%v err=%v", response.Code, settings.UpdaterEnabled, err) + } +} + func TestInputNormalizers(t *testing.T) { t.Parallel() username, err := normalizeUsername("Alice_Admin") @@ -293,7 +364,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) { `data-dashboard-ipv6`, `data-recent-runs aria-live="polite" aria-busy="true"`, `Aktualisierung läuft`, - `features-29`, + `features-30`, `data-local-time="1788001920"`, `dashboard-zone-item`, `DNS-Zone · example.de`, @@ -414,6 +485,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) { {method: http.MethodGet, path: "/users"}, {method: http.MethodGet, path: "/users/new"}, {method: http.MethodGet, path: "/system"}, + {method: http.MethodGet, path: "/errors/diagnostics"}, {method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"}, {method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"}, } @@ -541,7 +613,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil { t.Fatal(err) } - for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `
`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} { + for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Privater Zugang gespeichert", "@registry-user", "Zugang ändern", "Passwort/Token", `
`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} { if !strings.Contains(page.String(), expected) { t.Errorf("software update page is missing %q", expected) } @@ -551,7 +623,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil { t.Fatal(err) } - for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} { + for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Bereit", "Privater Zugang (optional)"} { if !strings.Contains(freshPage.String(), expected) { t.Errorf("fresh software update page is missing %q", expected) } @@ -857,14 +929,23 @@ func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T) func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) { t.Parallel() + var phase atomic.Int32 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if phase.Load() == 2 { + http.Error(w, "Pi-hole unavailable", http.StatusServiceUnavailable) + return + } switch { case r.URL.Path == "/api/auth" && r.Method == http.MethodPost: w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`)) case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid": w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`)) + if phase.Load() == 1 { + _, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.21 nas.lan"]}}}`)) + } else { + _, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`)) + } case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid": w.WriteHeader(http.StatusNoContent) default: @@ -909,6 +990,10 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" { t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err) } + links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID) + if err != nil || len(links) != 1 || links[0].Value != "192.168.1.20 nas.lan" { + t.Fatalf("initial import not persisted: %#v %v", links, err) + } _, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID) if err != nil { t.Fatal(err) @@ -917,6 +1002,28 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") { t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err) } + // A successful refresh persists the new state without a subsequent page + // load; an outage must retain that state instead of treating it as empty. + for _, step := range []int32{1, 2} { + phase.Store(step) + id := strconv.FormatInt(integrations[0].ID, 10) + request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/refresh", strings.NewReader("csrf=csrf-token")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetPathValue("id", id) + response := httptest.NewRecorder() + server.piHoleRefresh(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"}) + if response.Code != http.StatusSeeOther { + t.Fatalf("refresh response: %d", response.Code) + } + links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID) + if err != nil || len(links) != 2 || links[0].Active || !links[1].Active || links[1].Value != "192.168.1.21 nas.lan" { + t.Fatalf("refresh lost cached records in phase %d: %#v %v", step, links, err) + } + integration, err := database.GetPiHoleIntegration(ctx, integrations[0].ID) + if err != nil || (integration.LastError != "") != (step == 2) { + t.Fatalf("refresh status incorrect: %#v %v", integration, err) + } + } } func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) { diff --git a/internal/web/templates/errors.html b/internal/web/templates/errors.html index 035e31e..b26eace 100644 --- a/internal/web/templates/errors.html +++ b/internal/web/templates/errors.html @@ -2,10 +2,11 @@ {{define "content"}}
← ÜbersichtDiagnose

Fehlerzentrale

Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.

+
Diagnose-Logs herunterladen
-
+
{{if eq .ErrorCount 0}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index b0745f3..c59af1c 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -6,8 +6,8 @@ {{.Title}} · DNS Control - - + + diff --git a/internal/web/templates/pihole.html b/internal/web/templates/pihole.html index aad56f8..7210c30 100644 --- a/internal/web/templates/pihole.html +++ b/internal/web/templates/pihole.html @@ -12,7 +12,7 @@ {{$integration := $view.Integration}}
ProviderPi-hole API v6
-
Letzter Import
+
Letzte Prüfung
Verknüpfte Einträge{{len $view.Records}}
Getrennte Einträge{{len $view.ArchivedRecords}}
@@ -21,6 +21,8 @@
Pi-hole konnte nicht neu eingelesen werden

{{$view.FetchError}}

{{end}} +

Gespeicherter Stand. „Records neu einlesen“ prüft die Verbindung und aktualisiert die Einträge. Die Verbindung lässt sich auch ohne erreichbaren Pi-hole verwalten und entfernen.

+

Lokale DNS-Einträge

{{len $view.Records}} Einträge
{{if $view.Records}} @@ -67,7 +69,7 @@

Neuer lokaler DNS-Eintrag

-

Verbindung verwalten

{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}
+

Verbindung verwalten

{{if $view.FetchError}}Nicht erreichbar{{else}}Zuletzt erreichbar{{end}}

Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.

diff --git a/internal/web/templates/system.html b/internal/web/templates/system.html index 45c14f3..497949b 100644 --- a/internal/web/templates/system.html +++ b/internal/web/templates/system.html @@ -10,12 +10,12 @@
Installiert{{.SoftwareUpdate.CurrentVersion}}
-
{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}
+
{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}–{{end}}
-

{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}

+

{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}

Letzte Prüfung Update-Agent {{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}
{{if .SoftwareUpdate.LastError}}
!

Prüfung fehlgeschlagen{{.SoftwareUpdate.LastError}}

{{end}} - {{if .SoftwareUpdate.ComposeSyncRequired}}
↻

Compose einmal aktualisierenDer installierte Agent unterstützt die sichere Update-Basis noch nicht.

{{end}} + {{if .SoftwareUpdate.ComposeSyncRequired}}
↻

Compose einmal aktualisierenDer installierte Agent unterstützt die benötigte Update-Funktion noch nicht. Für Updates ohne Anmeldung muss auch der Agent diese Funktion unterstützen.

{{end}}
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}} @@ -28,7 +28,7 @@ {{else if .SoftwareUpdate.UpdateAvailable}} Agent nicht erreichbar {{else}} - {{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}} + {{if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}Bereit{{end}} {{end}}
@@ -43,16 +43,17 @@
+

Öffentliche Releases und Container-Images werden ohne Anmeldung geladen. Für private Repositories oder Images kannst du einen Zugang hinterlegen; er wird nur bei Bedarf verwendet.

{{if .SoftwareUpdate.CredentialConfigured}}
-

Mit Gitea verbunden

Angemeldet als @{{.SoftwareUpdate.CredentialUsername}}

- Verbunden +

Privater Zugang gespeichert

Hinterlegt für @{{.SoftwareUpdate.CredentialUsername}}

+ Gespeichert
Zugang ändernBenutzername oder Passwort/Token ersetzen {{else}} -

Update-Zugang einrichten

+

Privater Zugang (optional)

{{end}}