fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
4a468bf152
37 files changed
+2395
-230
No files matched your search
@@ -77,7 +77,7 @@ Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewu
|
|||||||
```bash
|
```bash
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$PWD":/src \
|
-v "$PWD":/src \
|
||||||
-w /src golang:1.25.13-alpine \
|
-w /src golang:1.26.9-alpine \
|
||||||
gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go
|
gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker run --rm \
|
|||||||
-v "$PWD":/src \
|
-v "$PWD":/src \
|
||||||
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||||
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
-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 ./...'
|
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 "$PWD":/src \
|
||||||
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||||
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
-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 ./...'
|
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
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG VERSION=v1.5.0
|
ARG VERSION=v1.5.1
|
||||||
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
||||||
ARG MINIMUM_AGENT_PROTOCOL=2
|
ARG MINIMUM_AGENT_PROTOCOL=2
|
||||||
ARG RUNTIME_CONTRACT_VERSION=2
|
ARG RUNTIME_CONTRACT_VERSION=2
|
||||||
ARG DATABASE_CONTRACT_VERSION=1
|
ARG DATABASE_CONTRACT_VERSION=1
|
||||||
ARG SCHEMA_VERSION=11
|
ARG SCHEMA_VERSION=11
|
||||||
|
|
||||||
FROM golang:1.25.13-alpine AS build
|
FROM golang:1.26.9-alpine AS build
|
||||||
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
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
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
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,
|
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
||||||
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
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.
|
`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
|
- 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
|
- 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"`.
|
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.
|
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
|
# 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
|
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||||
den folgenden Verfahren erhalten.
|
den folgenden Verfahren erhalten.
|
||||||
|
|
||||||
## Direkt in der Weboberfläche
|
## Direkt in der Weboberfläche
|
||||||
|
|
||||||
1. Als Administrator anmelden und **Update** öffnen.
|
1. Als Administrator anmelden und **Update** öffnen.
|
||||||
2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder einen
|
2. Bei öffentlichen Releases und Images ist keine Anmeldung erforderlich.
|
||||||
Token mit Lesezugriff auf Repository und Container-Paket hinterlegen.
|
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.
|
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
|
||||||
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
||||||
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
||||||
Container wiederherzustellen.
|
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
|
Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden
|
||||||
DynDNS-Dienst nicht. Direkte Sprünge über mehrere Versionen sind möglich, wenn
|
DynDNS-Dienst nicht. Direkte Sprünge über mehrere Versionen sind möglich, wenn
|
||||||
das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese
|
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
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
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:
|
update-agent:
|
||||||
image: *image
|
image: *image
|
||||||
```
|
```
|
||||||
@@ -79,7 +97,7 @@ Die erwartete Antwort lautet `ok`.
|
|||||||
## Portainer oder Dockge
|
## Portainer oder Dockge
|
||||||
|
|
||||||
1. Den bestehenden Stack bearbeiten.
|
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.
|
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
||||||
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"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/appupdate"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
"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/hetzner"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||||
@@ -74,6 +76,10 @@ func run(logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -84,6 +90,9 @@ func run(logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer database.Close()
|
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 {
|
if err := database.Prune(ctx); err != nil {
|
||||||
logger.Warn("prune old local data", "error", err)
|
logger.Warn("prune old local data", "error", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +107,9 @@ func run(logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !settings.IPv6Enabled {
|
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 {
|
if cleanupErr != nil {
|
||||||
logger.Warn("enforce disabled IPv6", "error", cleanupErr)
|
logger.Warn("enforce disabled IPv6", "error", cleanupErr)
|
||||||
} else if deleted > 0 {
|
} else if deleted > 0 {
|
||||||
@@ -128,6 +139,14 @@ func run(logger *slog.Logger) error {
|
|||||||
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
updater.Start(appCtx)
|
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)
|
softwareUpdates.Start(appCtx)
|
||||||
go pruneStore(appCtx, database, logger)
|
go pruneStore(appCtx, database, logger)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
dyndns:
|
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
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface
|
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 (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.47
|
github.com/mattn/go-sqlite3 v1.14.47
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
"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/model"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
"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/store"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
"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 (
|
const (
|
||||||
agentUnavailableRetryInterval = 2 * time.Second
|
agentUnavailableRetryInterval = 2 * time.Second
|
||||||
@@ -198,18 +199,19 @@ func (m *Manager) CheckNow(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
|
// Public releases must remain usable even with missing or stale credentials.
|
||||||
m.setStoredState(settings)
|
release, err := m.fetchLatest(ctx, "", "")
|
||||||
return ErrCredentialMissing
|
if errors.Is(err, ErrCredentialMissing) && settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||||
}
|
credential, decryptErr := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||||
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
if decryptErr != nil {
|
||||||
if err != nil {
|
|
||||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||||
}
|
}
|
||||||
release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
release, err = m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m.recordFailure(ctx, err)
|
return m.recordFailure(ctx, err)
|
||||||
}
|
}
|
||||||
|
diagnostics.Record(diagnostics.Event{Code: "release_check_ok", Target: "git"})
|
||||||
checkedAt := time.Now().UTC()
|
checkedAt := time.Now().UTC()
|
||||||
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -247,14 +249,22 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
var username, credential string
|
||||||
if err != nil || settings.RegistryUsername == "" {
|
if settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||||
return ErrCredentialMissing
|
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 {
|
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||||
return err
|
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 {
|
if err := m.agent.Start(ctx, request); err != nil {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
m.clearDesiredVersion(cleanupCtx, version)
|
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.AgentVersion = status.AgentVersion
|
||||||
m.state.AgentProtocol = status.Protocol
|
m.state.AgentProtocol = status.Protocol
|
||||||
m.state.AgentCompatible = agentCompatible(status)
|
m.state.AgentCompatible = agentCompatible(status)
|
||||||
m.state.ComposeSyncRequired = !m.state.AgentCompatible
|
m.state.ComposeSyncRequired = !m.state.AgentCompatible || (!m.state.CredentialConfigured && !status.AnonymousPull)
|
||||||
} else {
|
} else {
|
||||||
m.state.AgentCompatible = false
|
m.state.AgentCompatible = false
|
||||||
m.state.ComposeSyncRequired = false
|
m.state.ComposeSyncRequired = false
|
||||||
@@ -299,7 +309,7 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return releaseResponse{}, err
|
return releaseResponse{}, err
|
||||||
}
|
}
|
||||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
if needsAuthentication(response.StatusCode) && username != "" && credential != "" {
|
||||||
response.Body.Close()
|
response.Body.Close()
|
||||||
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -307,11 +317,14 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
if needsAuthentication(response.StatusCode) {
|
||||||
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
|
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 {
|
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
|
var release releaseResponse
|
||||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
|
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
|
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) {
|
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)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("Update-Endpunkt ist ungültig")
|
return nil, errors.New("Update-Endpunkt ist ungültig")
|
||||||
}
|
}
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
|
if username != "" && credential != "" {
|
||||||
if basic {
|
if basic {
|
||||||
request.SetBasicAuth(username, credential)
|
request.SetBasicAuth(username, credential)
|
||||||
} else {
|
} else {
|
||||||
request.Header.Set("Authorization", "token "+credential)
|
request.Header.Set("Authorization", "token "+credential)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||||
response, err := m.httpClient.Do(request)
|
response, err := m.httpClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
|
return nil, &releaseFetchError{err: err}
|
||||||
}
|
}
|
||||||
return response, nil
|
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 {
|
func (m *Manager) safeReleaseURL(raw string) string {
|
||||||
apiURL, apiErr := url.Parse(m.apiURL)
|
apiURL, apiErr := url.Parse(m.apiURL)
|
||||||
releaseURL, releaseErr := url.Parse(raw)
|
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 {
|
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()
|
checkedAt := time.Now().UTC()
|
||||||
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -383,7 +418,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
|||||||
state.AgentStatus = m.state.AgentStatus
|
state.AgentStatus = m.state.AgentStatus
|
||||||
state.AgentVersion = m.state.AgentVersion
|
state.AgentVersion = m.state.AgentVersion
|
||||||
state.AgentProtocol = m.state.AgentProtocol
|
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.state = state
|
||||||
m.stateMu.Unlock()
|
m.stateMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -65,6 +67,7 @@ func (a *fakeUpdateAgent) counts() (status, start int) {
|
|||||||
|
|
||||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
const credential = " test-password-or-token-that-must-stay-secret "
|
const credential = " test-password-or-token-that-must-stay-secret "
|
||||||
var unavailable atomic.Bool
|
var unavailable atomic.Bool
|
||||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -78,7 +81,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := manager.State()
|
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)
|
t.Fatalf("new release was not detected: %#v", state)
|
||||||
}
|
}
|
||||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
@@ -127,7 +130,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||||
}
|
}
|
||||||
state = manager.State()
|
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)
|
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) {
|
func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
manager, database := updateReadyManager(t, agent)
|
manager, database := updateReadyManager(t, agent)
|
||||||
if manager.State().AgentAvailable {
|
if manager.State().AgentAvailable {
|
||||||
t.Fatal("test precondition failed: cached agent state is already available")
|
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)
|
t.Fatalf("live agent was rejected because of stale cached state: %v", err)
|
||||||
}
|
}
|
||||||
statusCalls, startCalls := agent.counts()
|
statusCalls, startCalls := agent.counts()
|
||||||
@@ -200,13 +204,14 @@ func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if settings.DesiredVersion != "v1.5.1" {
|
if settings.DesiredVersion != nextVersion {
|
||||||
t.Fatalf("target version was not persisted: %#v", settings)
|
t.Fatalf("target version was not persisted: %#v", settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
|
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
status updateprotocol.Status
|
status updateprotocol.Status
|
||||||
@@ -222,7 +227,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
|
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
|
||||||
manager, database := updateReadyManager(t, agent)
|
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")
|
t.Fatal("unsafe update request was accepted")
|
||||||
}
|
}
|
||||||
_, startCalls := agent.counts()
|
_, startCalls := agent.counts()
|
||||||
@@ -242,6 +247,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
|
|||||||
|
|
||||||
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
manager, _ := updateReadyManager(t, agent)
|
manager, _ := updateReadyManager(t, agent)
|
||||||
results := make(chan error, 2)
|
results := make(chan error, 2)
|
||||||
@@ -249,7 +255,7 @@ func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
|||||||
for index := 0; index < 2; index++ {
|
for index := 0; index < 2; index++ {
|
||||||
go func() {
|
go func() {
|
||||||
<-start
|
<-start
|
||||||
results <- manager.RequestUpdate(context.Background(), "v1.5.1")
|
results <- manager.RequestUpdate(context.Background(), nextVersion)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
close(start)
|
close(start)
|
||||||
@@ -337,6 +343,7 @@ func compatibleAgentStatus(phase string) updateprotocol.Status {
|
|||||||
|
|
||||||
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
|
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
if err != nil {
|
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 {
|
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
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")
|
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
|
// Version is replaced through -ldflags for release images. The source fallback
|
||||||
// keeps local builds and tests honest about the currently checked-out release.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
actionPollInterval time.Duration
|
||||||
|
actionTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type Zone struct {
|
type Zone struct {
|
||||||
@@ -44,6 +48,8 @@ type APIError struct {
|
|||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *APIError) HTTPStatusCode() int { return e.Status }
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
if e.Code != "" {
|
if e.Code != "" {
|
||||||
return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message)
|
return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message)
|
||||||
@@ -52,9 +58,15 @@ func (e *APIError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(baseURL string) *Client {
|
func New(baseURL string) *Client {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.DialContext = netresolve.New().DialContext
|
||||||
|
transport.IdleConnTimeout = 30 * time.Second
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
actionPollInterval: 500 * time.Millisecond,
|
||||||
|
actionTimeout: 20 * time.Second,
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
|
Transport: transport,
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
@@ -64,6 +76,8 @@ func New(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
var result []Zone
|
var result []Zone
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
var response struct {
|
var response struct {
|
||||||
@@ -87,12 +101,17 @@ func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
|||||||
if response.Meta.Pagination.NextPage == nil {
|
if response.Meta.Pagination.NextPage == nil {
|
||||||
break
|
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
|
page = *response.Meta.Pagination.NextPage - 1
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) {
|
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
|
var result []RRSet
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
var response struct {
|
var response struct {
|
||||||
@@ -111,6 +130,9 @@ func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet,
|
|||||||
if response.Meta.Pagination.NextPage == nil {
|
if response.Meta.Pagination.NextPage == nil {
|
||||||
break
|
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
|
page = *response.Meta.Pagination.NextPage - 1
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -123,25 +145,25 @@ func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordTyp
|
|||||||
TTL int `json:"ttl"`
|
TTL int `json:"ttl"`
|
||||||
Records []Record `json:"records"`
|
Records []Record `json:"records"`
|
||||||
}{Name: name, Type: recordType, TTL: ttl, Records: 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 {
|
func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
|
||||||
body := struct {
|
body := struct {
|
||||||
Records []Record `json:"records"`
|
Records []Record `json:"records"`
|
||||||
}{Records: 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 {
|
func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error {
|
||||||
body := struct {
|
body := struct {
|
||||||
TTL int `json:"ttl"`
|
TTL int `json:"ttl"`
|
||||||
}{TTL: 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 {
|
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 {
|
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"`
|
Comment string `json:"comment"`
|
||||||
}{Value: record.Value, Comment: record.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 {
|
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)
|
return fmt.Errorf("request Hetzner API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("read Hetzner API response: %w", err)
|
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 {
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return decodeAPIError(resp.StatusCode, content)
|
return decodeAPIError(resp.StatusCode, content)
|
||||||
}
|
}
|
||||||
if responseBody != nil && len(content) > 0 {
|
if responseBody != nil {
|
||||||
if err := json.Unmarshal(content, responseBody); err != nil {
|
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||||
return fmt.Errorf("decode Hetzner API response: %w", err)
|
return fmt.Errorf("decode Hetzner API response: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,107 @@ package hetzner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"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) {
|
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
type capturedRequest struct {
|
type capturedRequest struct {
|
||||||
@@ -33,7 +129,7 @@ func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -77,6 +173,7 @@ func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
|
|||||||
body["path"] = r.URL.Path
|
body["path"] = r.URL.Path
|
||||||
captured <- body
|
captured <- body
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -115,6 +212,7 @@ func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
|
|||||||
body["path"] = r.URL.Path
|
body["path"] = r.URL.Path
|
||||||
captured <- body
|
captured <- body
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -133,7 +231,8 @@ func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
|
|||||||
captured := make(chan *http.Request, 1)
|
captured := make(chan *http.Request, 1)
|
||||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
captured <- r
|
captured <- r
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 {
|
type Result struct {
|
||||||
@@ -22,10 +25,15 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Resolver 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 {
|
func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result {
|
||||||
return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true)
|
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 {
|
func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result {
|
||||||
result := 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)
|
result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true)
|
||||||
if ipv6Enabled {
|
if ipv6Enabled {
|
||||||
result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false)
|
ipv6 := <-ipv6Result
|
||||||
|
result.IPv6, result.IPv6Err = ipv6.IPv6, ipv6.IPv6Err
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -56,57 +74,59 @@ func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 b
|
|||||||
if len(endpoints) == 0 {
|
if len(endpoints) == 0 {
|
||||||
return "", errors.New("no endpoints configured")
|
return "", errors.New("no endpoints configured")
|
||||||
}
|
}
|
||||||
var failures []string
|
var failures []error
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
value, err := r.resolveOne(ctx, endpoint, ipv4)
|
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 {
|
if err == nil {
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
failures = append(failures, safeEndpointName(endpoint)+": "+err.Error())
|
failures = append(failures, fmt.Errorf("%s: %w", safeEndpointName(endpoint), err))
|
||||||
}
|
}
|
||||||
family := "IPv6"
|
family := "IPv6"
|
||||||
if ipv4 {
|
if ipv4 {
|
||||||
family = "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) {
|
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)
|
u, ips, err := r.parseAndResolve(ctx, raw, ipv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
port := u.Port()
|
client := r.clientFor(u, ips)
|
||||||
if port == "" {
|
defer client.CloseIdleConnections()
|
||||||
port = "443"
|
|
||||||
}
|
|
||||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
||||||
transport := &http.Transport{
|
|
||||||
Proxy: nil,
|
|
||||||
ForceAttemptHTTP2: true,
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
ResponseHeaderTimeout: 5 * time.Second,
|
|
||||||
IdleConnTimeout: 10 * time.Second,
|
|
||||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()},
|
|
||||||
DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) {
|
|
||||||
var lastErr error
|
|
||||||
for _, ip := range ips {
|
|
||||||
conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port))
|
|
||||||
if dialErr == nil {
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
lastErr = dialErr
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -118,7 +138,7 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
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))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +160,30 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin
|
|||||||
return address.String(), nil
|
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) {
|
func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) {
|
||||||
u, err := url.Parse(strings.TrimSpace(raw))
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil || u == nil {
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("resolve host: %w", err)
|
return nil, nil, fmt.Errorf("resolve host: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ package ipresolve
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPublicAddress(t *testing.T) {
|
func TestPublicAddress(t *testing.T) {
|
||||||
@@ -32,3 +40,107 @@ func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) {
|
|||||||
t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result)
|
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
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *APIError) HTTPStatusCode() int { return e.Status }
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
message := strings.TrimSpace(e.Message)
|
message := strings.TrimSpace(e.Message)
|
||||||
if 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) {
|
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
|
||||||
var hosts []string
|
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 {
|
var response struct {
|
||||||
Config struct {
|
Config struct {
|
||||||
DNS 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 {
|
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)
|
return c.addHost(ctx, sid, value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
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)
|
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
|
// Pi-hole session. Missing values are accepted so an interrupted deletion can
|
||||||
// safely be retried.
|
// safely be retried.
|
||||||
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
|
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 {
|
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 {
|
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)
|
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 {
|
if previous == replacement {
|
||||||
return nil
|
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 {
|
if err := c.deleteHost(ctx, sid, previous); err != nil {
|
||||||
return err
|
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)
|
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)
|
sid, err := c.authenticate(ctx, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -177,7 +184,7 @@ func (c *Client) withSession(ctx context.Context, password string, operation fun
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
|
_ = 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) {
|
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
|
||||||
|
|||||||
+109
-51
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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/hetzner"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||||
@@ -21,7 +22,10 @@ var (
|
|||||||
ErrDisabled = errors.New("the updater is disabled")
|
ErrDisabled = errors.New("the updater is disabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultRetryInitial = 5 * time.Second
|
const (
|
||||||
|
defaultRetryInitial = 5 * time.Second
|
||||||
|
defaultRetryMaximum = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
type ipResolver interface {
|
type ipResolver interface {
|
||||||
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
|
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
|
||||||
@@ -37,6 +41,8 @@ type Scheduler struct {
|
|||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
nextRun atomic.Int64
|
nextRun atomic.Int64
|
||||||
wake chan struct{}
|
wake chan struct{}
|
||||||
|
changes chan struct{}
|
||||||
|
done chan struct{}
|
||||||
retryMin time.Duration
|
retryMin time.Duration
|
||||||
retryMax time.Duration
|
retryMax time.Duration
|
||||||
}
|
}
|
||||||
@@ -49,14 +55,18 @@ func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Re
|
|||||||
cipher: cipher,
|
cipher: cipher,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
|
changes: make(chan struct{}, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
retryMin: defaultRetryInitial,
|
retryMin: defaultRetryInitial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) Start(ctx context.Context) {
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(s.done)
|
||||||
defer s.nextRun.Store(0)
|
defer s.nextRun.Store(0)
|
||||||
consecutiveFailures := 0
|
consecutiveFailures := 0
|
||||||
|
var failureSince, networkLoggedAt time.Time
|
||||||
for {
|
for {
|
||||||
settings, err := s.store.GetSettings(ctx)
|
settings, err := s.store.GetSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,10 +96,24 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
if settings.UpdaterEnabled {
|
if settings.UpdaterEnabled {
|
||||||
_, runErr := s.Run(ctx)
|
_, runErr := s.Run(ctx)
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
if consecutiveFailures > 0 {
|
||||||
|
diagnostics.Record(diagnostics.Event{Code: "update_recovered", Target: "updater", Attempt: consecutiveFailures, DurationMS: time.Since(failureSince).Milliseconds()})
|
||||||
|
}
|
||||||
consecutiveFailures = 0
|
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++
|
consecutiveFailures++
|
||||||
|
if consecutiveFailures == 1 {
|
||||||
|
failureSince = time.Now()
|
||||||
|
}
|
||||||
retryAfter = s.retryDelay(consecutiveFailures, wait)
|
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)
|
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -112,7 +136,6 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
timer.Stop()
|
timer.Stop()
|
||||||
return
|
return
|
||||||
case <-s.wake:
|
case <-s.wake:
|
||||||
consecutiveFailures = 0
|
|
||||||
if !timer.Stop() {
|
if !timer.Stop() {
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
@@ -130,8 +153,12 @@ func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.D
|
|||||||
if minimum <= 0 {
|
if minimum <= 0 {
|
||||||
minimum = defaultRetryInitial
|
minimum = defaultRetryInitial
|
||||||
}
|
}
|
||||||
if s.retryMax > 0 && s.retryMax < maximum {
|
retryMax := s.retryMax
|
||||||
maximum = s.retryMax
|
if retryMax <= 0 {
|
||||||
|
retryMax = defaultRetryMaximum
|
||||||
|
}
|
||||||
|
if retryMax < maximum {
|
||||||
|
maximum = retryMax
|
||||||
}
|
}
|
||||||
if maximum < minimum {
|
if maximum < minimum {
|
||||||
maximum = minimum
|
maximum = minimum
|
||||||
@@ -167,11 +194,40 @@ func (s *Scheduler) NextRunAt() time.Time {
|
|||||||
return time.Unix(value, 0).UTC()
|
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) {
|
func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||||
if !s.running.CompareAndSwap(false, true) {
|
if !s.running.CompareAndSwap(false, true) {
|
||||||
return "", ErrRunInProgress
|
return "", ErrRunInProgress
|
||||||
}
|
}
|
||||||
defer s.running.Store(false)
|
defer s.running.Store(false)
|
||||||
|
select {
|
||||||
|
case s.changes <- struct{}{}:
|
||||||
|
defer func() { <-s.changes }()
|
||||||
|
default:
|
||||||
|
return "", ErrRunInProgress
|
||||||
|
}
|
||||||
|
|
||||||
settings, err := s.store.GetSettings(ctx)
|
settings, err := s.store.GetSettings(ctx)
|
||||||
if err != nil {
|
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 {
|
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
|
||||||
failed += len(group)
|
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)
|
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
|
||||||
for _, target := range group {
|
for _, target := range group {
|
||||||
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
|
_ = 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) {
|
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)
|
// Persist cancellation too; using the canceled run context leaves history
|
||||||
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
|
// stuck at "running". Keep cleanup bounded during shutdown.
|
||||||
if status != "ok" {
|
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
_ = s.store.AddAudit(ctx, "update."+status, summary)
|
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
|
return summary, runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,46 +349,53 @@ func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.Managed
|
|||||||
}
|
}
|
||||||
for _, item := range targets {
|
for _, item := range targets {
|
||||||
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
|
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 {
|
if targetUpdated {
|
||||||
*updated = *updated + 1
|
*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, "")
|
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) {
|
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 states []string
|
||||||
|
var familyErrors []error
|
||||||
updated := false
|
updated := false
|
||||||
if target.UpdateIPv4 {
|
for _, family := range []struct {
|
||||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
|
enabled bool
|
||||||
if err != nil {
|
recordType, ip string
|
||||||
return false, "", err
|
}{
|
||||||
|
{target.UpdateIPv4, "A", ipv4},
|
||||||
|
{target.UpdateIPv6, "AAAA", ipv6},
|
||||||
|
} {
|
||||||
|
if !family.enabled {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
updated = updated || changed
|
if family.recordType == "AAAA" && !ipv6Enabled {
|
||||||
states = append(states, state)
|
|
||||||
}
|
|
||||||
if target.UpdateIPv6 {
|
|
||||||
if !ipv6Enabled {
|
|
||||||
states = append(states, "AAAA global deaktiviert")
|
states = append(states, "AAAA global deaktiviert")
|
||||||
} else {
|
continue
|
||||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
}
|
||||||
|
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, family.recordType)], family.recordType, family.ip)
|
||||||
updated = updated || changed
|
updated = updated || changed
|
||||||
|
if err != nil {
|
||||||
|
familyErrors = append(familyErrors, fmt.Errorf("%s: %w", family.recordType, err))
|
||||||
|
states = append(states, family.recordType+" Fehler")
|
||||||
|
} else {
|
||||||
states = append(states, state)
|
states = append(states, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(states) == 0 {
|
if len(states) == 0 {
|
||||||
return false, "Deaktivierte IP-Familien", nil
|
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) {
|
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.Value == ip {
|
||||||
if current.Comment != target.Description {
|
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 {
|
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"
|
status := recordType + " Kommentar synchronisiert"
|
||||||
if ttlChanged {
|
if ttlChanged {
|
||||||
@@ -374,7 +443,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
|
|||||||
return false, recordType + " unverändert", nil
|
return false, recordType + " unverändert", nil
|
||||||
}
|
}
|
||||||
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
|
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"
|
status := recordType + " aktualisiert"
|
||||||
if ttlChanged {
|
if ttlChanged {
|
||||||
@@ -551,28 +620,12 @@ func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (
|
|||||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updated := false
|
target.UpdateIPv4 = target.UpdateIPv4 && target.StaticIPv4 != ""
|
||||||
var states []string
|
target.UpdateIPv6 = target.UpdateIPv6 && target.StaticIPv6 != ""
|
||||||
if target.UpdateIPv4 && target.StaticIPv4 != "" {
|
if !target.UpdateIPv4 && !target.UpdateIPv6 {
|
||||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
updated = updated || changed
|
|
||||||
states = append(states, state)
|
|
||||||
}
|
|
||||||
if target.UpdateIPv6 && target.StaticIPv6 != "" {
|
|
||||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
|
|
||||||
if err != nil {
|
|
||||||
return false, "", err
|
|
||||||
}
|
|
||||||
updated = updated || changed
|
|
||||||
states = append(states, state)
|
|
||||||
}
|
|
||||||
if len(states) == 0 {
|
|
||||||
return false, "Keine feste IP eingetragen", nil
|
return 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
|
// 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 {
|
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 {
|
for _, endpoint := range endpoints {
|
||||||
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
|
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
|
||||||
return fmt.Errorf("%s: %w", endpoint, err)
|
return fmt.Errorf("%s: %w", endpoint, err)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +23,187 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"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 {
|
type retryResolver struct {
|
||||||
calls atomic.Int32
|
calls atomic.Int32
|
||||||
recovered chan struct{}
|
recovered chan struct{}
|
||||||
@@ -238,7 +420,7 @@ func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
func TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
schedule := &Scheduler{retryMin: 5 * time.Second}
|
schedule := &Scheduler{retryMin: 5 * time.Second}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -253,7 +435,7 @@ func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
|||||||
{failures: 20, want: time.Minute},
|
{failures: 20, want: time.Minute},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
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)
|
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
|
changes <- body.TTL
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
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":
|
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
|
||||||
actions <- r.URL.Path
|
actions <- r.URL.Path
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
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":
|
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
|
||||||
actions <- r.URL.Path
|
actions <- r.URL.Path
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
@@ -492,7 +677,8 @@ func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
deletions <- r.URL.Path
|
deletions <- r.URL.Path
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
@@ -573,7 +759,8 @@ func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
deletions <- r.URL.Path
|
deletions <- r.URL.Path
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||||
default:
|
default:
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
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
|
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) {
|
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)
|
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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,49 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
"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) {
|
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func NewController(runner Runner, logger *slog.Logger) *Controller {
|
|||||||
func agentStatus(phase string) updateprotocol.Status {
|
func agentStatus(phase string) updateprotocol.Status {
|
||||||
return updateprotocol.Status{
|
return updateprotocol.Status{
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
|
AnonymousPull: true,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
AgentVersion: buildinfo.Version,
|
AgentVersion: buildinfo.Version,
|
||||||
Protocol: buildinfo.UpdateProtocolVersion,
|
Protocol: buildinfo.UpdateProtocolVersion,
|
||||||
@@ -69,7 +70,9 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
request.Version = strings.TrimSpace(request.Version)
|
request.Version = strings.TrimSpace(request.Version)
|
||||||
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
||||||
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
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)
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ func (c *Controller) begin(version string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,3 +103,30 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
|||||||
t.Fatalf("update did not complete: %#v", controller.Status())
|
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
|
targetReference := r.imageRepository + ":" + request.Version
|
||||||
progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft")
|
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 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}
|
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
|
||||||
}
|
}
|
||||||
targetImage, err := r.docker.inspectImage(ctx, targetReference)
|
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")
|
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 {
|
if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
oldStopped = true
|
|
||||||
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
|
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -315,9 +320,23 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS
|
|||||||
return running[0], nil
|
return running[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errRegistryAuthentication = errors.New("registry authentication required")
|
||||||
|
|
||||||
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error {
|
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error {
|
||||||
|
// 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]})
|
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)}}
|
headers = http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
|
||||||
|
}
|
||||||
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||||
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -325,21 +344,49 @@ func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username,
|
|||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
if response.StatusCode != http.StatusOK {
|
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 := bufio.NewScanner(response.Body)
|
||||||
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
var event struct {
|
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 != "" {
|
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||||
return errors.New(event.Error)
|
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()
|
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) {
|
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)
|
response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true)
|
||||||
if err != nil {
|
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 {
|
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)
|
ticker := time.NewTicker(time.Second)
|
||||||
defer deadline.Stop()
|
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
consecutiveHealthy := 0
|
consecutiveHealthy := 0
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
@@ -815,8 +862,6 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, exp
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-deadline.C:
|
|
||||||
return errors.New("replacement container did not become healthy in time")
|
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
inspected, err := c.inspectContainer(ctx, id)
|
inspected, err := c.inspectContainer(ctx, id)
|
||||||
if err != nil {
|
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) {
|
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)
|
ticker := time.NewTicker(pollInterval)
|
||||||
defer deadline.Stop()
|
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return containerInspect{}, ctx.Err()
|
return containerInspect{}, ctx.Err()
|
||||||
case <-deadline.C:
|
|
||||||
return containerInspect{}, errors.New("current container did not become healthy in time")
|
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
inspected, err := c.inspectContainer(ctx, id)
|
inspected, err := c.inspectContainer(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package updateagent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,8 +15,85 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
"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)
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f roundTripFunc) RoundTrip(request *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"}
|
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 {
|
type Status struct {
|
||||||
|
// Optional capability; older v2 agents require registry credentials.
|
||||||
|
AnonymousPull bool `json:"anonymous_pull,omitempty"`
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
TargetVersion string `json:"target_version,omitempty"`
|
TargetVersion string `json:"target_version,omitempty"`
|
||||||
Message string `json:"message,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"
|
"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/model"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"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
|
var view *piHoleView
|
||||||
if len(integrations) > 0 {
|
if len(integrations) > 0 {
|
||||||
integration := integrations[0]
|
integration := integrations[0]
|
||||||
current := piHoleView{Integration: integration}
|
current := piHoleView{Integration: integration, FetchError: integration.LastError}
|
||||||
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
|
// Navigation and removing an obsolete connection must work offline.
|
||||||
var links []model.PiHoleRecordLink
|
links, err := s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||||
if accessErr == nil {
|
if err != nil {
|
||||||
var hosts []string
|
s.serverError(w, err)
|
||||||
hosts, accessErr = client.ListHosts(r.Context(), password)
|
return
|
||||||
if accessErr == nil {
|
|
||||||
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if accessErr != nil {
|
|
||||||
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
|
||||||
}
|
}
|
||||||
for _, link := range links {
|
for _, link := range links {
|
||||||
record := parsePiHoleRecord(link.Value)
|
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)
|
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.Records)
|
||||||
sortPiHoleRecords(current.ArchivedRecords)
|
sortPiHoleRecords(current.ArchivedRecords)
|
||||||
view = ¤t
|
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")
|
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
|
||||||
return
|
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")
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,6 +127,10 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
|
|||||||
s.redirectNotice(w, r, "/pihole", message, "error")
|
s.redirectNotice(w, r, "/pihole", message, "error")
|
||||||
return
|
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.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||||
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
|
_ = 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.")
|
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")
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
return
|
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")
|
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
|
||||||
return
|
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")
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||||
return
|
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.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
|
_ = 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.")
|
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 {
|
if !ok {
|
||||||
return
|
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 {
|
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 {
|
if err != nil {
|
||||||
|
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_failed", Target: "pihole", Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
|
||||||
message := safeMessage(err)
|
message := safeMessage(err)
|
||||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_ok", Target: "pihole"})
|
||||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-20
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -144,22 +145,22 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /", s.authenticated(s.dashboard))
|
mux.Handle("GET /", s.authenticated(s.dashboard))
|
||||||
mux.Handle("GET /domains", s.authenticated(s.domains))
|
mux.Handle("GET /domains", s.authenticated(s.domains))
|
||||||
mux.Handle("GET /domains/new", s.adminOnly(s.domainNew))
|
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("GET /domains/{id}", s.authenticated(s.domainDetail))
|
||||||
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.domainImport))
|
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.dnsChange(s.domainImport)))
|
||||||
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.domainTTLSave))
|
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.dnsChange(s.domainTTLSave)))
|
||||||
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.domainRemove))
|
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.dnsChange(s.domainRemove)))
|
||||||
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.domainDelete))
|
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.dnsChange(s.domainDelete)))
|
||||||
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.domainToken))
|
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.dnsChange(s.domainToken)))
|
||||||
mux.Handle("POST /targets", s.authenticated(s.targetCreate))
|
mux.Handle("POST /targets", s.authenticated(s.dnsChange(s.targetCreate)))
|
||||||
mux.Handle("POST /targets/{id}/save", s.authenticated(s.targetSave))
|
mux.Handle("POST /targets/{id}/save", s.authenticated(s.dnsChange(s.targetSave)))
|
||||||
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.targetDelete))
|
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.dnsChange(s.targetDelete)))
|
||||||
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.targetArchive))
|
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.dnsChange(s.targetArchive)))
|
||||||
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.targetRestore))
|
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.dnsChange(s.targetRestore)))
|
||||||
mux.Handle("POST /cnames", s.adminOnly(s.cnameCreate))
|
mux.Handle("POST /cnames", s.adminOnly(s.dnsChange(s.cnameCreate)))
|
||||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.dnsChange(s.cnameSave)))
|
||||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
|
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.dnsChange(s.cnameRemove)))
|
||||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.dnsChange(s.cnameDelete)))
|
||||||
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
|
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
|
||||||
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
|
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
|
||||||
mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave))
|
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/archive", s.adminOnly(s.piHoleRecordArchive))
|
||||||
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
|
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
|
||||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
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("POST /run", s.adminOnly(s.runNow))
|
||||||
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
||||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||||
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
|
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("GET /errors", s.adminOnly(s.errorsPage))
|
||||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
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) {
|
if !target.Active && hasStaticIP(target) {
|
||||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||||
if syncErr != nil {
|
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")
|
s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -932,7 +934,7 @@ func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *mod
|
|||||||
if !target.Active && hasStaticIP(target) {
|
if !target.Active && hasStaticIP(target) {
|
||||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||||
if syncErr != nil {
|
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")
|
s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||||
return
|
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")
|
s.redirectNotice(w, r, "/settings", "Für aktiviertes IPv6 ist mindestens ein HTTPS-Endpoint erforderlich.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !slices.Equal(ipv4, current.IPv4Endpoints) {
|
||||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
||||||
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ipv6Enabled {
|
}
|
||||||
|
if ipv6Enabled && !slices.Equal(ipv6, current.IPv6Endpoints) {
|
||||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil {
|
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil {
|
||||||
s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error")
|
s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error")
|
||||||
return
|
return
|
||||||
@@ -1364,7 +1368,7 @@ func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, ses
|
|||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
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.")
|
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 {
|
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := s.session(r.Context(), r)
|
session, err := s.session(r.Context(), r)
|
||||||
|
|||||||
+110
-3
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -27,6 +28,76 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"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) {
|
func TestInputNormalizers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
username, err := normalizeUsername("Alice_Admin")
|
username, err := normalizeUsername("Alice_Admin")
|
||||||
@@ -293,7 +364,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
`data-dashboard-ipv6`,
|
`data-dashboard-ipv6`,
|
||||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||||
`Aktualisierung läuft`,
|
`Aktualisierung läuft`,
|
||||||
`features-29`,
|
`features-30`,
|
||||||
`data-local-time="1788001920"`,
|
`data-local-time="1788001920"`,
|
||||||
`dashboard-zone-item`,
|
`dashboard-zone-item`,
|
||||||
`DNS-Zone · example.de`,
|
`DNS-Zone · example.de`,
|
||||||
@@ -414,6 +485,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
|||||||
{method: http.MethodGet, path: "/users"},
|
{method: http.MethodGet, path: "/users"},
|
||||||
{method: http.MethodGet, path: "/users/new"},
|
{method: http.MethodGet, path: "/users/new"},
|
||||||
{method: http.MethodGet, path: "/system"},
|
{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: "/system/restart", body: "csrf=member-csrf"},
|
||||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", 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 {
|
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !strings.Contains(page.String(), expected) {
|
||||||
t.Errorf("software update page is missing %q", 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 {
|
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !strings.Contains(freshPage.String(), expected) {
|
||||||
t.Errorf("fresh software update page is missing %q", 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) {
|
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
var phase atomic.Int32
|
||||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
switch {
|
||||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
|
_, _ = 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":
|
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.Header().Set("Content-Type", "application/json")
|
||||||
|
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"]}}}`))
|
_, _ = 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":
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
default:
|
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" {
|
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)
|
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)
|
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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") {
|
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)
|
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) {
|
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading">
|
<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><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">
|
<form action="/errors/clear" method="post">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||||
</form>
|
</form></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if eq .ErrorCount 0}}
|
{{if eq .ErrorCount 0}}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{.Title}} · DNS Control</title>
|
<title>{{.Title}} · DNS Control</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=features-29">
|
<link rel="stylesheet" href="/static/styles.css?v=features-30">
|
||||||
<script src="/static/app.js?v=features-29" defer></script>
|
<script src="/static/app.js?v=features-30" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="page-{{.Page}}">
|
<body class="page-{{.Page}}">
|
||||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{{$integration := $view.Integration}}
|
{{$integration := $view.Integration}}
|
||||||
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||||
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
<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>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||||
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||||
{{end}}
|
{{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">
|
<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>
|
<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}}
|
{{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>
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
<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>
|
<div class="panel-divider"></div>
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
<div class="software-version-copy">
|
<div class="software-version-copy">
|
||||||
<div class="software-version-values">
|
<div class="software-version-values">
|
||||||
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
<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>
|
</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>
|
<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.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>
|
||||||
<div class="software-version-action">
|
<div class="software-version-action">
|
||||||
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
{{else if .SoftwareUpdate.UpdateAvailable}}
|
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||||
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -43,16 +43,17 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
<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}}
|
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||||
<div class="update-access-state">
|
<div class="update-access-state">
|
||||||
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
<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>
|
<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> Verbunden</span>
|
<span class="status-pill status-good"><i aria-hidden="true"></i> Gespeichert</span>
|
||||||
</div>
|
</div>
|
||||||
<details class="update-credential-editor">
|
<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>
|
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
|||||||
Reference in new issue
Block a user