Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef669f3d9 | ||
|
|
81def77e3c | ||
|
|
3383b91e49 | ||
|
|
7bb33e528c |
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.4.3
|
ARG VERSION=v1.5.2
|
||||||
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.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
@@ -61,12 +61,14 @@ 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.
|
||||||
|
|
||||||
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
|
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
|
||||||
|
|
||||||
|
Auf derselben Seite kann ein Administrator den vollständigen Anwendungscontainer geordnet neu starten. Dabei bleiben Daten-, Secret- und Steuer-Volumes unverändert. Der Neustart beendet den Prozess im Container; die in der unterstützten Compose-Konfiguration gesetzte Richtlinie `restart: unless-stopped` startet ihn anschließend automatisch wieder. Die Oberfläche erkennt die neue Prozessinstanz und verbindet sich selbstständig neu.
|
||||||
|
|
||||||
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
|
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -88,11 +90,77 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
|
|||||||
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||||
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
||||||
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
||||||
|
- administrativer Container-Neustart mit automatischem Wiederverbinden der Weboberfläche
|
||||||
- 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
|
||||||
|
|
||||||
|
Die Übersicht zeigt unter **Letzte Update-Läufe** die letzten sechs Läufe mit
|
||||||
|
Änderungen, Fehlern oder Teilerfolgen. Erfolgreiche Prüfungen ohne Änderungen
|
||||||
|
werden ausgeblendet; die vollständige Laufhistorie bleibt für die Diagnose erhalten.
|
||||||
|
|
||||||
|
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,25 +1,54 @@
|
|||||||
# Domain Control aktualisieren
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
Die aktuelle Compose-Version ist `v1.4.3`. Datenbank, Einstellungen und
|
Die aktuelle Compose-Version ist `v1.5.2`. 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
|
||||||
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
||||||
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
|
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
|
||||||
|
|
||||||
|
Nach einem Neustart des Docker-Hosts prüft die Web-App den Update-Agenten mit
|
||||||
|
kurzem Abstand erneut. Der Update-Klick führt zusätzlich immer eine aktuelle
|
||||||
|
Prüfung durch und wartet bei Bedarf darauf, dass der Container seinen
|
||||||
|
Start-Healthcheck beendet. Ein separater manueller Neustart der Anwendung ist
|
||||||
|
dafür nicht mehr erforderlich.
|
||||||
|
|
||||||
|
Über **Container neu starten** auf der Update-Seite lässt sich der vollständige
|
||||||
|
Anwendungscontainer außerdem gezielt neu starten. Datenbank, Secrets und
|
||||||
|
Einstellungen bleiben erhalten. Diese Funktion setzt die im mitgelieferten
|
||||||
|
Compose konfigurierte Richtlinie `restart: unless-stopped` voraus.
|
||||||
|
|
||||||
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
|
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
|
||||||
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
||||||
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
||||||
@@ -34,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.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
update-agent:
|
update-agent:
|
||||||
image: *image
|
image: *image
|
||||||
```
|
```
|
||||||
@@ -68,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.4.3` setzen.
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.2` 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.
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -3,17 +3,20 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -66,12 +69,17 @@ func commandForInvocation(arguments []string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
func run(logger *slog.Logger) error {
|
||||||
|
startedAt := time.Now()
|
||||||
// Credentials and session data must never inherit a permissive process umask.
|
// Credentials and session data must never inherit a permissive process umask.
|
||||||
syscall.Umask(0o077)
|
syscall.Umask(0o077)
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
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
|
||||||
@@ -82,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)
|
||||||
}
|
}
|
||||||
@@ -96,14 +107,31 @@ 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 {
|
||||||
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger)
|
restartRequested := make(chan struct{})
|
||||||
|
var restartOnce sync.Once
|
||||||
|
requestRestart := func() {
|
||||||
|
restartOnce.Do(func() {
|
||||||
|
// Docker applies restart policies only after a container has stayed up
|
||||||
|
// long enough to be considered successfully started. Keep very early
|
||||||
|
// UI requests alive until that safety window has passed.
|
||||||
|
delay := time.Until(startedAt.Add(11 * time.Second))
|
||||||
|
if delay > 0 {
|
||||||
|
time.AfterFunc(delay, func() { close(restartRequested) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(restartRequested)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger, requestRestart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -111,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{
|
||||||
@@ -133,6 +169,12 @@ func run(logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case <-restartRequested:
|
||||||
|
logger.Info("container restart requested")
|
||||||
|
stop()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return httpServer.Shutdown(shutdownCtx)
|
||||||
case <-appCtx.Done():
|
case <-appCtx.Done():
|
||||||
logger.Info("shutdown requested")
|
logger.Info("shutdown requested")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.2
|
||||||
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,24 @@ 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 (
|
||||||
|
agentUnavailableRetryInterval = 2 * time.Second
|
||||||
|
agentAvailableCheckInterval = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateAgent interface {
|
||||||
|
Start(context.Context, updateprotocol.Request) error
|
||||||
|
Status(context.Context) (updateprotocol.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
CurrentVersion string
|
CurrentVersion string
|
||||||
@@ -46,9 +57,10 @@ type Manager struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
apiURL string
|
apiURL string
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
agent *AgentClient
|
agent updateAgent
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
checkMu sync.Mutex
|
checkMu sync.Mutex
|
||||||
|
requestMu sync.Mutex
|
||||||
stateMu sync.RWMutex
|
stateMu sync.RWMutex
|
||||||
state State
|
state State
|
||||||
checkSignal chan struct{}
|
checkSignal chan struct{}
|
||||||
@@ -87,8 +99,8 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Start(ctx context.Context) {
|
func (m *Manager) Start(ctx context.Context) {
|
||||||
|
go m.monitorAgent(ctx, agentUnavailableRetryInterval, agentAvailableCheckInterval)
|
||||||
go func() {
|
go func() {
|
||||||
m.refreshAgentStatus(ctx)
|
|
||||||
initial := time.NewTimer(15 * time.Second)
|
initial := time.NewTimer(15 * time.Second)
|
||||||
defer initial.Stop()
|
defer initial.Stop()
|
||||||
var ticker *time.Ticker
|
var ticker *time.Ticker
|
||||||
@@ -121,7 +133,28 @@ func (m *Manager) backgroundCheck(ctx context.Context) {
|
|||||||
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
||||||
m.logger.Warn("software update check failed", "error", err)
|
m.logger.Warn("software update check failed", "error", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) monitorAgent(ctx context.Context, unavailableInterval, availableInterval time.Duration) {
|
||||||
|
for {
|
||||||
m.refreshAgentStatus(ctx)
|
m.refreshAgentStatus(ctx)
|
||||||
|
delay := unavailableInterval
|
||||||
|
if m.State().AgentAvailable {
|
||||||
|
delay = availableInterval
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TriggerCheck() {
|
func (m *Manager) TriggerCheck() {
|
||||||
@@ -166,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
|
||||||
@@ -190,6 +224,9 @@ func (m *Manager) CheckNow(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||||
|
m.requestMu.Lock()
|
||||||
|
defer m.requestMu.Unlock()
|
||||||
|
|
||||||
version = strings.TrimSpace(version)
|
version = strings.TrimSpace(version)
|
||||||
state := m.State()
|
state := m.State()
|
||||||
if !validReleaseVersion(version) || version != state.LatestVersion {
|
if !validReleaseVersion(version) || version != state.LatestVersion {
|
||||||
@@ -198,24 +235,36 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
|||||||
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
||||||
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
||||||
}
|
}
|
||||||
if !state.AgentAvailable {
|
status, err := m.UpdateStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
return errors.New("Update-Agent ist nicht erreichbar")
|
return errors.New("Update-Agent ist nicht erreichbar")
|
||||||
}
|
}
|
||||||
if !state.AgentCompatible {
|
if !agentCompatible(status) {
|
||||||
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
||||||
}
|
}
|
||||||
|
if status.Busy() {
|
||||||
|
return errors.New("ein Software-Update läuft bereits")
|
||||||
|
}
|
||||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
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)
|
||||||
@@ -239,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
|
||||||
@@ -260,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 {
|
||||||
@@ -268,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 {
|
||||||
@@ -285,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)
|
||||||
@@ -314,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
|
||||||
@@ -344,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package appupdate
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"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/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,8 +23,51 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeUpdateAgent struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
status updateprotocol.Status
|
||||||
|
statusErr error
|
||||||
|
startErr error
|
||||||
|
statusCalls int
|
||||||
|
startCalls int
|
||||||
|
requests []updateprotocol.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) Status(context.Context) (updateprotocol.Status, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.statusCalls++
|
||||||
|
return a.status, a.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) Start(_ context.Context, request updateprotocol.Request) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.startCalls++
|
||||||
|
a.requests = append(a.requests, request)
|
||||||
|
if a.startErr == nil {
|
||||||
|
a.status.Phase = updateprotocol.PhaseQueued
|
||||||
|
a.status.TargetVersion = request.Version
|
||||||
|
}
|
||||||
|
return a.startErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) setStatus(status updateprotocol.Status, err error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.status = status
|
||||||
|
a.statusErr = err
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) counts() (status, start int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.statusCalls, a.startCalls
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -34,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.4.4","html_url":"https://example.invalid/releases/v1.4.4","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()
|
||||||
|
|
||||||
@@ -67,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.4.4" || 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)
|
||||||
@@ -83,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.4.4" || 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +185,119 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
|
manager, database := updateReadyManager(t, agent)
|
||||||
|
if manager.State().AgentAvailable {
|
||||||
|
t.Fatal("test precondition failed: cached agent state is already available")
|
||||||
|
}
|
||||||
|
if err := manager.RequestUpdate(context.Background(), nextVersion); err != nil {
|
||||||
|
t.Fatalf("live agent was rejected because of stale cached state: %v", err)
|
||||||
|
}
|
||||||
|
statusCalls, startCalls := agent.counts()
|
||||||
|
if statusCalls != 1 || startCalls != 1 {
|
||||||
|
t.Fatalf("agent was not checked immediately before start: status=%d start=%d", statusCalls, startCalls)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.DesiredVersion != nextVersion {
|
||||||
|
t.Fatalf("target version was not persisted: %#v", settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status updateprotocol.Status
|
||||||
|
statusErr error
|
||||||
|
}{
|
||||||
|
{name: "unavailable", statusErr: errors.New("offline")},
|
||||||
|
{name: "incompatible", status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, AgentVersion: "v1.3.0", Protocol: 1}},
|
||||||
|
{name: "busy", status: compatibleAgentStatus(updateprotocol.PhasePulling)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
|
||||||
|
manager, database := updateReadyManager(t, agent)
|
||||||
|
if err := manager.RequestUpdate(context.Background(), nextVersion); err == nil {
|
||||||
|
t.Fatal("unsafe update request was accepted")
|
||||||
|
}
|
||||||
|
_, startCalls := agent.counts()
|
||||||
|
if startCalls != 0 {
|
||||||
|
t.Fatalf("agent start was called %d times", startCalls)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.DesiredVersion != "" {
|
||||||
|
t.Fatalf("rejected update changed desired version: %#v", settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
|
manager, _ := updateReadyManager(t, agent)
|
||||||
|
results := make(chan error, 2)
|
||||||
|
start := make(chan struct{})
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
results <- manager.RequestUpdate(context.Background(), nextVersion)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
var successes int
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
if err := <-results; err == nil {
|
||||||
|
successes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, startCalls := agent.counts()
|
||||||
|
if successes != 1 || startCalls != 1 {
|
||||||
|
t.Fatalf("parallel clicks started an unexpected number of updates: successes=%d starts=%d", successes, startCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentMonitorRecoversAfterDelayedAgentStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{statusErr: errors.New("not listening yet")}
|
||||||
|
manager := &Manager{agent: agent}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
manager.monitorAgent(ctx, 2*time.Millisecond, 20*time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
waitForCondition(t, time.Second, func() bool {
|
||||||
|
statusCalls, _ := agent.counts()
|
||||||
|
return statusCalls > 0
|
||||||
|
})
|
||||||
|
agent.setStatus(compatibleAgentStatus(updateprotocol.PhaseIdle), nil)
|
||||||
|
waitForCondition(t, time.Second, func() bool {
|
||||||
|
state := manager.State()
|
||||||
|
return state.AgentAvailable && state.AgentCompatible
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("agent monitor did not stop after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -176,3 +336,166 @@ func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
|||||||
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compatibleAgentStatus(phase string) updateprotocol.Status {
|
||||||
|
return updateprotocol.Status{Phase: phase, AgentVersion: buildinfo.MinimumAgentVersion, Protocol: buildinfo.MinimumAgentProtocol}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
nextVersion := nextReleaseVersion(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cipher, err := secure.LoadCipher(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := New(database, cipher, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager.agent = agent
|
||||||
|
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.RecordSoftwareUpdateSuccess(ctx, nextVersion, "https://git.example/releases/"+nextVersion, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager.setStoredState(settings)
|
||||||
|
return manager, database
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if condition() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
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.4.3"
|
var Version = "v1.5.2"
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-1
@@ -1435,8 +1435,69 @@ 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)
|
return s.recentRuns(ctx, limit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentSignificantRuns keeps changes and failures visible even after many
|
||||||
|
// successful checks without changes. The existing history stores counts only
|
||||||
|
// in its summary, so exclude the scheduler's two known successful no-op results.
|
||||||
|
// Unknown summaries and all failures are retained rather than silently hidden.
|
||||||
|
func (s *Store) RecentSignificantRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
||||||
|
return s.recentRuns(ctx, limit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) recentRuns(ctx context.Context, limit int, significantOnly bool) ([]model.UpdateRun, error) {
|
||||||
|
query := `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs`
|
||||||
|
if significantOnly {
|
||||||
|
query += ` WHERE status IN ('error', 'partial') OR (status = 'ok' AND summary NOT IN ('0 Ziel(e) aktualisiert, 0 Fehler', 'Keine aktiven DynDNS-Ziele'))`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY id DESC LIMIT ?`
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-11
@@ -64,7 +64,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
|||||||
if !updateprotocol.ValidVersion(request.Version) {
|
if !updateprotocol.ValidVersion(request.Version) {
|
||||||
return errors.New("invalid target version")
|
return errors.New("invalid target version")
|
||||||
}
|
}
|
||||||
if err := r.docker.negotiate(ctx); err != nil {
|
if err := waitForDockerAPI(ctx, 30*time.Second, time.Second, r.docker.negotiate); err != nil {
|
||||||
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
||||||
}
|
}
|
||||||
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
||||||
@@ -75,7 +75,18 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
if inspected.State.Health == nil {
|
||||||
|
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||||
|
}
|
||||||
|
switch inspected.State.Health.Status {
|
||||||
|
case "healthy":
|
||||||
|
case "starting":
|
||||||
|
progress(updateprotocol.PhaseQueued, "Laufender Container beendet noch seinen Start-Healthcheck")
|
||||||
|
inspected, err = r.docker.waitCurrentHealthy(ctx, target.ID, inspected.Image, 100*time.Second, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Die laufende Instanz wurde nach dem Host-Start nicht rechtzeitig healthy; das Update wurde nicht gestartet", err: err}
|
||||||
|
}
|
||||||
|
default:
|
||||||
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||||
}
|
}
|
||||||
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
||||||
@@ -93,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)
|
||||||
@@ -126,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
|
||||||
}
|
}
|
||||||
@@ -304,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 {
|
||||||
@@ -314,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 {
|
||||||
@@ -794,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()
|
||||||
@@ -804,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 {
|
||||||
@@ -847,6 +903,70 @@ 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) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return containerInspect{}, ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
inspected, err := c.inspectContainer(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return containerInspect{}, err
|
||||||
|
}
|
||||||
|
if !inspected.State.Running {
|
||||||
|
return containerInspect{}, fmt.Errorf("current container stopped with exit code %d", inspected.State.ExitCode)
|
||||||
|
}
|
||||||
|
if expectedImageID != "" && inspected.Image != expectedImageID {
|
||||||
|
return containerInspect{}, fmt.Errorf("current container image changed from %s to %s", expectedImageID, inspected.Image)
|
||||||
|
}
|
||||||
|
if inspected.State.Health == nil {
|
||||||
|
return containerInspect{}, errors.New("current container has no healthcheck")
|
||||||
|
}
|
||||||
|
switch inspected.State.Health.Status {
|
||||||
|
case "healthy":
|
||||||
|
return inspected, nil
|
||||||
|
case "starting":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return containerInspect{}, fmt.Errorf("current container health is %q", inspected.State.Health.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDockerAPI(ctx context.Context, timeout, interval time.Duration, negotiate func(context.Context) error) error {
|
||||||
|
retryCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
attemptCtx, attemptCancel := context.WithTimeout(retryCtx, 3*time.Second)
|
||||||
|
lastErr = negotiate(attemptCtx)
|
||||||
|
attemptCancel()
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-retryCtx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
|
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
|
||||||
if versioned {
|
if versioned {
|
||||||
path = c.apiPrefix + path
|
path = c.apiPrefix + path
|
||||||
|
|||||||
@@ -1,13 +1,105 @@
|
|||||||
package updateagent
|
package updateagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"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)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||||
|
return f(request)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
current := compatibleImage("v1.4.0", "v1.4.0")
|
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||||
@@ -17,6 +109,40 @@ func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWaitForDockerAPIRetriesBootTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var calls atomic.Int32
|
||||||
|
err := waitForDockerAPI(context.Background(), 100*time.Millisecond, time.Millisecond, func(context.Context) error {
|
||||||
|
if calls.Add(1) < 3 {
|
||||||
|
return errors.New("Docker is still starting")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || calls.Load() != 3 {
|
||||||
|
t.Fatalf("Docker boot transient was not retried: calls=%d err=%v", calls.Load(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitCurrentHealthyAllowsStartingButRejectsUnhealthy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var calls atomic.Int32
|
||||||
|
client := dockerTestClient(func() string {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
return "starting"
|
||||||
|
}
|
||||||
|
return "healthy"
|
||||||
|
})
|
||||||
|
inspected, err := client.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond)
|
||||||
|
if err != nil || inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
||||||
|
t.Fatalf("starting container did not become eligible: inspect=%#v err=%v", inspected, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unhealthy := dockerTestClient(func() string { return "unhealthy" })
|
||||||
|
if _, err := unhealthy.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond); err == nil || !strings.Contains(err.Error(), "unhealthy") {
|
||||||
|
t.Fatalf("unhealthy container was not rejected immediately: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
||||||
@@ -203,3 +329,65 @@ func contains(values []string, expected string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dockerTestClient(health func() string) *dockerClient {
|
||||||
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
body := `{"Id":"container-id","Image":"sha256:current","Config":{"Labels":{}},"State":{"Running":true,"ExitCode":0,"Health":{"Status":"` + health() + `"}}}`
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
})
|
||||||
|
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.")
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-25
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -43,6 +44,10 @@ type Server struct {
|
|||||||
limiter *loginLimiter
|
limiter *loginLimiter
|
||||||
dummyHash string
|
dummyHash string
|
||||||
authSlots chan struct{}
|
authSlots chan struct{}
|
||||||
|
instanceID string
|
||||||
|
restartContainer func()
|
||||||
|
restartMu sync.Mutex
|
||||||
|
restartPending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewData struct {
|
type viewData struct {
|
||||||
@@ -79,6 +84,7 @@ type viewData struct {
|
|||||||
ErrorCount int
|
ErrorCount int
|
||||||
PiHoleView *piHoleView
|
PiHoleView *piHoleView
|
||||||
SoftwareUpdate appupdate.State
|
SoftwareUpdate appupdate.State
|
||||||
|
InstanceID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type flashMessage struct {
|
type flashMessage struct {
|
||||||
@@ -113,12 +119,16 @@ type schedulerResponse struct {
|
|||||||
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger, restartContainer func()) (*Server, error) {
|
||||||
templates, err := loadTemplates()
|
templates, err := loadTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
instanceID, err := secure.RandomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create process instance ID: %w", err)
|
||||||
|
}
|
||||||
|
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2), instanceID: instanceID, restartContainer: restartContainer}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
@@ -135,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))
|
||||||
@@ -164,12 +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("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))
|
||||||
@@ -192,6 +204,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if s.instanceID != "" {
|
||||||
|
w.Header().Set("X-Domain-Control-Instance", s.instanceID)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
}
|
}
|
||||||
@@ -377,7 +393,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, session *mode
|
|||||||
var events []model.AuditEvent
|
var events []model.AuditEvent
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
if session.Role == "admin" {
|
if session.Role == "admin" {
|
||||||
runs, _ = s.store.RecentRuns(r.Context(), 6)
|
runs, _ = s.store.RecentSignificantRuns(r.Context(), 6)
|
||||||
events, _ = s.store.RecentAudit(r.Context(), 6)
|
events, _ = s.store.RecentAudit(r.Context(), 6)
|
||||||
errorCount, _ = s.store.CountErrors(r.Context())
|
errorCount, _ = s.store.CountErrors(r.Context())
|
||||||
}
|
}
|
||||||
@@ -804,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
|
||||||
}
|
}
|
||||||
@@ -918,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
|
||||||
}
|
}
|
||||||
@@ -1247,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
|
||||||
@@ -1304,7 +1322,7 @@ func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, sess
|
|||||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||||
cancel()
|
cancel()
|
||||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State(), InstanceID: s.instanceID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1350,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")
|
||||||
@@ -1418,6 +1436,63 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) containerRestart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.restartContainer == nil {
|
||||||
|
http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.updates != nil {
|
||||||
|
statusCtx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
|
updateStatus, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||||
|
cancel()
|
||||||
|
if statusErr == nil && updateStatus.Busy() {
|
||||||
|
http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.restartMu.Lock()
|
||||||
|
if s.restartPending {
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
s.writeRestartAccepted(w, r, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil {
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.restartPending = true
|
||||||
|
restart := s.restartContainer
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
|
||||||
|
s.writeRestartAccepted(w, r, false)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "already_pending": alreadyPending, "instance_id": s.instanceID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n"))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1522,7 +1597,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, session *m
|
|||||||
LastIPv6: settings.LastIPv6,
|
LastIPv6: settings.LastIPv6,
|
||||||
}
|
}
|
||||||
if session.Role == "admin" && r.URL.Query().Get("runs") == "1" {
|
if session.Role == "admin" && r.URL.Query().Get("runs") == "1" {
|
||||||
runs, err := s.store.RecentRuns(r.Context(), 6)
|
runs, err := s.store.RecentSignificantRuns(r.Context(), 6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
@@ -1776,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)
|
||||||
|
|||||||
+241
-8
@@ -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"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,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")
|
||||||
@@ -292,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-28`,
|
`features-31`,
|
||||||
`data-local-time="1788001920"`,
|
`data-local-time="1788001920"`,
|
||||||
`dashboard-zone-item`,
|
`dashboard-zone-item`,
|
||||||
`DNS-Zone · example.de`,
|
`DNS-Zone · example.de`,
|
||||||
@@ -303,7 +375,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
func TestRecentRunsShowChangesAndErrorsOnlyForAdministrators(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
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"))
|
||||||
@@ -318,8 +390,38 @@ func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
|||||||
if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil {
|
if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// More no-op runs than the display limit must not push out older changes.
|
||||||
|
for index := 0; index < 12; index++ {
|
||||||
|
id, err := database.CreateUpdateRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
summary := "0 Ziel(e) aktualisiert, 0 Fehler"
|
||||||
|
if index%2 == 0 {
|
||||||
|
summary = "Keine aktiven DynDNS-Ziele"
|
||||||
|
}
|
||||||
|
if err := database.FinishUpdateRun(ctx, id, "ok", "", "", summary); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"partial", "error"} {
|
||||||
|
id, err := database.CreateUpdateRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.FinishUpdateRun(ctx, id, status, "", "", "0 Ziel(e) aktualisiert, 0 Fehler"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := database.CreateUpdateRun(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
schedule := scheduler.New(database, nil, nil, nil, slog.Default())
|
schedule := scheduler.New(database, nil, nil, nil, slog.Default())
|
||||||
server := &Server{store: database, scheduler: schedule}
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{store: database, scheduler: schedule, templates: templates, logger: slog.Default()}
|
||||||
|
|
||||||
adminResponse := httptest.NewRecorder()
|
adminResponse := httptest.NewRecorder()
|
||||||
adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||||
@@ -334,13 +436,26 @@ func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(t *testing.T) {
|
|||||||
if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil {
|
if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 1 {
|
if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 3 {
|
||||||
t.Fatalf("administrator did not receive recent runs: %#v", adminState.RecentRuns)
|
t.Fatalf("administrator did not receive recent runs: %#v", adminState.RecentRuns)
|
||||||
}
|
}
|
||||||
if run := (*adminState.RecentRuns)[0]; run.ID != runID || run.Status != "ok" || run.Summary != "1 Ziel aktualisiert" {
|
if run := (*adminState.RecentRuns)[2]; run.ID != runID || run.Status != "ok" || run.Summary != "1 Ziel aktualisiert" {
|
||||||
t.Fatalf("unexpected recent run: %#v", run)
|
t.Fatalf("unexpected recent run: %#v", run)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (*adminState.RecentRuns)[0].Status != "error" || (*adminState.RecentRuns)[1].Status != "partial" {
|
||||||
|
t.Fatal("zero-change errors or partial runs were hidden")
|
||||||
|
}
|
||||||
|
allRuns, err := database.RecentRuns(ctx, 100)
|
||||||
|
if err != nil || len(allRuns) != 16 {
|
||||||
|
t.Fatalf("filter changed diagnostic history: count=%d err=%v", len(allRuns), err)
|
||||||
|
}
|
||||||
|
pageResponse := httptest.NewRecorder()
|
||||||
|
server.dashboard(pageResponse, httptest.NewRequest(http.MethodGet, "/", nil), &model.Session{Role: "admin"})
|
||||||
|
if pageResponse.Code != http.StatusOK || !strings.Contains(pageResponse.Body.String(), "1 Ziel aktualisiert") || strings.Contains(pageResponse.Body.String(), "Keine aktiven DynDNS-Ziele") || strings.Count(pageResponse.Body.String(), "0 Ziel(e) aktualisiert, 0 Fehler") != 2 {
|
||||||
|
t.Fatal("initial dashboard differs from the filtered live history")
|
||||||
|
}
|
||||||
|
|
||||||
memberResponse := httptest.NewRecorder()
|
memberResponse := httptest.NewRecorder()
|
||||||
memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||||
server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"})
|
server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"})
|
||||||
@@ -413,6 +528,8 @@ 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: "/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"},
|
||||||
}
|
}
|
||||||
for _, test := range requests {
|
for _, test := range requests {
|
||||||
@@ -523,13 +640,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
t.Errorf("global update notification is missing %q", expected)
|
t.Errorf("global update notification is missing %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var staleNotification bytes.Buffer
|
||||||
|
staleData := accountData
|
||||||
|
staleData.SoftwareUpdate.AgentAvailable = false
|
||||||
|
staleData.SoftwareUpdate.AgentCompatible = false
|
||||||
|
if err := templates["security"].ExecuteTemplate(&staleNotification, "security", staleData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(staleNotification.String(), `data-software-update-form`) {
|
||||||
|
t.Fatal("stale boot-time agent state hides the live-checked update button")
|
||||||
|
}
|
||||||
|
|
||||||
var page bytes.Buffer
|
var page bytes.Buffer
|
||||||
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState, InstanceID: "instance-before-restart"}
|
||||||
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"`} {
|
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)
|
||||||
}
|
}
|
||||||
@@ -539,7 +666,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)
|
||||||
}
|
}
|
||||||
@@ -565,6 +692,77 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainerRestartRequiresCSRFAndAuditsOneRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
var restartCalls atomic.Int32
|
||||||
|
server := &Server{store: database, instanceID: "instance-before-restart", restartContainer: func() { restartCalls.Add(1) }}
|
||||||
|
session := &model.Session{CSRFToken: "valid-csrf", Username: "admin", Role: "admin"}
|
||||||
|
|
||||||
|
invalidResponse := httptest.NewRecorder()
|
||||||
|
invalidRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=wrong"))
|
||||||
|
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
server.containerRestart(invalidResponse, invalidRequest, session)
|
||||||
|
if invalidResponse.Code != http.StatusForbidden || restartCalls.Load() != 0 {
|
||||||
|
t.Fatalf("invalid CSRF triggered restart: status=%d calls=%d", invalidResponse.Code, restartCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRestart := func() *httptest.ResponseRecorder {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
server.containerRestart(response, request, session)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
first := requestRestart()
|
||||||
|
if first.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||||
|
t.Fatalf("valid restart was not accepted once: status=%d calls=%d body=%s", first.Code, restartCalls.Load(), first.Body.String())
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(first.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result["accepted"] != true || result["instance_id"] != "instance-before-restart" {
|
||||||
|
t.Fatalf("unexpected restart response: %#v", result)
|
||||||
|
}
|
||||||
|
second := requestRestart()
|
||||||
|
if second.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||||
|
t.Fatalf("duplicate restart was not idempotent: status=%d calls=%d", second.Code, restartCalls.Load())
|
||||||
|
}
|
||||||
|
events, err := database.RecentAudit(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Kind != "system.container_restart" || !strings.Contains(events[0].Message, "admin") {
|
||||||
|
t.Fatalf("restart audit is missing or duplicated: %#v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
unconfigured := &Server{store: database}
|
||||||
|
unconfiguredResponse := httptest.NewRecorder()
|
||||||
|
unconfiguredRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||||
|
unconfiguredRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
unconfigured.containerRestart(unconfiguredResponse, unconfiguredRequest, session)
|
||||||
|
if unconfiguredResponse.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("restart without process callback returned %d", unconfiguredResponse.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthExposesProcessInstanceWithoutCaching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := &Server{instanceID: "current-process-instance"}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.health(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if response.Code != http.StatusOK || response.Header().Get("X-Domain-Control-Instance") != "current-process-instance" || response.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatalf("health response does not identify the current process: status=%d headers=%v", response.Code, response.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
templates, err := loadTemplates()
|
templates, err := loadTemplates()
|
||||||
@@ -774,14 +972,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:
|
||||||
@@ -826,6 +1033,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)
|
||||||
@@ -834,6 +1045,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) {
|
||||||
|
|||||||
+110
-1
@@ -478,6 +478,115 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerRestartForm = document.querySelector('[data-container-restart-form]');
|
||||||
|
if (containerRestartForm) {
|
||||||
|
const overlay = document.querySelector('[data-container-restart-overlay]');
|
||||||
|
const title = overlay?.querySelector('[data-container-restart-title]');
|
||||||
|
const message = overlay?.querySelector('[data-container-restart-message]');
|
||||||
|
const close = overlay?.querySelector('[data-container-restart-close]');
|
||||||
|
const submit = containerRestartForm.querySelector('button[type="submit"]');
|
||||||
|
let polling = false;
|
||||||
|
let startedAt = 0;
|
||||||
|
let expectedInstance = containerRestartForm.dataset.instanceId || '';
|
||||||
|
let consecutiveReady = 0;
|
||||||
|
let sawUnavailable = false;
|
||||||
|
|
||||||
|
const setRestartState = (state, heading, text) => {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.dataset.updateState = state;
|
||||||
|
overlay.setAttribute('aria-busy', String(state !== 'complete' && state !== 'failed'));
|
||||||
|
if (title) title.textContent = heading;
|
||||||
|
if (message) message.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollContainerRestart = async () => {
|
||||||
|
if (!polling) return;
|
||||||
|
if (Date.now() - startedAt > 90 * 1000) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('failed', 'Neustart nicht bestätigt', 'Die neue Container-Instanz wurde nicht rechtzeitig erreichbar. Prüfe den Containerstatus am Docker-Host.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/healthz?restart-check=${Date.now()}`, {cache: 'no-store', credentials: 'same-origin'});
|
||||||
|
if (!response.ok) throw new Error('healthcheck unavailable');
|
||||||
|
const currentInstance = response.headers.get('X-Domain-Control-Instance') || '';
|
||||||
|
const isNewInstance = expectedInstance
|
||||||
|
? (currentInstance !== '' && currentInstance !== expectedInstance) || (currentInstance === '' && sawUnavailable)
|
||||||
|
: sawUnavailable;
|
||||||
|
if (isNewInstance) {
|
||||||
|
consecutiveReady += 1;
|
||||||
|
setRestartState('restarting', 'Neue Instanz wird geprüft', 'Der Container ist wieder erreichbar. Die Verbindung wird noch einmal bestätigt.');
|
||||||
|
if (consecutiveReady >= 2) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('complete', 'Container neu gestartet', 'Die neue Container-Instanz ist bereit. Die Seite wird neu geladen.');
|
||||||
|
window.setTimeout(() => window.location.assign('/system'), 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveReady = 0;
|
||||||
|
setRestartState('restarting', 'Container wird neu gestartet', 'Die bisherige Instanz wird geordnet beendet.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sawUnavailable = true;
|
||||||
|
consecutiveReady = 0;
|
||||||
|
setRestartState('restarting', 'Verbindung wird aufgebaut', 'Der Container ist kurz nicht erreichbar und startet automatisch neu.');
|
||||||
|
}
|
||||||
|
window.setTimeout(pollContainerRestart, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
containerRestartForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (polling || !overlay) return;
|
||||||
|
if (!window.confirm('Den vollständigen DynDNS-Container jetzt neu starten? Der Dienst ist kurz nicht erreichbar.')) return;
|
||||||
|
|
||||||
|
const formData = new FormData(containerRestartForm);
|
||||||
|
const requestBody = new URLSearchParams();
|
||||||
|
for (const [name, value] of formData) {
|
||||||
|
if (typeof value === 'string') requestBody.append(name, value);
|
||||||
|
}
|
||||||
|
polling = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
consecutiveReady = 0;
|
||||||
|
sawUnavailable = false;
|
||||||
|
overlay.hidden = false;
|
||||||
|
if (close) close.hidden = true;
|
||||||
|
if (submit) submit.disabled = true;
|
||||||
|
document.body.classList.add('software-update-open');
|
||||||
|
setRestartState('restarting', 'Neustart wird vorbereitet', 'Die Anwendung wird geordnet beendet und automatisch wieder gestartet.');
|
||||||
|
|
||||||
|
let responseReceived = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(containerRestartForm.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
|
||||||
|
responseReceived = true;
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const result = contentType.includes('application/json')
|
||||||
|
? await response.json().catch(() => ({}))
|
||||||
|
: {message: (await response.text()).trim().slice(0, 240)};
|
||||||
|
if (!response.ok || !result.accepted) throw new Error(result.message || 'Container-Neustart konnte nicht gestartet werden.');
|
||||||
|
expectedInstance = result.instance_id || expectedInstance;
|
||||||
|
} catch (error) {
|
||||||
|
if (responseReceived) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('failed', 'Neustart nicht gestartet', error instanceof Error ? error.message : 'Container-Neustart konnte nicht gestartet werden.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
if (submit) submit.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sawUnavailable = true;
|
||||||
|
}
|
||||||
|
window.setTimeout(pollContainerRestart, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
close?.addEventListener('click', () => {
|
||||||
|
polling = false;
|
||||||
|
overlay.hidden = true;
|
||||||
|
close.hidden = true;
|
||||||
|
document.body.classList.remove('software-update-open');
|
||||||
|
if (submit) submit.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||||
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||||
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
||||||
@@ -527,7 +636,7 @@
|
|||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-state';
|
empty.className = 'empty-state';
|
||||||
empty.textContent = 'Noch kein Update-Lauf protokolliert.';
|
empty.textContent = 'Noch keine Änderungen oder Fehler protokolliert.';
|
||||||
recentRuns.replaceChildren(empty);
|
recentRuns.replaceChildren(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,6 +513,17 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
||||||
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||||
.software-update-button:hover i { transform: translate(2px,-2px); }
|
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||||
|
.container-restart-panel { display: grid; width: min(930px,100%); grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.container-restart-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #c8d3df; border-radius: 12px; background: linear-gradient(145deg,#f7f9fb,#edf1f5); color: #526174; font-family: var(--mono); font-size: 18px; font-weight: 900; }
|
||||||
|
.container-restart-copy h2 { margin-bottom: 3px; }
|
||||||
|
.container-restart-copy p { max-width: 650px; margin: 0; color: var(--muted); font-size: 9px; line-height: 1.55; }
|
||||||
|
.container-restart-panel form { position: relative; z-index: 1; }
|
||||||
|
.container-restart-overlay .software-update-dialog { width: min(500px,100%); }
|
||||||
|
.container-restart-progress { height: 7px; overflow: hidden; margin-top: 18px; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
|
||||||
|
.container-restart-progress i { display: block; width: 38%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); animation: container-restart-progress 1.35s ease-in-out infinite; }
|
||||||
|
.container-restart-overlay[data-update-state="complete"] .container-restart-progress i { width: 100%; background: #34d399; animation: none; }
|
||||||
|
.container-restart-overlay[data-update-state="failed"] .container-restart-progress i { width: 100%; background: #e45c70; animation: none; }
|
||||||
|
.container-restart-overlay [data-container-restart-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||||
.update-credential-panel { width: min(930px,100%); }
|
.update-credential-panel { width: min(930px,100%); }
|
||||||
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
|
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
|
||||||
.update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
|
.update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
|
||||||
@@ -613,6 +624,7 @@ body.software-update-open { overflow: hidden; }
|
|||||||
@keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } }
|
@keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } }
|
||||||
@keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
@keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||||
@keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } }
|
@keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } }
|
||||||
|
@keyframes container-restart-progress { from { transform: translateX(-115%); } to { transform: translateX(305%); } }
|
||||||
|
|
||||||
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||||
@@ -709,6 +721,9 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||||
.software-version-action { grid-column: 1 / -1; }
|
.software-version-action { grid-column: 1 / -1; }
|
||||||
.software-version-action form, .software-version-action .button { width: 100%; }
|
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||||
|
.container-restart-panel { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
|
.container-restart-panel form { grid-column: 1 / -1; }
|
||||||
|
.container-restart-panel form, .container-restart-panel .button { width: 100%; }
|
||||||
.update-access-state { grid-template-columns: 42px minmax(0,1fr); }
|
.update-access-state { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
.update-access-state > .status-pill { grid-column: 2; justify-self: start; }
|
.update-access-state > .status-pill { grid-column: 2; justify-self: start; }
|
||||||
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
@@ -755,6 +770,7 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
||||||
.software-update-phases { margin-top: 14px; }
|
.software-update-phases { margin-top: 14px; }
|
||||||
.software-update-dialog [data-software-update-close] { margin-top: 14px; }
|
.software-update-dialog [data-software-update-close] { margin-top: 14px; }
|
||||||
|
.container-restart-overlay [data-container-restart-close] { margin-top: 14px; }
|
||||||
.onboarding-intro { padding-block: 30px; }
|
.onboarding-intro { padding-block: 30px; }
|
||||||
.onboarding-copy { margin-top: 28px; }
|
.onboarding-copy { margin-top: 28px; }
|
||||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
||||||
|
<p class="panel-description">Die letzten Änderungen, Fehler und Teilerfolge.</p>
|
||||||
<div data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
|
<div data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
|
||||||
{{if .Dashboard.RecentRuns}}
|
{{if .Dashboard.RecentRuns}}
|
||||||
<div class="data-list">
|
<div class="data-list">
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time></small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time></small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
|
{{else}}<div class="empty-state">Noch keine Änderungen oder Fehler protokolliert.</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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-28">
|
<link rel="stylesheet" href="/static/styles.css?v=features-31">
|
||||||
<script src="/static/app.js?v=features-28" defer></script>
|
<script src="/static/app.js?v=features-31" 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>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||||
<div class="software-update-banner-actions">
|
<div class="software-update-banner-actions">
|
||||||
<a class="button button-quiet" href="/system">Details</a>
|
<a class="button button-quiet" href="/system">Details</a>
|
||||||
{{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
{{if not .SoftwareUpdate.ComposeSyncRequired}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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,15 +10,15 @@
|
|||||||
<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 .SoftwareUpdate.AgentCompatible}}
|
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||||
<form action="/system/update" method="post" data-software-update-form>
|
<form action="/system/update" method="post" data-software-update-form>
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
||||||
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||||
@@ -28,25 +28,35 @@
|
|||||||
{{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>
|
||||||
|
|
||||||
|
<section class="panel container-restart-panel">
|
||||||
|
<span class="container-restart-icon" aria-hidden="true">↻</span>
|
||||||
|
<div class="container-restart-copy"><span class="section-label">Containersteuerung</span><h2>Anwendungscontainer neu starten</h2><p>Beendet den vollständigen DynDNS-Container geordnet und startet ihn mit unveränderten Daten, Secrets und Einstellungen neu. Der Dienst ist dabei kurz nicht erreichbar.</p></div>
|
||||||
|
<form action="/system/restart" method="post" data-container-restart-form data-instance-id="{{.InstanceID}}">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button class="button button-quiet" type="submit">Container neu starten</button>
|
||||||
|
</form>
|
||||||
|
</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">
|
||||||
{{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}}">
|
||||||
|
<p class="field-note">Öffentliche Updates benötigen keine Anmeldung. Dieser Zugang wird nur für private Updates verwendet.</p>
|
||||||
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
||||||
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
||||||
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
|
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
|
||||||
@@ -54,5 +64,19 @@
|
|||||||
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="software-update-overlay container-restart-overlay" data-container-restart-overlay data-update-state="restarting" hidden role="dialog" aria-modal="true" aria-labelledby="container-restart-title">
|
||||||
|
<div class="software-update-dialog">
|
||||||
|
<div class="software-update-visual" aria-hidden="true">
|
||||||
|
<span class="software-update-scan"></span>
|
||||||
|
<div class="software-update-orbit"><span>DC</span><i></i></div>
|
||||||
|
</div>
|
||||||
|
<span class="section-label">Container-Neustart</span>
|
||||||
|
<h2 id="container-restart-title" data-container-restart-title>Neustart wird vorbereitet</h2>
|
||||||
|
<p data-container-restart-message aria-live="polite">Die Anwendung wird geordnet beendet und automatisch wieder gestartet.</p>
|
||||||
|
<div class="container-restart-progress" aria-hidden="true"><i></i></div>
|
||||||
|
<button class="button button-quiet" type="button" data-container-restart-close hidden>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{template "software_update_overlay" .}}
|
{{template "software_update_overlay" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user