Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81def77e3c |
No files matched your search
@@ -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 ./...'
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
+20
-1
@@ -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{
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+110
-52
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+29
-25
@@ -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.")
|
||||
}
|
||||
|
||||
+51
-23
@@ -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)
|
||||
|
||||
+111
-4
@@ -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", `<details class="update-credential-editor">`, `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", `<details class="update-credential-editor">`, `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) {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.</p></div>
|
||||
<div class="form-actions"><a class="button button-quiet" href="/errors/diagnostics" download>Diagnose-Logs herunterladen</a>
|
||||
<form action="/errors/clear" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||
</form>
|
||||
</form></div>
|
||||
</header>
|
||||
|
||||
{{if eq .ErrorCount 0}}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · DNS Control</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-29">
|
||||
<script src="/static/app.js?v=features-29" defer></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-30">
|
||||
<script src="/static/app.js?v=features-30" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{{$integration := $view.Integration}}
|
||||
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
||||
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Letzte Prüfung</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||
</section>
|
||||
@@ -21,6 +21,8 @@
|
||||
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted">Gespeicherter Stand. „Records neu einlesen“ prüft die Verbindung und aktualisiert die Einträge. Die Verbindung lässt sich auch ohne erreichbaren Pi-hole <a href="#pihole-connection">verwalten und entfernen</a>.</p>
|
||||
|
||||
<section class="targets-section" id="local-dns">
|
||||
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
|
||||
{{if $view.Records}}
|
||||
@@ -67,7 +69,7 @@
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
|
||||
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Zuletzt erreichbar{{end}}</span></div>
|
||||
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
||||
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
||||
<div class="panel-divider"></div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<div class="software-version-copy">
|
||||
<div class="software-version-values">
|
||||
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
||||
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
|
||||
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}–{{end}}</strong></div>
|
||||
</div>
|
||||
<p class="software-version-state">{{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}}</p>
|
||||
<p class="software-version-state">{{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}}</p>
|
||||
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
|
||||
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die benötigte Update-Funktion noch nicht. Für Updates ohne Anmeldung muss auch der Agent diese Funktion unterstützen.</small></p></div>{{end}}
|
||||
</div>
|
||||
<div class="software-version-action">
|
||||
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||
@@ -28,7 +28,7 @@
|
||||
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
||||
{{else}}
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{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}}</span>
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}Bereit{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -43,16 +43,17 @@
|
||||
</section>
|
||||
|
||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||
<p>Ö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.</p>
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||
<div class="update-access-state">
|
||||
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
||||
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
|
||||
<div><span class="section-label">Update-Zugang</span><h2>Privater Zugang gespeichert</h2><p>Hinterlegt für <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||
<span class="status-pill status-good"><i aria-hidden="true"></i> Gespeichert</span>
|
||||
</div>
|
||||
<details class="update-credential-editor">
|
||||
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
|
||||
{{else}}
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Privater Zugang (optional)</h2></div></div>
|
||||
{{end}}
|
||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
|
||||
Reference in new issue
Block a user