Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81def77e3c | ||
|
|
3383b91e49 | ||
|
|
7bb33e528c | ||
|
|
1bbb14470c | ||
|
|
26a960c6bd | ||
|
|
fc59092030 | ||
|
|
bb60c06c0e | ||
|
|
f02ec9977c |
No files matched your search
@@ -43,3 +43,5 @@ jobs:
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.version=${{ gitea.ref_name }}
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ gitea.ref_name }}
|
||||
@@ -44,13 +44,18 @@ Ersetze die bisherige Version mindestens an diesen Stellen:
|
||||
|
||||
- `compose.yml`
|
||||
- Image-Beispiel in `README.md`
|
||||
- Versionshinweis in `UPDATE.md`
|
||||
- `ARG VERSION` in `Dockerfile`
|
||||
- Fallback-Version in `internal/buildinfo/version.go`
|
||||
|
||||
Danach dürfen diese Dateien die vorherige Version nicht mehr enthalten. Setze `PREVIOUS` auf die tatsächlich vorherige Version:
|
||||
`MINIMUM_UPDATE_VERSION`, `MinimumUpdateVersion`, `MinimumAgentVersion` und die Vertragsnummern sind Kompatibilitätsgrenzen. Ändere sie nicht bei normalen Releases, sondern nur bei einem bewusst geplanten Vertragsbruch mit passender Migration und Dokumentation.
|
||||
|
||||
Danach dürfen die gepinnten Image-Referenzen nicht mehr auf die vorherige Version zeigen. Historische Hinweise zur v1.4-Update-Basis bleiben bestehen. Setze `PREVIOUS` auf die tatsächlich vorherige Version:
|
||||
|
||||
```bash
|
||||
PREVIOUS=v1.2.1
|
||||
if grep -n "$PREVIOUS" README.md compose.yml; then
|
||||
echo "Alte Version $PREVIOUS ist noch eingetragen." >&2
|
||||
if grep -n "domain-control-webinterface-prod:$PREVIOUS" README.md compose.yml; then
|
||||
echo "Alte Image-Version $PREVIOUS ist noch eingetragen." >&2
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f compose.yml config --quiet
|
||||
@@ -72,7 +77,7 @@ Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewu
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
-w /src golang:1.25.12-alpine \
|
||||
-w /src golang:1.26.9-alpine \
|
||||
gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go
|
||||
```
|
||||
|
||||
@@ -83,7 +88,7 @@ docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
||||
-w /src golang:1.25.12-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 ./...'
|
||||
```
|
||||
|
||||
@@ -94,7 +99,7 @@ docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
||||
-w /src golang:1.25.12-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 ./...'
|
||||
```
|
||||
|
||||
@@ -133,13 +138,13 @@ git ls-remote origin refs/heads/main refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}'
|
||||
|
||||
## 5. Container-Image veröffentlichen
|
||||
|
||||
Ein `v*`-Tag startet den Gitea-Workflow. Prüfe zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden.
|
||||
Wenn Repository-Actions aktiviert sind, startet ein `v*`-Tag den Gitea-Workflow. Prüfe dann zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden. Sind Repository-Actions deaktiviert, verwende ausschließlich den manuellen Multi-Arch-Fallback.
|
||||
|
||||
### Bekannter Gitea-Runner-Zustand
|
||||
|
||||
Stand 13.07.2026 ist kein Gitea-Actions-Runner aktiv. Die Runs bleiben auf `queued`. Der übliche Cancel-Endpunkt ist in dieser Gitea-Version nicht vorhanden; das Löschen wartender Runs antwortete mit HTTP 400. Verändere deshalb nicht direkt die Gitea-Datenbank. Prüfe den Zustand bei jedem Release neu.
|
||||
Stand 29.08.2026 ist kein Gitea-Actions-Runner aktiv. Repository-Actions sind deshalb deaktiviert. Run 7 für `v1.3.0` liegt weiterhin intern auf `queued`, kann im deaktivierten Zustand aber nicht ausgeführt werden; sein Commit-Status wurde nach der bereits abgeschlossenen manuellen Veröffentlichung auf `success` korrigiert. Gitea 1.26.4 bietet keinen API-Endpunkt zum Abbrechen einzelner Runs. Verändere deshalb nicht direkt die Gitea-Datenbank.
|
||||
|
||||
Bevor später ein Runner aktiviert wird, müssen die alten wartenden Release-Runs in Gitea bereinigt werden. Andernfalls könnten sie veraltete Images erneut als `stable` veröffentlichen.
|
||||
Bevor Actions oder ein Runner später aktiviert werden, muss Run 7 in der Gitea-Weboberfläche abgebrochen werden. Andernfalls könnte er das veraltete Image erneut als `stable` veröffentlichen. Aktiviere Actions erst danach und nur zusammen mit einem funktionsfähigen Runner.
|
||||
|
||||
### Manueller Multi-Arch-Fallback
|
||||
|
||||
@@ -185,6 +190,7 @@ Baue und pushe anschließend beide Plattformen in einem gemeinsamen Manifest:
|
||||
docker buildx build \
|
||||
--builder domain-control-release-builder \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg "VERSION=$VERSION" \
|
||||
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION" \
|
||||
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:stable" \
|
||||
--label org.opencontainers.image.source=https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface \
|
||||
|
||||
+30
-5
@@ -1,5 +1,14 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.25.12-alpine AS build
|
||||
ARG VERSION=v1.5.1
|
||||
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
||||
ARG MINIMUM_AGENT_PROTOCOL=2
|
||||
ARG RUNTIME_CONTRACT_VERSION=2
|
||||
ARG DATABASE_CONTRACT_VERSION=1
|
||||
ARG SCHEMA_VERSION=11
|
||||
|
||||
FROM golang:1.26.9-alpine AS build
|
||||
|
||||
ARG VERSION
|
||||
|
||||
RUN apk add --no-cache build-base
|
||||
WORKDIR /src
|
||||
@@ -8,21 +17,37 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dyndns-web ./cmd/dyndns-web
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/domain-control ./cmd/dyndns-web
|
||||
|
||||
FROM alpine:3.23.2 AS runtime
|
||||
|
||||
ARG VERSION
|
||||
ARG MINIMUM_UPDATE_VERSION
|
||||
ARG MINIMUM_AGENT_PROTOCOL
|
||||
ARG RUNTIME_CONTRACT_VERSION
|
||||
ARG DATABASE_CONTRACT_VERSION
|
||||
ARG SCHEMA_VERSION
|
||||
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
addgroup -S -g 10001 dyndns && \
|
||||
adduser -S -D -H -u 10001 -G dyndns dyndns && \
|
||||
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets
|
||||
|
||||
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
|
||||
COPY --from=build /out/domain-control /usr/local/bin/domain-control
|
||||
RUN ln -s domain-control /usr/local/bin/update-agent
|
||||
|
||||
LABEL org.opencontainers.image.version="${VERSION}" \
|
||||
de.j-mahler.domain-control.minimum-update-version="${MINIMUM_UPDATE_VERSION}" \
|
||||
de.j-mahler.domain-control.minimum-agent-protocol="${MINIMUM_AGENT_PROTOCOL}" \
|
||||
de.j-mahler.domain-control.runtime-contract="${RUNTIME_CONTRACT_VERSION}" \
|
||||
de.j-mahler.domain-control.database-contract="${DATABASE_CONTRACT_VERSION}" \
|
||||
de.j-mahler.domain-control.schema-version="${SCHEMA_VERSION}"
|
||||
|
||||
USER dyndns:dyndns
|
||||
WORKDIR /data
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dyndns-web", "healthcheck"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/domain-control", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/dyndns-web"]
|
||||
ENTRYPOINT ["/usr/local/bin/domain-control"]
|
||||
CMD ["serve"]
|
||||
@@ -25,34 +25,59 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
|
||||
```yaml
|
||||
services:
|
||||
dyndns:
|
||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2
|
||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9090:8080"
|
||||
volumes:
|
||||
- dyndns-data:/data
|
||||
- dyndns-secrets:/secrets
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
stop_grace_period: 20s
|
||||
ports: ["9090:8080"]
|
||||
volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
|
||||
labels: {de.j-mahler.domain-control.role: app}
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
volumes:
|
||||
dyndns-data:
|
||||
dyndns-secrets:
|
||||
update-agent:
|
||||
image: *image
|
||||
restart: unless-stopped
|
||||
user: "0:10001"
|
||||
command: [update-agent]
|
||||
healthcheck: {disable: true}
|
||||
network_mode: none
|
||||
volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 32
|
||||
mem_limit: 64m
|
||||
|
||||
volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
|
||||
```
|
||||
|
||||
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
In `compose.yml` die Versionsnummer beim Image ändern, anschließend das Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
|
||||
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
||||
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
||||
|
||||
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Ö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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
Compose ersetzt beide Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes. Vor einem späteren manuellen `docker compose up` muss der dort gepinnte Tag deshalb auf die gewünschte Version gesetzt werden. Meldet die WebUI **Compose aktualisieren**, unterstützt der vorhandene Agent den benötigten Vertrag nicht und beide Container werden auf diesem Weg gemeinsam aktualisiert.
|
||||
|
||||
Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden.
|
||||
|
||||
## Funktionen
|
||||
@@ -63,11 +88,75 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
|
||||
- lokales Archiv statt Löschen von Hetzner-Records
|
||||
- frei einstellbares Update-Intervall mit Countdown
|
||||
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||
- 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
|
||||
- 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
|
||||
- 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. Der Master-Key liegt getrennt mit Dateimodus `0600`; Datenverzeichnis und SQLite-Datei werden auf `0700` beziehungsweise `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
|
||||
Die Pi-hole-Seite zeigt sofort den gespeicherten Stand. **Records neu einlesen** aktualisiert ihn mit einer begrenzten API-Abfrage. Eine nicht mehr erreichbare Verbindung kann über **Lokale Verbindung entfernen** entfernt werden. Der Container muss die verbundene Pi-hole-Adresse für API-Aktionen direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
|
||||
|
||||
Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`.
|
||||
|
||||
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
|
||||
|
||||
## Netzwerkausfälle und Diagnose
|
||||
|
||||
Der Build und die Container-Prüfungen verwenden Go 1.26.9. Damit sind auch
|
||||
die vom Sicherheitscheck gemeldeten HTTP-/Template-Probleme der bisherigen
|
||||
Go-1.25.13-Basis behoben (siehe etwa [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)).
|
||||
|
||||
Nach fehlgeschlagenen DynDNS-Läufen folgen weitere Versuche nach 5, 10, 20,
|
||||
40 und höchstens 60 Sekunden; nach einem erfolgreichen Lauf gilt wieder das
|
||||
konfigurierte Intervall. Ein IP-Endpunkt hat einschließlich DNS, TLS und
|
||||
Antwort höchstens zehn Sekunden Zeit. IPv4 und IPv6 werden unabhängig und
|
||||
parallel ermittelt. Fehlgeschlagene Abfragen liefern keine alte IP für ein
|
||||
DNS-Update; vorhandene DNS-Records bleiben dann bestehen.
|
||||
|
||||
Hetzner-Änderungen gelten erst dann als erfolgreich, wenn der asynchrone
|
||||
API-Auftrag `success` meldet; `running`, spätere Fehler und Zeitüberschreitungen
|
||||
werden nicht als Erfolg gespeichert. Die Bestätigung wird bis zu 20 Sekunden
|
||||
abgewartet. Ein Fehler beim A-Record verhindert die Aktualisierung des
|
||||
AAAA-Records nicht und umgekehrt; bestätigte Teiländerungen bleiben sichtbar.
|
||||
Siehe den [Hetzner-API-Vertrag für Actions](https://docs.hetzner.cloud/reference/cloud#actions).
|
||||
|
||||
WebUI-Änderungen an DNS-Zielen und automatische Läufe werden vollständig
|
||||
nacheinander ausgeführt, damit ein alter Lauf keine gelöschten Ziele neu
|
||||
anlegt oder feste Adressen überschreibt. Dauert ein laufender Vorgang länger
|
||||
als fünf Sekunden, wird die neue WebUI-Änderung mit einer Meldung zum erneuten
|
||||
Senden abgewiesen. Unveränderte IP-Endpunkte brauchen beim Speichern der
|
||||
Updater-Einstellungen keine erneute DNS-Prüfung. Neue Endpunkte werden weiter
|
||||
geprüft (höchstens acht je IP-Familie).
|
||||
|
||||
Laufhistorie und letzter Status werden gemeinsam gespeichert, auch nach einem
|
||||
kontrollierten Abbruch. Beim Prozessstart werden durch einen Absturz offene
|
||||
Läufe als unterbrochen markiert. Der aktive Updater beginnt automatisch mit
|
||||
einer neuen Prüfung; beim Herunterfahren wird sein Abschluss abgewartet.
|
||||
|
||||
Bei einem vorübergehenden Fehler des System-DNS (Timeout/SERVFAIL) werden
|
||||
**nur** `api.ipify.org`, `api6.ipify.org`, `ifconfig.co` und
|
||||
`api.hetzner.cloud` zusätzlich direkt über Cloudflare und Quad9 aufgelöst
|
||||
(`1.1.1.1`, `9.9.9.9`, `2606:4700:4700::1111`, `2620:fe::fe`, Port 53).
|
||||
System-DNS und die parallelen Ausweichabfragen haben jeweils zwei Sekunden.
|
||||
Jede neue Abfrage beginnt wieder mit System-DNS. Negative DNS-Antworten
|
||||
(NXDOMAIN), eigene Endpunkte und lokale Pi-hole-Namen erhalten keinen solchen
|
||||
Ausweichweg. HTTPS-Zertifikate und öffentliche IP-Endpunkte werden weiterhin
|
||||
geprüft. Dieser Mechanismus ersetzt keine funktionierende Route oder
|
||||
Firewall-Freigabe auf dem Produktionsserver.
|
||||
|
||||
Unter **Fehler → Diagnose-Logs herunterladen** können Administratoren einen
|
||||
JSON-Bericht exportieren. Er enthält Version, letzte Laufstatus, IP-Abfragen,
|
||||
DNS-Ausweichversuche, Fehlertypen, Wartezeiten, Wiederherstellung und
|
||||
DNS-/IPv4-Routing-Daten. Die Ereignisse werden im bestehenden Daten-Volume in
|
||||
zwei Dateien zu höchstens je 4 MiB mit Dateirechten `0600` gespeichert und
|
||||
überleben Container-Neustarts. Bei langen Ausfällen wird der Netzwerkzustand
|
||||
beim ersten Fehler und anschließend alle 15 Minuten festgehalten.
|
||||
|
||||
Der Export enthält ausschließlich festgelegte technische Felder. Rohmeldungen,
|
||||
Audit-Texte, Zugangsdaten, Cookies, Sitzungs-IDs, HTTP-Header, URL-Pfade und
|
||||
Query-Parameter werden nicht übernommen. IP-Adressen und Netzwerkrouten sind
|
||||
zur Diagnose enthalten. Die Aufzeichnung beginnt mit dieser Funktion; alte
|
||||
Läufe sind nur mit Zeit und Status enthalten. Der lokale Healthcheck bestätigt
|
||||
die Erreichbarkeit der Web-App, nicht die Erreichbarkeit von Internet/Hetzner.
|
||||
Bei `no_route` müssen auf Prod Gateway, Docker-Bridge, Forwarding/NAT und
|
||||
Firewall geprüft werden; ein Container-Neustart allein belegt keine Behebung.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Domain Control aktualisieren
|
||||
|
||||
Die aktuelle Compose-Version ist `v1.5.1`. Datenbank, Einstellungen und
|
||||
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||
den folgenden Verfahren erhalten.
|
||||
|
||||
## Direkt in der Weboberfläche
|
||||
|
||||
1. Als Administrator anmelden und **Update** öffnen.
|
||||
2. Bei öffentlichen Releases und Images ist keine Anmeldung erforderlich.
|
||||
Für private Repositories oder Container-Pakete Gitea-Benutzername und
|
||||
Passwort oder einen Token mit den entsprechenden Leserechten hinterlegen.
|
||||
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
|
||||
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
||||
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
||||
Container wiederherzustellen.
|
||||
|
||||
Die Wiederherstellung greift auch dann, wenn Docker den bisherigen Container
|
||||
bereits gestoppt hat, aber die Antwort auf den Stopp-Aufruf verloren geht.
|
||||
Healthcheck-Zeitlimits schließen blockierte Docker-Anfragen ein.
|
||||
|
||||
Jede Release-Prüfung und jeder Image-Download beginnt ohne Anmeldung. Nur bei
|
||||
fehlendem Zugriff werden gespeicherte Zugangsdaten verwendet. Der Wechsel
|
||||
zwischen öffentlich und privat wird bei jedem Vorgang erneut berücksichtigt;
|
||||
die Sichtbarkeit des Git-Repositories und des Container-Pakets wird getrennt
|
||||
geprüft. Ein nicht sichtbares Release (HTTP 404) kann auch bedeuten, dass noch
|
||||
kein Release veröffentlicht wurde.
|
||||
|
||||
Ältere Update-Agenten mit Protokoll 2 verlangen noch Zugangsdaten. Für anonyme
|
||||
Image-Downloads muss der Agent einmal gemeinsam mit der Web-App per Compose
|
||||
auf eine Version mit dieser Funktion aktualisiert werden. Die Web-App erkennt
|
||||
diese Fähigkeit im Agent-Status; die bestehenden Kompatibilitätsgrenzen bleiben
|
||||
erhalten. Mit gespeichertem Zugang bleiben ältere kompatible Agenten nutzbar.
|
||||
|
||||
Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden
|
||||
DynDNS-Dienst nicht. Direkte Sprünge über mehrere Versionen sind möglich, wenn
|
||||
das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese
|
||||
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
||||
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
|
||||
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
||||
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
||||
eigene Versionsnummer älter als die der Web-App ist. Erscheint **Compose
|
||||
aktualisieren**, benötigt das Ziel einen neueren Agent-Vertrag; dann beide
|
||||
Container wie unten beschrieben gemeinsam aktualisieren.
|
||||
|
||||
## Mit Docker Compose
|
||||
|
||||
In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
dyndns:
|
||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
||||
update-agent:
|
||||
image: *image
|
||||
```
|
||||
|
||||
Danach im Ordner der Compose-Datei ausführen:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Die Web-App und der Update-Agent verwenden durch den YAML-Anker dieselbe
|
||||
Version. Für das Update ist kein `docker compose down` nötig. Insbesondere
|
||||
niemals `docker compose down -v` verwenden, da `-v` die Daten- und
|
||||
Secret-Volumes löschen würde.
|
||||
|
||||
Der Ein-Klick-Updater kann die auf dem Host liegende `compose.yml` nicht
|
||||
verändern. Vor jedem späteren manuellen Compose-Lauf daher zuerst den gepinnten
|
||||
Tag auf die gewünschte Version setzen, damit Compose keine ältere Version
|
||||
wiederherstellt.
|
||||
|
||||
Der Healthcheck lässt sich anschließend direkt prüfen:
|
||||
|
||||
```bash
|
||||
curl --fail http://127.0.0.1:9090/healthz
|
||||
```
|
||||
|
||||
Die erwartete Antwort lautet `ok`.
|
||||
|
||||
## Portainer oder Dockge
|
||||
|
||||
1. Den bestehenden Stack bearbeiten.
|
||||
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.1` setzen.
|
||||
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
||||
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
||||
|
||||
Den Stack nicht mitsamt seinen Volumes löschen oder neu anlegen.
|
||||
|
||||
## Rollback
|
||||
|
||||
Falls ein manuelles Rollback nötig ist, die zentrale Image-Zeile wieder auf die
|
||||
zuvor eingesetzte konkrete Version setzen und erneut ausführen:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und
|
||||
`dyndns-secrets` gemeinsam gesichert werden. Nur zusammen enthalten sie sowohl
|
||||
die Datenbank als auch den zur Entschlüsselung benötigten Master-Key.
|
||||
+105
-4
@@ -3,41 +3,83 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||
"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/updateagent"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||
command := commandForInvocation(os.Args)
|
||||
if command == "healthcheck" {
|
||||
os.Exit(healthcheck())
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
if err := run(logger); err != nil {
|
||||
var err error
|
||||
switch command {
|
||||
case "serve":
|
||||
err = run(logger)
|
||||
case "update-agent":
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
err = updateagent.Serve(ctx, updateagent.ServerConfigFromEnvironment(), logger)
|
||||
default:
|
||||
err = errors.New("unknown command; expected serve, healthcheck, or update-agent")
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("application stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func commandForInvocation(arguments []string) string {
|
||||
if len(arguments) == 0 {
|
||||
return "invalid"
|
||||
}
|
||||
// v1.3 Compose files override the agent entrypoint and inherit the image's
|
||||
// default "serve" command. Keep that one-time manual upgrade path working
|
||||
// while the image itself still contains only one executable.
|
||||
if filepath.Base(arguments[0]) == "update-agent" && (len(arguments) == 1 || (len(arguments) == 2 && arguments[1] == "serve")) {
|
||||
return "update-agent"
|
||||
}
|
||||
if len(arguments) == 1 {
|
||||
return "serve"
|
||||
}
|
||||
if len(arguments) == 2 {
|
||||
return arguments[1]
|
||||
}
|
||||
return "invalid"
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
startedAt := time.Now()
|
||||
// Credentials and session data must never inherit a permissive process umask.
|
||||
syscall.Umask(0o077)
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diagnostics.Configure(filepath.Join(cfg.DataDir, "diagnostics.jsonl"), logger); err != nil {
|
||||
logger.Warn("diagnostic log unavailable")
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "startup", Network: diagnostics.CurrentNetwork()})
|
||||
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -48,24 +90,48 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.RecoverInterruptedRuns(ctx); err != nil {
|
||||
return fmt.Errorf("recover interrupted update runs: %w", err)
|
||||
}
|
||||
if err := database.Prune(ctx); err != nil {
|
||||
logger.Warn("prune old local data", "error", err)
|
||||
}
|
||||
provider := hetzner.New(cfg.HCloudBaseURL)
|
||||
updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger)
|
||||
softwareUpdates, err := appupdate.New(database, cipher, cfg.UpdateAPIURL, cfg.UpdateAgentSock, cfg.UpdateInterval, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settings.IPv6Enabled {
|
||||
deleted, cleanupErr := updater.DisableIPv6(ctx)
|
||||
cleanupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
deleted, cleanupErr := updater.DisableIPv6(cleanupCtx)
|
||||
cancel()
|
||||
if cleanupErr != nil {
|
||||
logger.Warn("enforce disabled IPv6", "error", cleanupErr)
|
||||
} else if deleted > 0 {
|
||||
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
||||
}
|
||||
}
|
||||
serverUI, err := web.New(cfg, database, cipher, updater, 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 {
|
||||
return err
|
||||
}
|
||||
@@ -73,6 +139,16 @@ func run(logger *slog.Logger) error {
|
||||
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
updater.Start(appCtx)
|
||||
defer func() {
|
||||
stop()
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := updater.Wait(waitCtx); err != nil {
|
||||
logger.Warn("waiting for updater shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
softwareUpdates.Start(appCtx)
|
||||
go pruneStore(appCtx, database, logger)
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: serverUI.Handler(),
|
||||
@@ -80,6 +156,7 @@ func run(logger *slog.Logger) error {
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -92,6 +169,12 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
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():
|
||||
logger.Info("shutdown requested")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
@@ -100,6 +183,24 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
}
|
||||
|
||||
func pruneStore(ctx context.Context, database *store.Store, logger *slog.Logger) {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pruneCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
err := database.Prune(pruneCtx)
|
||||
cancel()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Warn("prune old local data", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func healthcheck() int {
|
||||
url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL"))
|
||||
if url == "" {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommandForInvocationKeepsV13ComposeAgentCompatible(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
arguments []string
|
||||
want string
|
||||
}{
|
||||
{arguments: []string{"/usr/local/bin/domain-control"}, want: "serve"},
|
||||
{arguments: []string{"/usr/local/bin/domain-control", "update-agent"}, want: "update-agent"},
|
||||
{arguments: []string{"/usr/local/bin/domain-control", "healthcheck"}, want: "healthcheck"},
|
||||
{arguments: []string{"/usr/local/bin/update-agent"}, want: "update-agent"},
|
||||
{arguments: []string{"/usr/local/bin/update-agent", "serve"}, want: "update-agent"},
|
||||
{arguments: []string{"/usr/local/bin/domain-control", "serve", "extra"}, want: "invalid"},
|
||||
{arguments: nil, want: "invalid"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := commandForInvocation(test.arguments); got != test.want {
|
||||
t.Errorf("commandForInvocation(%q) = %q; want %q", test.arguments, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-15
@@ -1,20 +1,29 @@
|
||||
services:
|
||||
dyndns:
|
||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2
|
||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9090:8080"
|
||||
volumes:
|
||||
- dyndns-data:/data
|
||||
- dyndns-secrets:/secrets
|
||||
stop_grace_period: 20s
|
||||
ports: ["9090:8080"]
|
||||
volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
|
||||
labels: {de.j-mahler.domain-control.role: app}
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
|
||||
volumes:
|
||||
dyndns-data:
|
||||
dyndns-secrets:
|
||||
update-agent:
|
||||
image: *image
|
||||
restart: unless-stopped
|
||||
user: "0:10001"
|
||||
command: [update-agent]
|
||||
healthcheck: {disable: true}
|
||||
network_mode: none
|
||||
volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 32
|
||||
mem_limit: 64m
|
||||
|
||||
volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
|
||||
@@ -1,6 +1,8 @@
|
||||
module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.9
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
type AgentClient struct {
|
||||
socket string
|
||||
}
|
||||
|
||||
func NewAgentClient(socket string) *AgentClient {
|
||||
return &AgentClient{socket: socket}
|
||||
}
|
||||
|
||||
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
|
||||
encoded, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode == http.StatusConflict {
|
||||
return errors.New("ein Software-Update läuft bereits")
|
||||
}
|
||||
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||
}
|
||||
|
||||
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
|
||||
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
|
||||
if err != nil {
|
||||
return updateprotocol.Status{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||
}
|
||||
var status updateprotocol.Status
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
|
||||
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
if c == nil || c.socket == "" {
|
||||
return nil, errors.New("Update-Agent ist nicht konfiguriert")
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{Timeout: time.Second}
|
||||
return dialer.DialContext(ctx, "unix", c.socket)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("Update-Agent ist nicht erreichbar")
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
var ErrCredentialMissing = errors.New("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 {
|
||||
CurrentVersion string
|
||||
LatestVersion string
|
||||
ReleaseURL string
|
||||
DesiredVersion string
|
||||
LastCheckedAt time.Time
|
||||
LastError string
|
||||
CredentialConfigured bool
|
||||
CredentialUsername string
|
||||
UpdateAvailable bool
|
||||
AgentAvailable bool
|
||||
AgentCompatible bool
|
||||
AgentStatus updateprotocol.Status
|
||||
AgentVersion string
|
||||
AgentProtocol int
|
||||
ComposeSyncRequired bool
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
apiURL string
|
||||
interval time.Duration
|
||||
agent updateAgent
|
||||
httpClient *http.Client
|
||||
checkMu sync.Mutex
|
||||
requestMu sync.Mutex
|
||||
stateMu sync.RWMutex
|
||||
state State
|
||||
checkSignal chan struct{}
|
||||
}
|
||||
|
||||
type releaseResponse struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) {
|
||||
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager := &Manager{
|
||||
store: database,
|
||||
cipher: cipher,
|
||||
logger: logger,
|
||||
apiURL: apiURL,
|
||||
interval: interval,
|
||||
agent: NewAgentClient(agentSocket),
|
||||
checkSignal: make(chan struct{}, 1),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 12 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
settings = manager.reconcileInstalledVersion(context.Background(), settings)
|
||||
manager.setStoredState(settings)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
go m.monitorAgent(ctx, agentUnavailableRetryInterval, agentAvailableCheckInterval)
|
||||
go func() {
|
||||
initial := time.NewTimer(15 * time.Second)
|
||||
defer initial.Stop()
|
||||
var ticker *time.Ticker
|
||||
var tick <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
return
|
||||
case <-initial.C:
|
||||
if ticker == nil {
|
||||
ticker = time.NewTicker(m.interval)
|
||||
tick = ticker.C
|
||||
}
|
||||
m.backgroundCheck(ctx)
|
||||
case <-tick:
|
||||
m.backgroundCheck(ctx)
|
||||
case <-m.checkSignal:
|
||||
m.backgroundCheck(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) backgroundCheck(ctx context.Context) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) monitorAgent(ctx context.Context, unavailableInterval, availableInterval time.Duration) {
|
||||
for {
|
||||
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() {
|
||||
select {
|
||||
case m.checkSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) State() State {
|
||||
m.stateMu.RLock()
|
||||
defer m.stateMu.RUnlock()
|
||||
return m.state
|
||||
}
|
||||
|
||||
func (m *Manager) SaveCredential(ctx context.Context, username, credential string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || len(username) > 128 {
|
||||
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
||||
}
|
||||
if len(credential) < 8 || len(credential) > 1024 {
|
||||
return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge")
|
||||
}
|
||||
ciphertext, nonce, err := m.cipher.Encrypt(credential)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
|
||||
}
|
||||
if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) CheckNow(ctx context.Context) error {
|
||||
m.checkMu.Lock()
|
||||
defer m.checkMu.Unlock()
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Public releases must remain usable even with missing or stale credentials.
|
||||
release, err := m.fetchLatest(ctx, "", "")
|
||||
if errors.Is(err, ErrCredentialMissing) && settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||
credential, decryptErr := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if decryptErr != nil {
|
||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||
}
|
||||
release, err = m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
||||
}
|
||||
if err != nil {
|
||||
return m.recordFailure(ctx, err)
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "release_check_ok", Target: "git"})
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
m.requestMu.Lock()
|
||||
defer m.requestMu.Unlock()
|
||||
|
||||
version = strings.TrimSpace(version)
|
||||
state := m.State()
|
||||
if !validReleaseVersion(version) || version != state.LatestVersion {
|
||||
return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release")
|
||||
}
|
||||
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")
|
||||
}
|
||||
status, err := m.UpdateStatus(ctx)
|
||||
if err != nil {
|
||||
return errors.New("Update-Agent ist nicht erreichbar")
|
||||
}
|
||||
if !agentCompatible(status) {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var username, credential string
|
||||
if settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||
credential, err = m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err == nil {
|
||||
username = settings.RegistryUsername
|
||||
} else if !status.AnonymousPull {
|
||||
return errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")
|
||||
}
|
||||
}
|
||||
if username == "" && !status.AnonymousPull {
|
||||
return errors.New("für Updates ohne Anmeldung muss der Update-Agent einmal mit Docker Compose aktualisiert werden")
|
||||
}
|
||||
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: username, RegistryToken: credential}
|
||||
if err := m.agent.Start(ctx, request); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
m.clearDesiredVersion(cleanupCtx, version)
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
|
||||
status, err := m.agent.Status(ctx)
|
||||
m.stateMu.Lock()
|
||||
desiredVersion := m.state.DesiredVersion
|
||||
m.state.AgentAvailable = err == nil
|
||||
if err == nil {
|
||||
m.state.AgentStatus = status
|
||||
m.state.AgentVersion = status.AgentVersion
|
||||
m.state.AgentProtocol = status.Protocol
|
||||
m.state.AgentCompatible = agentCompatible(status)
|
||||
m.state.ComposeSyncRequired = !m.state.AgentCompatible || (!m.state.CredentialConfigured && !status.AnonymousPull)
|
||||
} else {
|
||||
m.state.AgentCompatible = false
|
||||
m.state.ComposeSyncRequired = false
|
||||
}
|
||||
m.stateMu.Unlock()
|
||||
if err == nil && status.Phase == updateprotocol.PhaseFailed && status.TargetVersion != "" {
|
||||
m.clearDesiredVersion(ctx, status.TargetVersion)
|
||||
} else if err == nil && status.Phase == updateprotocol.PhaseIdle && desiredVersion != "" {
|
||||
// An agent restart intentionally resets its in-memory queue. Do not leave
|
||||
// an update marked as pending when no update can still be running.
|
||||
m.clearDesiredVersion(ctx, desiredVersion)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (m *Manager) fetchLatest(ctx context.Context, username, credential string) (releaseResponse, error) {
|
||||
response, err := m.fetchLatestWithAuth(ctx, username, credential, true)
|
||||
if err != nil {
|
||||
return releaseResponse{}, err
|
||||
}
|
||||
if needsAuthentication(response.StatusCode) && username != "" && credential != "" {
|
||||
response.Body.Close()
|
||||
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
||||
if err != nil {
|
||||
return releaseResponse{}, err
|
||||
}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if needsAuthentication(response.StatusCode) {
|
||||
if username == "" || credential == "" {
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: ErrCredentialMissing}
|
||||
}
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: errors.New("Gitea-Zugang abgelehnt oder kein sichtbares veröffentlichtes Release vorhanden")}
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)}
|
||||
}
|
||||
var release releaseResponse
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
|
||||
return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig")
|
||||
}
|
||||
if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) {
|
||||
return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release")
|
||||
}
|
||||
release.HTMLURL = m.safeReleaseURL(release.HTMLURL)
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func needsAuthentication(status int) bool {
|
||||
// Gitea also conceals private repositories using HTTP 404.
|
||||
return status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound
|
||||
}
|
||||
|
||||
func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("Update-Endpunkt ist ungültig")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if username != "" && credential != "" {
|
||||
if basic {
|
||||
request.SetBasicAuth(username, credential)
|
||||
} else {
|
||||
request.Header.Set("Authorization", "token "+credential)
|
||||
}
|
||||
}
|
||||
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, &releaseFetchError{err: err}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type releaseFetchError struct{ err error }
|
||||
|
||||
func (e *releaseFetchError) Error() string { return "Git-Server ist momentan nicht erreichbar" }
|
||||
func (e *releaseFetchError) Unwrap() error { return e.err }
|
||||
|
||||
type releaseStatusError struct {
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *releaseStatusError) Error() string { return e.err.Error() }
|
||||
func (e *releaseStatusError) Unwrap() error { return e.err }
|
||||
func (e *releaseStatusError) HTTPStatusCode() int { return e.status }
|
||||
|
||||
func (m *Manager) safeReleaseURL(raw string) string {
|
||||
apiURL, apiErr := url.Parse(m.apiURL)
|
||||
releaseURL, releaseErr := url.Parse(raw)
|
||||
if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) {
|
||||
return ""
|
||||
}
|
||||
return releaseURL.String()
|
||||
}
|
||||
|
||||
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
|
||||
diagnostics.Record(diagnostics.Event{Code: "release_check_failed", Target: "git", Failure: diagnostics.Failure(checkErr), HTTPStatus: diagnostics.HTTPStatus(checkErr)})
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil {
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
return checkErr
|
||||
}
|
||||
|
||||
func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
||||
state := State{
|
||||
CurrentVersion: buildinfo.Version,
|
||||
LatestVersion: settings.LatestVersion,
|
||||
ReleaseURL: settings.ReleaseURL,
|
||||
DesiredVersion: settings.DesiredVersion,
|
||||
LastCheckedAt: settings.LastCheckedAt,
|
||||
LastError: settings.LastError,
|
||||
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
|
||||
CredentialUsername: settings.RegistryUsername,
|
||||
}
|
||||
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
|
||||
state.UpdateAvailable = comparison < 0
|
||||
}
|
||||
m.stateMu.Lock()
|
||||
state.AgentAvailable = m.state.AgentAvailable
|
||||
state.AgentCompatible = m.state.AgentCompatible
|
||||
state.AgentStatus = m.state.AgentStatus
|
||||
state.AgentVersion = m.state.AgentVersion
|
||||
state.AgentProtocol = m.state.AgentProtocol
|
||||
state.ComposeSyncRequired = state.AgentAvailable && (!state.AgentCompatible || (!state.CredentialConfigured && !state.AgentStatus.AnonymousPull))
|
||||
m.state = state
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) reconcileInstalledVersion(ctx context.Context, settings model.SoftwareUpdateSettings) model.SoftwareUpdateSettings {
|
||||
if settings.DesiredVersion == "" {
|
||||
return settings
|
||||
}
|
||||
comparison, err := compareVersions(buildinfo.Version, settings.DesiredVersion)
|
||||
if err != nil || comparison < 0 {
|
||||
return settings
|
||||
}
|
||||
if err := m.store.ClearDesiredSoftwareVersion(ctx, settings.DesiredVersion); err != nil {
|
||||
m.logger.Warn("clear completed software update state", "error", err)
|
||||
return settings
|
||||
}
|
||||
settings.DesiredVersion = ""
|
||||
return settings
|
||||
}
|
||||
|
||||
func (m *Manager) clearDesiredVersion(ctx context.Context, expected string) {
|
||||
if expected == "" {
|
||||
return
|
||||
}
|
||||
if err := m.store.ClearDesiredSoftwareVersion(ctx, expected); err != nil {
|
||||
m.logger.Warn("clear stale software update state", "error", err)
|
||||
return
|
||||
}
|
||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
m.logger.Warn("refresh software update state", "error", err)
|
||||
return
|
||||
}
|
||||
m.setStoredState(settings)
|
||||
}
|
||||
|
||||
func agentCompatible(status updateprotocol.Status) bool {
|
||||
if status.Protocol < buildinfo.MinimumAgentProtocol || status.Protocol > buildinfo.UpdateProtocolVersion {
|
||||
return false
|
||||
}
|
||||
comparison, err := compareVersions(status.AgentVersion, buildinfo.MinimumAgentVersion)
|
||||
return err == nil && comparison >= 0
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAgentStatus(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
_, _ = m.UpdateStatus(statusCtx)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package appupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
const credential = " test-password-or-token-that-must-stay-secret "
|
||||
var unavailable atomic.Bool
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || username != "registry-user" || password != credential {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if unavailable.Load() {
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":%q,"draft":false,"prerelease":false}`, nextVersion, "https://example.invalid/releases/"+nextVersion)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
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, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.SaveCredential(ctx, "registry-user", credential); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.CheckNow(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
||||
t.Fatalf("new release was not detected: %#v", state)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(settings.TokenCiphertext), credential) {
|
||||
t.Fatal("registry credential was stored as plaintext")
|
||||
}
|
||||
|
||||
unavailable.Store(true)
|
||||
if err := manager.CheckNow(ctx); err == nil {
|
||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||
}
|
||||
state = manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError == "" {
|
||||
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerFallsBackToTokenAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
const credential = "fallback-api-token"
|
||||
var requests atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
if r.Header.Get("Authorization") != "token "+credential {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v2.0.0","html_url":"https://example.invalid/releases/v2.0.0","draft":false,"prerelease":false}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
manager := &Manager{apiURL: api.URL, httpClient: api.Client()}
|
||||
release, err := manager.fetchLatest(context.Background(), "registry-user", credential)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release.TagName != "v2.0.0" || requests.Load() != 2 {
|
||||
t.Fatalf("token fallback was not used: release=%#v requests=%d", release, requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
status updateprotocol.Status
|
||||
want bool
|
||||
}{
|
||||
{name: "v1.4 baseline", status: updateprotocol.Status{AgentVersion: "v1.4.0", Protocol: 2}, want: true},
|
||||
{name: "newer compatible agent version", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion}, want: true},
|
||||
{name: "legacy protocol", status: updateprotocol.Status{Protocol: 0}},
|
||||
{name: "old agent version", status: updateprotocol.Status{AgentVersion: "v1.3.0", Protocol: 2}},
|
||||
{name: "future unknown protocol", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion + 1}},
|
||||
{name: "missing version", status: updateprotocol.Status{Protocol: 2}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := agentCompatible(test.status); got != test.want {
|
||||
t.Fatalf("agentCompatible(%#v) = %v; want %v", test.status, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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() })
|
||||
if err := database.SetDesiredSoftwareVersion(ctx, buildinfo.Version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if manager.State().DesiredVersion != "" {
|
||||
t.Fatalf("installed version remained pending: %#v", manager.State())
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.DesiredVersion != "" {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package appupdate
|
||||
|
||||
import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
|
||||
func compareVersions(left, right string) (int, error) {
|
||||
return updateprotocol.CompareVersions(left, right)
|
||||
}
|
||||
|
||||
func validReleaseVersion(version string) bool {
|
||||
if !updateprotocol.ValidVersion(version) {
|
||||
return false
|
||||
}
|
||||
_, err := updateprotocol.CompareVersions(version, version)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package appupdate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
left string
|
||||
right string
|
||||
want int
|
||||
}{
|
||||
{left: "v1.2.2", right: "v1.2.3", want: -1},
|
||||
{left: "v1.3.0", right: "v2.0.0", want: -1},
|
||||
{left: "v2.0.0", right: "v1.99.99", want: 1},
|
||||
{left: "v1.2.2", right: "v1.2.2", want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := compareVersions(test.left, test.right)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
|
||||
if validReleaseVersion(invalid) {
|
||||
t.Errorf("invalid release version accepted: %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package buildinfo
|
||||
|
||||
const (
|
||||
// UpdateProtocolVersion is returned by the privileged update agent. Version
|
||||
// 2 requires the running version so the agent can verify it independently.
|
||||
UpdateProtocolVersion = 2
|
||||
|
||||
// The contracts below are deliberately stable across normal releases. A
|
||||
// breaking container or database change must use a new contract and cannot
|
||||
// be installed through the unattended update path.
|
||||
RuntimeContractVersion = 2
|
||||
DatabaseContractVersion = 1
|
||||
CurrentSchemaVersion = 11
|
||||
MinimumUpdateVersion = "v1.4.0"
|
||||
MinimumAgentVersion = "v1.4.0"
|
||||
MinimumAgentProtocol = 2
|
||||
)
|
||||
|
||||
// Version is replaced through -ldflags for release images. The source fallback
|
||||
// keeps local builds and tests honest about the currently checked-out release.
|
||||
var Version = "v1.5.1"
|
||||
@@ -0,0 +1,50 @@
|
||||
package buildinfo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerfileMetadataMatchesRuntimeContracts(t *testing.T) {
|
||||
t.Parallel()
|
||||
content, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dockerfile := string(content)
|
||||
wanted := []string{
|
||||
"ARG VERSION=" + Version,
|
||||
"ARG MINIMUM_UPDATE_VERSION=" + MinimumUpdateVersion,
|
||||
"ARG MINIMUM_AGENT_PROTOCOL=" + strconv.Itoa(MinimumAgentProtocol),
|
||||
"ARG RUNTIME_CONTRACT_VERSION=" + strconv.Itoa(RuntimeContractVersion),
|
||||
"ARG DATABASE_CONTRACT_VERSION=" + strconv.Itoa(DatabaseContractVersion),
|
||||
"ARG SCHEMA_VERSION=" + strconv.Itoa(CurrentSchemaVersion),
|
||||
}
|
||||
for _, line := range wanted {
|
||||
if !strings.Contains(dockerfile, line+"\n") {
|
||||
t.Errorf("Dockerfile is missing %q", line)
|
||||
}
|
||||
}
|
||||
versionLinkerFlag := "-X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}"
|
||||
if count := strings.Count(dockerfile, versionLinkerFlag); count != 1 {
|
||||
t.Fatalf("release version must be linked into the control binary, found %d flags", count)
|
||||
}
|
||||
if !strings.Contains(dockerfile, "ln -s domain-control /usr/local/bin/update-agent") {
|
||||
t.Fatal("Dockerfile is missing the v1.3 Compose compatibility symlink")
|
||||
}
|
||||
for path, marker := range map[string]string{
|
||||
"../../compose.yml": "domain-control-webinterface-prod:" + Version,
|
||||
"../../README.md": "domain-control-webinterface-prod:" + Version,
|
||||
"../../UPDATE.md": "Compose-Version ist `" + Version + "`",
|
||||
} {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), marker) {
|
||||
t.Errorf("%s is not synchronized with %s", path, Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -27,6 +29,9 @@ type Config struct {
|
||||
HCloudBaseURL string
|
||||
Mode string
|
||||
ShutdownTimeout time.Duration
|
||||
UpdateAPIURL string
|
||||
UpdateAgentSock string
|
||||
UpdateInterval time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -51,6 +56,14 @@ func Load() (Config, error) {
|
||||
}
|
||||
|
||||
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
|
||||
updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour)
|
||||
if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour {
|
||||
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h")
|
||||
}
|
||||
updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest")
|
||||
if err := validateUpdateURL(updateAPIURL); err != nil {
|
||||
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err)
|
||||
}
|
||||
|
||||
cookieSecure := mode == "https"
|
||||
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
||||
@@ -71,9 +84,28 @@ func Load() (Config, error) {
|
||||
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
|
||||
Mode: mode,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
UpdateAPIURL: updateAPIURL,
|
||||
UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
|
||||
UpdateInterval: updateInterval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateUpdateURL(raw string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return errors.New("must be an absolute HTTPS URL without embedded credentials")
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
address := net.ParseIP(host)
|
||||
if host == "localhost" || (address != nil && address.IsLoopback()) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("HTTP is allowed only for loopback test endpoints")
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package diagnostics records only explicitly selected technical fields. Raw
|
||||
// errors, request headers, URLs, credentials and arbitrary log text are excluded.
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxFileSize = 4 << 20
|
||||
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Code string `json:"event"`
|
||||
Address string `json:"address,omitempty"`
|
||||
DNSResolver string `json:"dns_resolver,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Failure string `json:"failure,omitempty"`
|
||||
RunID int64 `json:"run_id,omitempty"`
|
||||
TargetID int64 `json:"target_id,omitempty"`
|
||||
ActionID int64 `json:"action_id,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||
RetrySeconds int64 `json:"retry_seconds,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
Network *Network `json:"network,omitempty"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Interface string `json:"interface"`
|
||||
Destination string `json:"destination"`
|
||||
Gateway string `json:"gateway"`
|
||||
Mask string `json:"mask"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
DockerUpstreams []string `json:"docker_upstreams,omitempty"`
|
||||
Routes []Route `json:"ipv4_routes"`
|
||||
}
|
||||
|
||||
type Recorder struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var active atomic.Pointer[Recorder]
|
||||
|
||||
func Configure(path string, logger *slog.Logger) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = file.Chmod(0600)
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
active.Store(&Recorder{path: path, logger: logger})
|
||||
return nil
|
||||
}
|
||||
|
||||
func Record(event Event) {
|
||||
if recorder := active.Load(); recorder != nil {
|
||||
recorder.Record(event)
|
||||
}
|
||||
}
|
||||
|
||||
func Snapshot() []Event {
|
||||
if recorder := active.Load(); recorder != nil {
|
||||
return recorder.Snapshot()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Recorder) Record(event Event) {
|
||||
event.Time = time.Now().UTC()
|
||||
event = sanitize(event)
|
||||
if event.Code == "" {
|
||||
return
|
||||
}
|
||||
r.logger.Info("diagnostic", "event", event.Code, "target", event.Target, "family", event.Family, "failure", event.Failure, "run_id", event.RunID, "target_id", event.TargetID, "action_id", event.ActionID, "attempt", event.Attempt, "duration_ms", event.DurationMS, "retry_seconds", event.RetrySeconds, "http_status", event.HTTPStatus, "address", event.Address, "dns_resolver", event.DNSResolver, "status", event.Status, "network", event.Network)
|
||||
encoded, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if info, err := os.Stat(r.path); err == nil && info.Size()+int64(len(encoded)+1) > maxFileSize {
|
||||
if err := os.Rename(r.path, r.path+".1"); err != nil {
|
||||
r.logger.Warn("diagnostic log rotation failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
r.logger.Warn("diagnostic log unavailable")
|
||||
return
|
||||
}
|
||||
_, err = file.Write(append(encoded, '\n'))
|
||||
closeErr := file.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
r.logger.Warn("diagnostic log write failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) Snapshot() []Event {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var events []Event
|
||||
for _, path := range []string{r.path + ".1", r.path} {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(io.LimitReader(file, maxFileSize))
|
||||
scanner.Buffer(make([]byte, 4096), 64<<10)
|
||||
for scanner.Scan() {
|
||||
var event Event
|
||||
if json.Unmarshal(scanner.Bytes(), &event) == nil {
|
||||
event = sanitize(event)
|
||||
if event.Code != "" {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
file.Close()
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func allowed(value string, values ...string) string {
|
||||
for _, v := range values {
|
||||
if value == v {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitize(e Event) Event {
|
||||
if ip, err := netip.ParseAddr(e.Address); err == nil && ip.Zone() == "" {
|
||||
e.Address = ip.String()
|
||||
} else {
|
||||
e.Address = ""
|
||||
}
|
||||
if ip, err := netip.ParseAddr(e.DNSResolver); err == nil && ip.Zone() == "" {
|
||||
e.DNSResolver = ip.String()
|
||||
} else {
|
||||
e.DNSResolver = ""
|
||||
}
|
||||
e.Status = allowed(e.Status, "ok", "partial", "error")
|
||||
e.Code = allowed(e.Code, "startup", "dns_fallback_query", "update_run_finished", "provider_update_failed", "dns_fallback", "dns_fallback_ok", "dns_failed", "ip_fetch_ok", "ip_fetch_failed", "update_failed", "update_recovered", "pihole_refresh_ok", "pihole_refresh_failed", "release_check_ok", "release_check_failed")
|
||||
e.Target = allowed(e.Target, "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud", "custom_endpoint", "pihole", "git", "updater")
|
||||
e.Family = allowed(e.Family, "ip", "ip4", "ip6")
|
||||
e.Failure = allowed(e.Failure, "dns_timeout", "dns_not_found", "dns_server_failure", "dns_error", "no_route", "connection_refused", "connection_reset", "timeout", "canceled", "tls", "http", "provider_action", "other")
|
||||
if e.Network != nil {
|
||||
n := *e.Network
|
||||
n.Nameservers = validIPs(n.Nameservers)
|
||||
n.DockerUpstreams = validIPs(n.DockerUpstreams)
|
||||
n.Routes = nil
|
||||
for _, route := range e.Network.Routes {
|
||||
if validInterface(route.Interface) && len(validIPs([]string{route.Destination, route.Gateway, route.Mask})) == 3 {
|
||||
n.Routes = append(n.Routes, route)
|
||||
}
|
||||
}
|
||||
e.Network = &n
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func Target(host string) string {
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if allowed(host, "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud") != "" {
|
||||
return host
|
||||
}
|
||||
return "custom_endpoint"
|
||||
}
|
||||
|
||||
func Failure(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if ActionID(err) > 0 {
|
||||
return "provider_action"
|
||||
}
|
||||
if HTTPStatus(err) != 0 {
|
||||
return "http"
|
||||
}
|
||||
var unknown x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var invalid x509.CertificateInvalidError
|
||||
var record tls.RecordHeaderError
|
||||
if errors.As(err, &unknown) || errors.As(err, &hostname) || errors.As(err, &invalid) || errors.As(err, &record) {
|
||||
return "tls"
|
||||
}
|
||||
var dns *net.DNSError
|
||||
if errors.As(err, &dns) {
|
||||
if dns.IsNotFound {
|
||||
return "dns_not_found"
|
||||
}
|
||||
if dns.IsTimeout {
|
||||
return "dns_timeout"
|
||||
}
|
||||
if dns.IsTemporary {
|
||||
return "dns_server_failure"
|
||||
}
|
||||
return "dns_error"
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.EHOSTUNREACH), errors.Is(err, syscall.ENETUNREACH):
|
||||
return "no_route"
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return "connection_refused"
|
||||
case errors.Is(err, syscall.ECONNRESET):
|
||||
return "connection_reset"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "canceled"
|
||||
}
|
||||
var timeout net.Error
|
||||
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &timeout) && timeout.Timeout()) {
|
||||
return "timeout"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func ActionID(err error) int64 {
|
||||
var action interface{ ProviderActionID() int64 }
|
||||
if errors.As(err, &action) {
|
||||
return action.ProviderActionID()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func HTTPStatus(err error) int {
|
||||
var status interface{ HTTPStatusCode() int }
|
||||
if errors.As(err, &status) {
|
||||
return status.HTTPStatusCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func CurrentNetwork() *Network {
|
||||
n := &Network{}
|
||||
if data, err := os.ReadFile("/etc/resolv.conf"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[0] == "nameserver" {
|
||||
n.Nameservers = append(n.Nameservers, validIPs(fields[1:])...)
|
||||
}
|
||||
if strings.HasPrefix(line, "# ExtServers:") {
|
||||
n.DockerUpstreams = append(n.DockerUpstreams, validIPs(strings.Fields(strings.NewReplacer("[", " ", "]", " ", ",", " ").Replace(strings.TrimPrefix(line, "# ExtServers:"))))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/net/route"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 8 || !validInterface(f[0]) {
|
||||
continue
|
||||
}
|
||||
flags, _ := strconv.ParseUint(f[3], 16, 32)
|
||||
if flags&1 == 0 {
|
||||
continue
|
||||
}
|
||||
route := Route{f[0], routeIP(f[1]), routeIP(f[2]), routeIP(f[7])}
|
||||
if route.Destination != "" && route.Gateway != "" && route.Mask != "" {
|
||||
n.Routes = append(n.Routes, route)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func routeIP(raw string) string {
|
||||
b, err := hex.DecodeString(raw)
|
||||
if err != nil || len(b) != 4 {
|
||||
return ""
|
||||
}
|
||||
value := binary.LittleEndian.Uint32(b)
|
||||
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}).String()
|
||||
}
|
||||
func validInterface(s string) bool {
|
||||
if s == "" || len(s) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func validIPs(values []string) []string {
|
||||
var result []string
|
||||
for _, v := range values {
|
||||
if ip, err := netip.ParseAddr(v); err == nil && ip.Zone() == "" {
|
||||
result = append(result, ip.String())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportPersistsOnlyAllowlistedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
path := filepath.Join(t.TempDir(), "diagnostics.jsonl")
|
||||
r := &Recorder{path: path, logger: slog.New(slog.NewJSONHandler(&output, nil))}
|
||||
const secret = "password-token-cookie-SID-secret"
|
||||
r.Record(Event{Code: "ip_fetch_failed", Target: "https://user:" + secret + "@private.test/?token=" + secret, Failure: secret, Address: secret, Family: secret, Network: &Network{Nameservers: []string{"127.0.0.11", secret}, DockerUpstreams: []string{secret}, Routes: []Route{{secret, secret, secret, secret}}}})
|
||||
r.Record(Event{Code: secret, Target: secret})
|
||||
r.Record(Event{Code: "ip_fetch_failed", Address: "fe80::1%" + secret, DNSResolver: "fe80::2%" + secret, Network: &Network{Nameservers: []string{"fe80::3%" + secret}}})
|
||||
reloaded := &Recorder{path: path, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
encoded, err := json.Marshal(reloaded.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, data := range []string{string(encoded), string(file), output.String()} {
|
||||
if strings.Contains(data, secret) || strings.Contains(data, "private.test") {
|
||||
t.Fatalf("secret escaped allowlist: %s", data)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(encoded), "127.0.0.11") || !strings.Contains(string(encoded), "ip_fetch_failed") {
|
||||
t.Fatal("technical evidence lost")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("unsafe diagnostic permissions: %v %v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticRotationIsBoundedAndReloadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "diagnostics.jsonl")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = file.Truncate(maxFileSize); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file.Close()
|
||||
r := &Recorder{path: path, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
r.Record(Event{Code: "update_recovered", Attempt: 20})
|
||||
info, err := os.Stat(path + ".1")
|
||||
if err != nil || info.Size() != maxFileSize {
|
||||
t.Fatalf("rotation: %v %v", info, err)
|
||||
}
|
||||
events := r.Snapshot()
|
||||
if len(events) != 1 || events[0].Attempt != 20 {
|
||||
t.Fatalf("new event lost after rotation: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkFailuresAndRouteDecoding(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{&net.DNSError{IsTemporary: true}, "dns_server_failure"},
|
||||
{&net.DNSError{IsTimeout: true}, "dns_timeout"},
|
||||
{syscall.EHOSTUNREACH, "no_route"},
|
||||
{syscall.ECONNREFUSED, "connection_refused"},
|
||||
{context.DeadlineExceeded, "timeout"},
|
||||
} {
|
||||
if got := Failure(test.err); got != test.want {
|
||||
t.Errorf("failure=%s want %s", got, test.want)
|
||||
}
|
||||
}
|
||||
if got := routeIP("010012AC"); got != "172.18.0.1" {
|
||||
t.Fatalf("default gateway decoded incorrectly: %s", got)
|
||||
}
|
||||
}
|
||||
+103
-11
@@ -11,11 +11,15 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
baseURL string
|
||||
http *http.Client
|
||||
actionPollInterval time.Duration
|
||||
actionTimeout time.Duration
|
||||
}
|
||||
|
||||
type Zone struct {
|
||||
@@ -44,6 +48,8 @@ type APIError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) HTTPStatusCode() int { return e.Status }
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message)
|
||||
@@ -52,10 +58,16 @@ func (e *APIError) Error() string {
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DialContext = netresolve.New().DialContext
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
actionPollInterval: 500 * time.Millisecond,
|
||||
actionTimeout: 20 * time.Second,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -64,6 +76,8 @@ func New(baseURL string) *Client {
|
||||
}
|
||||
|
||||
func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
var result []Zone
|
||||
for page := 1; ; page++ {
|
||||
var response struct {
|
||||
@@ -87,12 +101,17 @@ func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
|
||||
if response.Meta.Pagination.NextPage == nil {
|
||||
break
|
||||
}
|
||||
if *response.Meta.Pagination.NextPage <= page {
|
||||
return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page)
|
||||
}
|
||||
page = *response.Meta.Pagination.NextPage - 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
var result []RRSet
|
||||
for page := 1; ; page++ {
|
||||
var response struct {
|
||||
@@ -111,6 +130,9 @@ func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet,
|
||||
if response.Meta.Pagination.NextPage == nil {
|
||||
break
|
||||
}
|
||||
if *response.Meta.Pagination.NextPage <= page {
|
||||
return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page)
|
||||
}
|
||||
page = *response.Meta.Pagination.NextPage - 1
|
||||
}
|
||||
return result, nil
|
||||
@@ -123,25 +145,25 @@ func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordTyp
|
||||
TTL int `json:"ttl"`
|
||||
Records []Record `json:"records"`
|
||||
}{Name: name, Type: recordType, TTL: ttl, Records: records}
|
||||
return c.doJSON(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body, nil)
|
||||
return c.doAction(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body)
|
||||
}
|
||||
|
||||
func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
|
||||
body := struct {
|
||||
Records []Record `json:"records"`
|
||||
}{Records: records}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil)
|
||||
return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body)
|
||||
}
|
||||
|
||||
func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error {
|
||||
body := struct {
|
||||
TTL int `json:"ttl"`
|
||||
}{TTL: ttl}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil)
|
||||
return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error {
|
||||
return c.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil)
|
||||
return c.doAction(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
|
||||
@@ -160,7 +182,74 @@ func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name,
|
||||
Comment string `json:"comment"`
|
||||
}{Value: record.Value, Comment: record.Comment})
|
||||
}
|
||||
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body, nil)
|
||||
return c.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body)
|
||||
}
|
||||
|
||||
// ActionError reports a failed asynchronous operation even when its HTTP
|
||||
// request was accepted successfully.
|
||||
type ActionError struct {
|
||||
ID int64
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ActionError) ProviderActionID() int64 { return e.ID }
|
||||
|
||||
func (e *ActionError) Error() string {
|
||||
return fmt.Sprintf("Hetzner action %d failed (%s): %s", e.ID, e.Code, e.Message)
|
||||
}
|
||||
|
||||
type actionResponse struct {
|
||||
Action *struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
} `json:"action"`
|
||||
}
|
||||
|
||||
func (c *Client) doAction(ctx context.Context, token, method, path string, body any) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.actionTimeout)
|
||||
defer cancel()
|
||||
var response actionResponse
|
||||
if err := c.doJSON(ctx, token, method, path, body, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
var actionID int64
|
||||
for {
|
||||
action := response.Action
|
||||
if action == nil || action.ID <= 0 || (actionID != 0 && action.ID != actionID) {
|
||||
return fmt.Errorf("Hetzner API returned a missing or invalid action")
|
||||
}
|
||||
actionID = action.ID
|
||||
switch action.Status {
|
||||
case "success":
|
||||
return nil
|
||||
case "error":
|
||||
failure := &ActionError{ID: action.ID, Code: "unknown", Message: "operation failed"}
|
||||
if action.Error != nil {
|
||||
failure.Code, failure.Message = action.Error.Code, action.Error.Message
|
||||
}
|
||||
return failure
|
||||
case "running":
|
||||
default:
|
||||
return fmt.Errorf("Hetzner action %d returned an invalid status", actionID)
|
||||
}
|
||||
timer := time.NewTimer(c.actionPollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return fmt.Errorf("wait for Hetzner action %d: %w", actionID, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
response = actionResponse{}
|
||||
// Poll the accepted operation; never repeat a write whose outcome is unknown.
|
||||
if err := c.doJSON(ctx, token, http.MethodGet, "/zones/actions/"+strconv.FormatInt(actionID, 10), nil, &response); err != nil {
|
||||
return fmt.Errorf("check Hetzner action %d: %w", actionID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error {
|
||||
@@ -186,14 +275,17 @@ func (c *Client) doJSON(ctx context.Context, token, method, path string, request
|
||||
return fmt.Errorf("request Hetzner API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Hetzner API response: %w", err)
|
||||
}
|
||||
if len(content) > 1<<20 {
|
||||
return fmt.Errorf("Hetzner API response exceeds size limit")
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return decodeAPIError(resp.StatusCode, content)
|
||||
}
|
||||
if responseBody != nil && len(content) > 0 {
|
||||
if responseBody != nil {
|
||||
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||
return fmt.Errorf("decode Hetzner API response: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,107 @@ package hetzner
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
)
|
||||
|
||||
func TestDNSWritesWaitForActionCompletion(t *testing.T) {
|
||||
operations := map[string]func(*Client) error{
|
||||
"create": func(c *Client) error {
|
||||
return c.CreateRRSet(context.Background(), "token", "123", "home", "A", 60, nil)
|
||||
},
|
||||
"records": func(c *Client) error {
|
||||
return c.SetRRSetRecords(context.Background(), "token", "123", "home", "A", nil)
|
||||
},
|
||||
"ttl": func(c *Client) error { return c.ChangeRRSetTTL(context.Background(), "token", "123", "home", "A", 60) },
|
||||
"comments": func(c *Client) error {
|
||||
return c.UpdateRecordComments(context.Background(), "token", "123", "home", "A", nil)
|
||||
},
|
||||
"delete": func(c *Client) error { return c.DeleteRRSet(context.Background(), "token", "123", "home", "A") },
|
||||
}
|
||||
for name, operation := range operations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, status := range []string{"success", "error"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
var writes, polls int
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes++
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/zones/actions/42" || r.Header.Get("Authorization") != "Bearer token" {
|
||||
t.Errorf("incorrect action polling request: %s", r.URL.Path)
|
||||
}
|
||||
polls++
|
||||
if polls == 1 {
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"` + status + `","error":{"code":"action_failed","message":"failed later"}}}`))
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
client := New(api.URL)
|
||||
client.actionPollInterval = time.Millisecond
|
||||
err := operation(client)
|
||||
if status == "success" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var actionErr *ActionError
|
||||
if status == "error" && (!errors.As(err, &actionErr) || actionErr.Code != "action_failed") {
|
||||
t.Fatalf("lost action failure: %v", err)
|
||||
}
|
||||
if status == "error" && (diagnostics.Failure(err) != "provider_action" || diagnostics.ActionID(err) != 42) {
|
||||
t.Fatal("action failure missing from safe diagnostics")
|
||||
}
|
||||
if writes != 1 || polls != 2 {
|
||||
t.Fatalf("returned too early or repeated write: writes=%d polls=%d", writes, polls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWaitIsBoundedAndRejectsMalformedResponses(t *testing.T) {
|
||||
for _, body := range []string{"", `{}`, `{"action":{"id":42,"status":"unknown"}}`, `{"action":{"status":"running"}}`, `{"action":{"id":42,"status":"running"}}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
|
||||
defer api.Close()
|
||||
client := New(api.URL)
|
||||
client.actionPollInterval = time.Millisecond
|
||||
client.actionTimeout = 30 * time.Millisecond
|
||||
if err := client.DeleteRRSet(context.Background(), "token", "123", "home", "A"); err == nil {
|
||||
t.Fatal("unconfirmed operation reported success")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := client.DeleteRRSet(ctx, "token", "123", "home", "A"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationRejectsRepeatedPage(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"zones":[],"rrsets":[],"meta":{"pagination":{"next_page":1}}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
client := New(api.URL)
|
||||
if _, err := client.ListZones(context.Background(), "token"); err == nil {
|
||||
t.Fatal("repeated zones page accepted")
|
||||
}
|
||||
if _, err := client.ListRRSets(context.Background(), "token", "123"); err == nil {
|
||||
t.Fatal("repeated RRsets page accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
type capturedRequest struct {
|
||||
@@ -33,7 +129,7 @@ func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
@@ -77,6 +173,7 @@ func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
@@ -115,6 +212,7 @@ func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
@@ -133,7 +231,8 @@ func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
|
||||
captured := make(chan *http.Request, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured <- r
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
@@ -22,10 +25,15 @@ type Result struct {
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
resolver *net.Resolver
|
||||
resolver interface {
|
||||
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
|
||||
}
|
||||
clientFor func(*url.URL, []netip.Addr) *http.Client
|
||||
}
|
||||
|
||||
func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} }
|
||||
func New() *Resolver {
|
||||
return &Resolver{resolver: netresolve.New(), clientFor: newEndpointClient}
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result {
|
||||
return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true)
|
||||
@@ -33,9 +41,19 @@ func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []s
|
||||
|
||||
func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result {
|
||||
result := Result{}
|
||||
// Each family gets independent endpoint deadlines.
|
||||
var ipv6Result chan Result
|
||||
if ipv6Enabled {
|
||||
ipv6Result = make(chan Result, 1)
|
||||
go func() {
|
||||
ip, err := r.resolveFamily(ctx, ipv6Endpoints, false)
|
||||
ipv6Result <- Result{IPv6: ip, IPv6Err: err}
|
||||
}()
|
||||
}
|
||||
result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true)
|
||||
if ipv6Enabled {
|
||||
result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false)
|
||||
ipv6 := <-ipv6Result
|
||||
result.IPv6, result.IPv6Err = ipv6.IPv6, ipv6.IPv6Err
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -56,57 +74,59 @@ func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 b
|
||||
if len(endpoints) == 0 {
|
||||
return "", errors.New("no endpoints configured")
|
||||
}
|
||||
var failures []string
|
||||
var failures []error
|
||||
for _, endpoint := range endpoints {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
started := time.Now()
|
||||
value, err := r.resolveOne(ctx, endpoint, ipv4)
|
||||
family := "ip6"
|
||||
if ipv4 {
|
||||
family = "ip4"
|
||||
}
|
||||
code := "ip_fetch_ok"
|
||||
if err != nil {
|
||||
code = "ip_fetch_failed"
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: code, Target: diagnostics.Target(safeEndpointName(endpoint)), Family: family, Failure: diagnostics.Failure(err), DurationMS: time.Since(started).Milliseconds(), Address: value, HTTPStatus: diagnostics.HTTPStatus(err)})
|
||||
if err == nil {
|
||||
return value, nil
|
||||
}
|
||||
failures = append(failures, safeEndpointName(endpoint)+": "+err.Error())
|
||||
failures = append(failures, fmt.Errorf("%s: %w", safeEndpointName(endpoint), err))
|
||||
}
|
||||
family := "IPv6"
|
||||
if ipv4 {
|
||||
family = "IPv4"
|
||||
}
|
||||
return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; "))
|
||||
return "", &familyError{family: family, failures: failures}
|
||||
}
|
||||
|
||||
type familyError struct {
|
||||
family string
|
||||
failures []error
|
||||
}
|
||||
|
||||
func (e *familyError) Error() string {
|
||||
messages := make([]string, len(e.failures))
|
||||
for i, err := range e.failures {
|
||||
messages[i] = err.Error()
|
||||
}
|
||||
return fmt.Sprintf("all %s endpoints failed: %s", e.family, strings.Join(messages, "; "))
|
||||
}
|
||||
func (e *familyError) Unwrap() []error { return e.failures }
|
||||
|
||||
func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) {
|
||||
// DNS, dialing, TLS and the response body share one deadline.
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
u, ips, err := r.parseAndResolve(ctx, raw, ipv4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
IdleConnTimeout: 10 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()},
|
||||
DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
client := r.clientFor(u, ips)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -118,7 +138,7 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("returned HTTP %d", resp.StatusCode)
|
||||
return "", responseError(resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
|
||||
if err != nil {
|
||||
@@ -140,6 +160,30 @@ func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (strin
|
||||
return address.String(), nil
|
||||
}
|
||||
|
||||
type responseError int
|
||||
|
||||
func (e responseError) Error() string { return fmt.Sprintf("returned HTTP %d", e) }
|
||||
func (e responseError) HTTPStatusCode() int { return int(e) }
|
||||
|
||||
func newEndpointClient(u *url.URL, ips []netip.Addr) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()},
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return netresolve.DialAddresses(ctx, "tcp", ips, "443")
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil {
|
||||
@@ -161,7 +205,11 @@ func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (
|
||||
}
|
||||
return u, []netip.Addr{literal}, nil
|
||||
}
|
||||
addresses, err := r.resolver.LookupNetIP(ctx, "ip", host)
|
||||
family := "ip6"
|
||||
if ipv4 {
|
||||
family = "ip4"
|
||||
}
|
||||
addresses, err := r.resolver.LookupNetIP(ctx, family, host)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve host: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,16 @@ package ipresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPublicAddress(t *testing.T) {
|
||||
@@ -32,3 +40,107 @@ func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) {
|
||||
t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
|
||||
func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return f(ctx, network, host)
|
||||
}
|
||||
|
||||
func TestResolverRecoversWithFreshIPAndFallbackEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
var current atomic.Value
|
||||
current.Store("1.1.1.1")
|
||||
api := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/unavailable" {
|
||||
http.Error(w, "offline", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, current.Load().(string))
|
||||
}))
|
||||
defer api.Close()
|
||||
r := New()
|
||||
var offline atomic.Bool
|
||||
r.resolver = lookupFunc(func(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if network != "ip4" {
|
||||
t.Errorf("unexpected family: %s", network)
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 10*time.Second {
|
||||
t.Error("DNS is outside the endpoint deadline")
|
||||
}
|
||||
if offline.Load() {
|
||||
return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true}
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil
|
||||
})
|
||||
r.clientFor = func(u *url.URL, ips []netip.Addr) *http.Client {
|
||||
client := newEndpointClient(u, ips)
|
||||
transport := client.Transport.(*http.Transport)
|
||||
transport.TLSClientConfig.RootCAs = api.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "tcp", api.Listener.Addr().String())
|
||||
}
|
||||
return client
|
||||
}
|
||||
endpoints := []string{"https://example.com/unavailable", "https://example.com/ip"}
|
||||
for _, step := range []struct {
|
||||
offline bool
|
||||
ip string
|
||||
}{{false, "1.1.1.1"}, {true, ""}, {false, "9.9.9.9"}} {
|
||||
offline.Store(step.offline)
|
||||
if step.ip != "" {
|
||||
current.Store(step.ip)
|
||||
}
|
||||
result := r.ResolveWithIPv6(context.Background(), endpoints, nil, false)
|
||||
if result.IPv4 != step.ip || (result.IPv4Err != nil) != step.offline {
|
||||
t.Fatalf("outage recovery: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPFamiliesResolveIndependently(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
started := make(chan string, 2)
|
||||
proceed := make(chan struct{})
|
||||
r.resolver = lookupFunc(func(ctx context.Context, network, _ string) ([]netip.Addr, error) {
|
||||
started <- network
|
||||
select {
|
||||
case <-proceed:
|
||||
return nil, errors.New("offline")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
done := make(chan Result, 1)
|
||||
go func() { done <- r.Resolve(ctx, []string{"https://example.com"}, []string{"https://example.com"}) }()
|
||||
families := map[string]bool{}
|
||||
for range 2 {
|
||||
select {
|
||||
case family := <-started:
|
||||
families[family] = true
|
||||
case <-ctx.Done():
|
||||
t.Fatal("family resolution blocked another family")
|
||||
}
|
||||
}
|
||||
close(proceed)
|
||||
result := <-done
|
||||
if !families["ip4"] || !families["ip6"] || result.IPv4Err == nil || result.IPv6Err == nil {
|
||||
t.Fatalf("unexpected family result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateEndpointStillRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
r.resolver = lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1"), netip.MustParseAddr("10.0.20.110")}, nil
|
||||
})
|
||||
for _, endpoint := range []string{"https://example.com/ip", "https://127.0.0.1/ip", "http://example.com", "https://user:secret@example.com", "https://example.com?token=secret", "https://example.com:8443"} {
|
||||
if _, _, err := r.parseAndResolve(context.Background(), endpoint, true); err == nil {
|
||||
t.Fatalf("unsafe endpoint accepted: %s", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,21 @@ type PiHoleCredential struct {
|
||||
Nonce []byte
|
||||
}
|
||||
|
||||
// SoftwareUpdateSettings contains the locally persisted update state. The
|
||||
// encrypted registry credential is returned only to trusted backend components and
|
||||
// is never passed to templates.
|
||||
type SoftwareUpdateSettings struct {
|
||||
RegistryUsername string
|
||||
TokenCiphertext []byte
|
||||
TokenNonce []byte
|
||||
LatestVersion string
|
||||
ReleaseURL string
|
||||
DesiredVersion string
|
||||
LastCheckedAt time.Time
|
||||
LastError string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
|
||||
// merely retained at Pi-hole after being removed from the active WebUI list.
|
||||
type PiHoleRecordLink struct {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package netresolve provides bounded DNS lookups for the public DynDNS services.
|
||||
package netresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
)
|
||||
|
||||
type lookup interface {
|
||||
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
system lookup
|
||||
fallbacks []lookup
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func New() *Resolver {
|
||||
r := &Resolver{system: net.DefaultResolver, timeout: 2 * time.Second}
|
||||
// Literal addresses keep this recovery path independent of Docker/Pi-hole
|
||||
// DNS. Both providers can also be reached from IPv6-only networks.
|
||||
for _, server := range []string{"1.1.1.1", "9.9.9.9", "2606:4700:4700::1111", "2620:fe::fe"} {
|
||||
r.fallbacks = append(r.fallbacks, &directResolver{server: server, resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(server, "53"))
|
||||
},
|
||||
}})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type directResolver struct {
|
||||
server string
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
func (r *directResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
started := time.Now()
|
||||
ips, err := r.resolver.LookupNetIP(ctx, network, host)
|
||||
// A canceled losing query is normal when another resolver already succeeded.
|
||||
if !errors.Is(ctx.Err(), context.Canceled) {
|
||||
diagnostics.Record(diagnostics.Event{Code: "dns_fallback_query", Target: diagnostics.Target(host), Family: network, DNSResolver: r.server, Failure: diagnostics.Failure(err), DurationMS: time.Since(started).Milliseconds()})
|
||||
}
|
||||
return ips, err
|
||||
}
|
||||
|
||||
func publicService(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSuffix(host, ".")) {
|
||||
case "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Resolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
addresses, err := r.system.LookupNetIP(lookupCtx, network, host)
|
||||
cancel()
|
||||
if err == nil || ctx.Err() != nil || !publicService(host) || !temporaryDNSFailure(err) {
|
||||
return addresses, err
|
||||
}
|
||||
|
||||
diagnostics.Record(diagnostics.Event{Code: "dns_fallback", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)})
|
||||
// Do not cache failures or prefer a previous fallback permanently. Every
|
||||
// lookup checks the configured resolver again after connectivity returns.
|
||||
fallbackCtx, stop := context.WithTimeout(ctx, r.timeout)
|
||||
defer stop()
|
||||
type result struct {
|
||||
addresses []netip.Addr
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(r.fallbacks))
|
||||
for _, fallback := range r.fallbacks {
|
||||
go func() {
|
||||
// An absolute name avoids sending local DNS search suffixes outside.
|
||||
addresses, err := fallback.LookupNetIP(fallbackCtx, network, strings.TrimSuffix(host, ".")+".")
|
||||
results <- result{addresses, err}
|
||||
}()
|
||||
}
|
||||
for range r.fallbacks {
|
||||
select {
|
||||
case result := <-results:
|
||||
if result.err == nil && len(result.addresses) > 0 {
|
||||
diagnostics.Record(diagnostics.Event{Code: "dns_fallback_ok", Target: diagnostics.Target(host), Family: network})
|
||||
return result.addresses, nil
|
||||
}
|
||||
case <-fallbackCtx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)})
|
||||
return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback timed out", err)
|
||||
}
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)})
|
||||
return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback unavailable", err)
|
||||
}
|
||||
|
||||
func temporaryDNSFailure(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return !dnsErr.IsNotFound && (dnsErr.IsTimeout || dnsErr.IsTemporary)
|
||||
}
|
||||
return errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// DialContext preserves ordinary resolution for custom API URLs and local hosts.
|
||||
func (r *Resolver) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil || !publicService(host) {
|
||||
return (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext(ctx, network, address)
|
||||
}
|
||||
family := "ip"
|
||||
if strings.HasSuffix(network, "4") {
|
||||
family = "ip4"
|
||||
} else if strings.HasSuffix(network, "6") {
|
||||
family = "ip6"
|
||||
}
|
||||
addresses, err := r.LookupNetIP(ctx, family, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DialAddresses(ctx, network, addresses, port)
|
||||
}
|
||||
|
||||
// DialAddresses connects only to the already resolved addresses. Sharing the
|
||||
// connect budget allows later addresses a chance even if the first one stalls.
|
||||
func DialAddresses(ctx context.Context, network string, addresses []netip.Addr, port string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
deadline, _ := ctx.Deadline()
|
||||
var lastErr error
|
||||
for index, address := range addresses {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptCtx, stop := context.WithTimeout(ctx, time.Until(deadline)/time.Duration(len(addresses)-index))
|
||||
conn, err := (&net.Dialer{KeepAlive: 30 * time.Second}).DialContext(attemptCtx, network, net.JoinHostPort(address.String(), port))
|
||||
stop()
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("host has no addresses")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package netresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
|
||||
func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return f(ctx, network, host)
|
||||
}
|
||||
|
||||
func TestDNSOutageFallbackAndReturnToSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
var healthy atomic.Bool
|
||||
var fallbacks atomic.Int32
|
||||
r := &Resolver{timeout: time.Second}
|
||||
r.system = lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if network != "ip4" || host != "api.ipify.org" {
|
||||
t.Errorf("unexpected query %s %s", network, host)
|
||||
}
|
||||
if healthy.Load() {
|
||||
return []netip.Addr{netip.MustParseAddr("1.0.0.1")}, nil
|
||||
}
|
||||
return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true}
|
||||
})
|
||||
r.fallbacks = []lookup{lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
fallbacks.Add(1)
|
||||
if network != "ip4" || host != "api.ipify.org." {
|
||||
t.Errorf("fallback must use absolute family-specific query: %s %s", network, host)
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil
|
||||
})}
|
||||
for _, recovered := range []bool{false, true} {
|
||||
healthy.Store(recovered)
|
||||
ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org")
|
||||
want := "1.1.1.1"
|
||||
if recovered {
|
||||
want = "1.0.0.1"
|
||||
}
|
||||
if err != nil || len(ips) != 1 || ips[0].String() != want {
|
||||
t.Fatalf("DNS recovery: %v %v", ips, err)
|
||||
}
|
||||
}
|
||||
if fallbacks.Load() != 1 {
|
||||
t.Fatal("system recovery did not stop fallback use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowDNSCannotBlockWorkingFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
slow := lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { <-ctx.Done(); return nil, ctx.Err() })
|
||||
r := &Resolver{system: slow, timeout: 20 * time.Millisecond, fallbacks: []lookup{slow, lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil
|
||||
})}}
|
||||
started := time.Now()
|
||||
ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org")
|
||||
if err != nil || len(ips) != 1 || time.Since(started) > time.Second {
|
||||
t.Fatalf("blocked fallback: %v %v", ips, err)
|
||||
}
|
||||
r.fallbacks = []lookup{slow}
|
||||
if _, err = r.LookupNetIP(context.Background(), "ip4", "api.ipify.org"); err == nil {
|
||||
t.Fatal("total outage was hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSFallbackDoesNotOverrideNegativeAnswersOrLocalNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
host string
|
||||
err error
|
||||
}{
|
||||
{"api.ipify.org", &net.DNSError{IsNotFound: true}},
|
||||
{"api.ipify.org", errors.New("permanent DNS error")},
|
||||
{"pi.hole", &net.DNSError{IsTemporary: true}},
|
||||
{"private.example", &net.DNSError{IsTemporary: true}},
|
||||
} {
|
||||
r := &Resolver{timeout: time.Second, system: lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { return nil, test.err }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
t.Error("unexpected external query")
|
||||
return nil, nil
|
||||
})}}
|
||||
if _, err := r.LookupNetIP(context.Background(), "ip4", test.host); !errors.Is(err, test.err) {
|
||||
t.Fatalf("original error lost: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledDNSDoesNotStartFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
r := &Resolver{timeout: time.Second, system: lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { return nil, ctx.Err() }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
t.Error("fallback after cancellation")
|
||||
return nil, nil
|
||||
})}}
|
||||
if _, err := r.LookupNetIP(ctx, "ip6", "api6.ipify.org"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialAddressesTriesNextAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
_, port, _ := net.SplitHostPort(listener.Addr().String())
|
||||
conn, err := DialAddresses(context.Background(), "tcp4", []netip.Addr{netip.MustParseAddr("127.0.0.2"), netip.MustParseAddr("127.0.0.1")}, port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
@@ -28,6 +28,8 @@ type APIError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) HTTPStatusCode() int { return e.Status }
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
message := strings.TrimSpace(e.Message)
|
||||
if message == "" {
|
||||
@@ -98,7 +100,7 @@ func (c *Client) BaseURL() string { return c.baseURL }
|
||||
|
||||
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
|
||||
var hosts []string
|
||||
err := c.withSession(ctx, password, func(sid string) error {
|
||||
err := c.withSession(ctx, password, func(ctx context.Context, sid string) error {
|
||||
var response struct {
|
||||
Config struct {
|
||||
DNS struct {
|
||||
@@ -116,13 +118,13 @@ func (c *Client) ListHosts(ctx context.Context, password string) ([]string, erro
|
||||
}
|
||||
|
||||
func (c *Client) AddHost(ctx context.Context, password, value string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.withSession(ctx, password, func(ctx context.Context, sid string) error {
|
||||
return c.addHost(ctx, sid, value)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.withSession(ctx, password, func(ctx context.Context, sid string) error {
|
||||
return c.deleteHost(ctx, sid, value)
|
||||
})
|
||||
}
|
||||
@@ -131,7 +133,7 @@ func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
||||
// Pi-hole session. Missing values are accepted so an interrupted deletion can
|
||||
// safely be retried.
|
||||
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.withSession(ctx, password, func(ctx context.Context, sid string) error {
|
||||
for _, value := range values {
|
||||
if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||
return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err)
|
||||
@@ -145,7 +147,7 @@ func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacemen
|
||||
if previous == replacement {
|
||||
return nil
|
||||
}
|
||||
return c.withSession(ctx, password, func(sid string) error {
|
||||
return c.withSession(ctx, password, func(ctx context.Context, sid string) error {
|
||||
if err := c.deleteHost(ctx, sid, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,7 +169,12 @@ func (c *Client) deleteHost(ctx context.Context, sid, value string) error {
|
||||
return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error {
|
||||
func (c *Client) withSession(ctx context.Context, password string, operation func(context.Context, string) error) error {
|
||||
// Authentication and the operation must finish before the web server's
|
||||
// write deadline, including when a Pi-hole disappears between requests.
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
defer c.http.CloseIdleConnections()
|
||||
sid, err := c.authenticate(ctx, password)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -177,7 +184,7 @@ func (c *Client) withSession(ctx context.Context, password string, operation fun
|
||||
defer cancel()
|
||||
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
|
||||
}()
|
||||
return operation(sid)
|
||||
return operation(ctx, sid)
|
||||
}
|
||||
|
||||
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
|
||||
|
||||
+204
-67
@@ -9,6 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
@@ -21,24 +22,51 @@ var (
|
||||
ErrDisabled = errors.New("the updater is disabled")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRetryInitial = 5 * time.Second
|
||||
defaultRetryMaximum = time.Minute
|
||||
)
|
||||
|
||||
type ipResolver interface {
|
||||
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
|
||||
ValidateEndpoint(ctx context.Context, raw string) error
|
||||
}
|
||||
|
||||
type Scheduler struct {
|
||||
store *store.Store
|
||||
provider *hetzner.Client
|
||||
resolver *ipresolve.Resolver
|
||||
resolver ipResolver
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
running atomic.Bool
|
||||
nextRun atomic.Int64
|
||||
wake chan struct{}
|
||||
changes chan struct{}
|
||||
done chan struct{}
|
||||
retryMin time.Duration
|
||||
retryMax time.Duration
|
||||
}
|
||||
|
||||
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
|
||||
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
|
||||
return &Scheduler{
|
||||
store: database,
|
||||
provider: provider,
|
||||
resolver: resolver,
|
||||
cipher: cipher,
|
||||
logger: logger,
|
||||
wake: make(chan struct{}, 1),
|
||||
changes: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
retryMin: defaultRetryInitial,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
defer close(s.done)
|
||||
defer s.nextRun.Store(0)
|
||||
consecutiveFailures := 0
|
||||
var failureSince, networkLoggedAt time.Time
|
||||
for {
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -60,11 +88,36 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
}
|
||||
retryAfter := time.Duration(0)
|
||||
if settings.UpdaterEnabled {
|
||||
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
||||
s.logger.Warn("scheduled update run failed", "error", runErr)
|
||||
_, runErr := s.Run(ctx)
|
||||
if runErr == nil {
|
||||
if consecutiveFailures > 0 {
|
||||
diagnostics.Record(diagnostics.Event{Code: "update_recovered", Target: "updater", Attempt: consecutiveFailures, DurationMS: time.Since(failureSince).Milliseconds()})
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
} else if errors.Is(runErr, ErrRunInProgress) {
|
||||
retryAfter = time.Second
|
||||
} else if !errors.Is(runErr, ErrDisabled) {
|
||||
consecutiveFailures++
|
||||
if consecutiveFailures == 1 {
|
||||
failureSince = time.Now()
|
||||
}
|
||||
retryAfter = s.retryDelay(consecutiveFailures, wait)
|
||||
event := diagnostics.Event{Code: "update_failed", Target: "updater", Failure: diagnostics.Failure(runErr), Attempt: consecutiveFailures, RetrySeconds: int64(retryAfter / time.Second)}
|
||||
if consecutiveFailures == 1 || time.Since(networkLoggedAt) >= 15*time.Minute {
|
||||
event.Network = diagnostics.CurrentNetwork()
|
||||
networkLoggedAt = time.Now()
|
||||
}
|
||||
diagnostics.Record(event)
|
||||
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
|
||||
}
|
||||
} else {
|
||||
consecutiveFailures = 0
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -73,9 +126,8 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
if retryAfter > 0 {
|
||||
wait = retryAfter
|
||||
}
|
||||
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
||||
timer := time.NewTimer(wait)
|
||||
@@ -96,6 +148,34 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
|
||||
minimum, maximum := s.retryMin, scheduledWait
|
||||
if minimum <= 0 {
|
||||
minimum = defaultRetryInitial
|
||||
}
|
||||
retryMax := s.retryMax
|
||||
if retryMax <= 0 {
|
||||
retryMax = defaultRetryMaximum
|
||||
}
|
||||
if retryMax < maximum {
|
||||
maximum = retryMax
|
||||
}
|
||||
if maximum < minimum {
|
||||
maximum = minimum
|
||||
}
|
||||
wait := minimum
|
||||
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
|
||||
if wait > maximum/2 {
|
||||
return maximum
|
||||
}
|
||||
wait *= 2
|
||||
}
|
||||
if wait > maximum {
|
||||
return maximum
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func (s *Scheduler) Trigger() {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
@@ -114,11 +194,40 @@ func (s *Scheduler) NextRunAt() time.Time {
|
||||
return time.Unix(value, 0).UTC()
|
||||
}
|
||||
|
||||
// LockChanges serializes a complete WebUI mutation (provider and database)
|
||||
// with updater snapshots. Callers must hold it until the local change is saved.
|
||||
func (s *Scheduler) LockChanges(ctx context.Context) (func(), error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case s.changes <- struct{}{}:
|
||||
return func() { <-s.changes }, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Wait(ctx context.Context) error {
|
||||
select {
|
||||
case <-s.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return "", ErrRunInProgress
|
||||
}
|
||||
defer s.running.Store(false)
|
||||
select {
|
||||
case s.changes <- struct{}{}:
|
||||
defer func() { <-s.changes }()
|
||||
default:
|
||||
return "", ErrRunInProgress
|
||||
}
|
||||
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -131,22 +240,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
|
||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||
message := "Keine öffentliche IP ermittelt"
|
||||
if ipResult.IPv4Err != nil {
|
||||
message += ": " + ipResult.IPv4Err.Error()
|
||||
}
|
||||
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
|
||||
}
|
||||
|
||||
targets, err := s.store.ListManagedTargets(ctx)
|
||||
if err != nil {
|
||||
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
|
||||
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
|
||||
}
|
||||
needsIPv6 := false
|
||||
for _, target := range targets {
|
||||
if target.Target.UpdateIPv6 {
|
||||
needsIPv6 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
|
||||
var resolutionErr error
|
||||
if ipResult.IPv4Err != nil {
|
||||
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
|
||||
}
|
||||
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
|
||||
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
|
||||
}
|
||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||
message := "Keine öffentliche IP ermittelt"
|
||||
if resolutionErr == nil {
|
||||
resolutionErr = errors.New(message)
|
||||
} else {
|
||||
message += ": " + resolutionErr.Error()
|
||||
}
|
||||
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
summary := "Keine aktiven DynDNS-Ziele"
|
||||
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
status := "ok"
|
||||
if resolutionErr != nil {
|
||||
status = "partial"
|
||||
summary += "; eine IP-Familie war nicht verfügbar"
|
||||
}
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
|
||||
}
|
||||
|
||||
grouped := make(map[int64][]store.ManagedTarget)
|
||||
@@ -160,6 +290,7 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
}
|
||||
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
|
||||
failed += len(group)
|
||||
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", RunID: runID, Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
|
||||
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
|
||||
for _, target := range group {
|
||||
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
|
||||
@@ -172,21 +303,31 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
status = "partial"
|
||||
}
|
||||
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
||||
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
|
||||
if resolutionErr != nil {
|
||||
summary += "; eine IP-Familie war nicht verfügbar"
|
||||
if status == "ok" {
|
||||
status = "partial"
|
||||
}
|
||||
}
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
var runErr error
|
||||
if failed > 0 {
|
||||
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
|
||||
}
|
||||
runErr = errors.Join(runErr, resolutionErr)
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
|
||||
}
|
||||
|
||||
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
|
||||
_ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary)
|
||||
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
|
||||
if status != "ok" {
|
||||
_ = s.store.AddAudit(ctx, "update."+status, summary)
|
||||
// Persist cancellation too; using the canceled run context leaves history
|
||||
// stuck at "running". Keep cleanup bounded during shutdown.
|
||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.CompleteUpdateRun(finishCtx, runID, status, ipv4, ipv6, summary); err != nil {
|
||||
s.logger.Error("persist update result", "run_id", runID, "error", err)
|
||||
runErr = errors.Join(runErr, fmt.Errorf("Updater-Ergebnis konnte nicht gespeichert werden: %w", err))
|
||||
status = "error"
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "update_run_finished", Target: "updater", RunID: runID, Status: status, Failure: diagnostics.Failure(runErr), HTTPStatus: diagnostics.HTTPStatus(runErr)})
|
||||
return summary, runErr
|
||||
}
|
||||
|
||||
@@ -208,46 +349,53 @@ func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.Managed
|
||||
}
|
||||
for _, item := range targets {
|
||||
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
|
||||
if targetErr != nil {
|
||||
*failed = *failed + 1
|
||||
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false)
|
||||
continue
|
||||
}
|
||||
if targetUpdated {
|
||||
*updated = *updated + 1
|
||||
}
|
||||
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated)
|
||||
if targetErr != nil {
|
||||
*failed = *failed + 1
|
||||
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", TargetID: item.Target.ID, ActionID: diagnostics.ActionID(targetErr), Failure: diagnostics.Failure(targetErr), HTTPStatus: diagnostics.HTTPStatus(targetErr)})
|
||||
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), targetUpdated)
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.store.MarkZoneSynced(ctx, zone.ID, "")
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) {
|
||||
var states []string
|
||||
var familyErrors []error
|
||||
updated := false
|
||||
if target.UpdateIPv4 {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
for _, family := range []struct {
|
||||
enabled bool
|
||||
recordType, ip string
|
||||
}{
|
||||
{target.UpdateIPv4, "A", ipv4},
|
||||
{target.UpdateIPv6, "AAAA", ipv6},
|
||||
} {
|
||||
if !family.enabled {
|
||||
continue
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if target.UpdateIPv6 {
|
||||
if !ipv6Enabled {
|
||||
if family.recordType == "AAAA" && !ipv6Enabled {
|
||||
states = append(states, "AAAA global deaktiviert")
|
||||
continue
|
||||
}
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, family.recordType)], family.recordType, family.ip)
|
||||
updated = updated || changed
|
||||
if err != nil {
|
||||
familyErrors = append(familyErrors, fmt.Errorf("%s: %w", family.recordType, err))
|
||||
states = append(states, family.recordType+" Fehler")
|
||||
} else {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return false, "Deaktivierte IP-Familien", nil
|
||||
}
|
||||
return updated, strings.Join(states, " · "), nil
|
||||
return updated, strings.Join(states, " · "), errors.Join(familyErrors...)
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
|
||||
@@ -281,7 +429,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
|
||||
if current.Value == ip {
|
||||
if current.Comment != target.Description {
|
||||
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
return ttlChanged, "", err
|
||||
}
|
||||
status := recordType + " Kommentar synchronisiert"
|
||||
if ttlChanged {
|
||||
@@ -295,7 +443,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
|
||||
return false, recordType + " unverändert", nil
|
||||
}
|
||||
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
|
||||
return false, "", err
|
||||
return ttlChanged, "", err
|
||||
}
|
||||
status := recordType + " aktualisiert"
|
||||
if ttlChanged {
|
||||
@@ -472,28 +620,12 @@ func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (
|
||||
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
|
||||
}
|
||||
}
|
||||
updated := false
|
||||
var states []string
|
||||
if target.UpdateIPv4 && target.StaticIPv4 != "" {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if target.UpdateIPv6 && target.StaticIPv6 != "" {
|
||||
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
updated = updated || changed
|
||||
states = append(states, state)
|
||||
}
|
||||
if len(states) == 0 {
|
||||
target.UpdateIPv4 = target.UpdateIPv4 && target.StaticIPv4 != ""
|
||||
target.UpdateIPv6 = target.UpdateIPv6 && target.StaticIPv6 != ""
|
||||
if !target.UpdateIPv4 && !target.UpdateIPv6 {
|
||||
return false, "Keine feste IP eingetragen", nil
|
||||
}
|
||||
return updated, strings.Join(states, " · "), nil
|
||||
return s.syncTarget(ctx, token, zone, target, byKey, target.StaticIPv4, target.StaticIPv6, true)
|
||||
}
|
||||
|
||||
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
|
||||
@@ -780,6 +912,11 @@ func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, st
|
||||
}
|
||||
|
||||
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
if len(endpoints) > 8 {
|
||||
return errors.New("höchstens acht IP-Endpoints pro Familie erlaubt")
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
|
||||
return fmt.Errorf("%s: %w", endpoint, err)
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +23,205 @@ import (
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
type resolverFunc func(context.Context) ipresolve.Result
|
||||
|
||||
func (f resolverFunc) ResolveWithIPv6(ctx context.Context, _, _ []string, _ bool) ipresolve.Result {
|
||||
return f(ctx)
|
||||
}
|
||||
func (f resolverFunc) ValidateEndpoint(context.Context, string) error { return nil }
|
||||
|
||||
func TestOneFailedFamilyDoesNotBlockTheOther(t *testing.T) {
|
||||
for _, protectedFamily := range []string{"A", "AAAA"} {
|
||||
t.Run(protectedFamily, func(t *testing.T) {
|
||||
var writes atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/"+protectedFamily+"/") {
|
||||
t.Error("wrote protected family")
|
||||
}
|
||||
writes.Add(1)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
ttl := 60
|
||||
rrsets := map[string]hetzner.RRSet{}
|
||||
for _, family := range []string{"A", "AAAA"} {
|
||||
rrset := hetzner.RRSet{Name: "home", Type: family, TTL: &ttl, Records: []hetzner.Record{{Value: "old"}}}
|
||||
rrset.Protection.Change = family == protectedFamily
|
||||
rrsets[rrKey("home", family)] = rrset
|
||||
}
|
||||
schedule := New(nil, hetzner.New(api.URL), nil, nil, nil)
|
||||
changed, status, err := schedule.syncTarget(context.Background(), "token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", TTL: 60, UpdateIPv4: true, UpdateIPv6: true}, rrsets, "198.51.100.42", "2001:db8::42", true)
|
||||
if !changed || err == nil || !strings.Contains(err.Error(), protectedFamily+":") || !strings.Contains(status, "aktualisiert") || writes.Load() != 1 {
|
||||
t.Fatalf("partial update lost: changed=%v status=%q err=%v writes=%d", changed, status, err, writes.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistsConfirmedPartialTargetChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
cipher, err := secure.LoadOrCreateCipher(filepath.Join(dir, "secrets", "key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.org")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.org", Active: true, UpdateIPv4: true, UpdateIPv6: true, TTL: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, 300, true, true, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var writes atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"protection":{"change":true},"records":[{"value":"198.51.100.1"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::1"}]}]}`))
|
||||
} else {
|
||||
if r.URL.Path != "/zones/123/rrsets/home/AAAA/actions/set_records" {
|
||||
t.Errorf("unexpected write: %s", r.URL.Path)
|
||||
}
|
||||
writes.Add(1)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
worker := New(database, hetzner.New(api.URL), nil, cipher, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
worker.resolver = resolverFunc(func(context.Context) ipresolve.Result {
|
||||
return ipresolve.Result{IPv4: "198.51.100.42", IPv6: "2001:db8::42"}
|
||||
})
|
||||
summary, err := worker.Run(ctx)
|
||||
if err == nil || summary != "1 Ziel(e) aktualisiert, 1 Fehler" || writes.Load() != 1 {
|
||||
t.Fatalf("incorrect partial result: %q %v writes=%d", summary, err, writes.Load())
|
||||
}
|
||||
target, err = database.GetTarget(ctx, target.ID)
|
||||
if err != nil || target.LastUpdatedAt.IsZero() || target.LastError == "" {
|
||||
t.Fatalf("confirmed change or error lost: %#v %v", target, err)
|
||||
}
|
||||
runs, err := database.RecentRuns(ctx, 1)
|
||||
if err != nil || len(runs) != 1 || runs[0].Status != "partial" {
|
||||
t.Fatalf("wrong partial run status: %#v %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledRunFinishesHistoryAndReleasesWorker(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { cancel(); return ipresolve.Result{IPv4Err: context.Canceled} })
|
||||
if _, err := schedule.Run(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("lost cancellation: %v", err)
|
||||
}
|
||||
runs, err := database.RecentRuns(context.Background(), 10)
|
||||
if err != nil || len(runs) != 1 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || schedule.IsRunning() {
|
||||
t.Fatalf("canceled run stuck: %#v err=%v", runs, err)
|
||||
}
|
||||
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { return ipresolve.Result{IPv4: "198.51.100.42"} })
|
||||
if _, err := schedule.Run(context.Background()); err != nil {
|
||||
t.Fatalf("worker did not recover: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSChangesAndUpdaterCannotOverlap(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
unlock, err := schedule.LockChanges(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := schedule.Run(context.Background()); !errors.Is(err, ErrRunInProgress) {
|
||||
t.Fatalf("updater entered active edit: %v", err)
|
||||
}
|
||||
unlock()
|
||||
entered := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
schedule.resolver = resolverFunc(func(ctx context.Context) ipresolve.Result {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
return ipresolve.Result{IPv4Err: ctx.Err()}
|
||||
})
|
||||
schedule.Start(ctx)
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker did not start")
|
||||
}
|
||||
waitCtx, stop := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer stop()
|
||||
if release, err := schedule.LockChanges(waitCtx); err == nil {
|
||||
release()
|
||||
t.Fatal("edit entered active updater")
|
||||
}
|
||||
cancel()
|
||||
shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), time.Second)
|
||||
defer stopShutdown()
|
||||
if err := schedule.Wait(shutdownCtx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release, err := schedule.LockChanges(shutdownCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("shutdown left lock held: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
type retryResolver struct {
|
||||
calls atomic.Int32
|
||||
recovered chan struct{}
|
||||
}
|
||||
|
||||
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
|
||||
call := r.calls.Add(1)
|
||||
if call == 1 {
|
||||
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
|
||||
}
|
||||
if call == 2 {
|
||||
close(r.recovered)
|
||||
}
|
||||
return ipresolve.Result{IPv4: "198.51.100.42"}
|
||||
}
|
||||
|
||||
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -164,6 +366,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver := &retryResolver{recovered: make(chan struct{})}
|
||||
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
schedule.resolver = resolver
|
||||
schedule.retryMin = 5 * time.Millisecond
|
||||
schedule.retryMax = 20 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
schedule.Start(ctx)
|
||||
|
||||
select {
|
||||
case <-resolver.recovered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduler did not retry the failed startup resolution")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
settings, err = database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.LastIPv4 == "198.51.100.42" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
runs, err := database.RecentRuns(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
|
||||
t.Fatalf("unexpected startup retry history: %#v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{retryMin: 5 * time.Second}
|
||||
tests := []struct {
|
||||
failures int
|
||||
want time.Duration
|
||||
}{
|
||||
{failures: 1, want: 5 * time.Second},
|
||||
{failures: 2, want: 10 * time.Second},
|
||||
{failures: 3, want: 20 * time.Second},
|
||||
{failures: 4, want: 40 * time.Second},
|
||||
{failures: 5, want: time.Minute},
|
||||
{failures: 20, want: time.Minute},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{}
|
||||
@@ -201,6 +478,7 @@ func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
|
||||
}
|
||||
changes <- body.TTL
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -264,6 +542,7 @@ func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -329,6 +608,7 @@ func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
|
||||
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
|
||||
actions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -397,7 +677,8 @@ func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
@@ -478,7 +759,8 @@ func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
|
||||
return
|
||||
}
|
||||
deletions <- r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
||||
default:
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
@@ -21,6 +22,14 @@ const (
|
||||
argonTime = 3
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
|
||||
PasswordMinLength = 15
|
||||
PasswordMaxLength = 128
|
||||
|
||||
// PasswordTimingHash is a non-secret, precomputed Argon2id hash used only
|
||||
// to equalize failed-login work when a username does not exist. Keeping it
|
||||
// static avoids allocating the full Argon2 workspace during every startup.
|
||||
PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q"
|
||||
)
|
||||
|
||||
type Cipher struct {
|
||||
@@ -28,6 +37,16 @@ type Cipher struct {
|
||||
}
|
||||
|
||||
func LoadCipher(path string) (*Cipher, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect master key: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("master key must be a regular file")
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("secure master key: %w", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read master key: %w", err)
|
||||
@@ -38,6 +57,17 @@ func LoadCipher(path string) (*Cipher, error) {
|
||||
// LoadOrCreateCipher loads an existing master key or creates one on first start.
|
||||
// The key must live on a persistent volume separate from the application data.
|
||||
func LoadOrCreateCipher(path string) (*Cipher, error) {
|
||||
directory := filepath.Dir(path)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create master key directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("master key directory must be a regular directory")
|
||||
}
|
||||
if err := os.Chmod(directory, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("secure master key directory: %w", err)
|
||||
}
|
||||
cipher, err := LoadCipher(path)
|
||||
if err == nil {
|
||||
return cipher, nil
|
||||
@@ -46,9 +76,6 @@ func LoadOrCreateCipher(path string) (*Cipher, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create master key directory: %w", err)
|
||||
}
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("create master key: %w", err)
|
||||
@@ -129,8 +156,30 @@ func HashToken(token string) string {
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 12 {
|
||||
return "", errors.New("password must contain at least 12 characters")
|
||||
return hashPassword(password, PasswordMinLength)
|
||||
}
|
||||
|
||||
// HashProvisionedPassword hashes the initial password chosen by an
|
||||
// administrator when creating another account. Product policy deliberately
|
||||
// leaves its length to that administrator while still rejecting empty,
|
||||
// overlong, or invalid UTF-8 input.
|
||||
func HashProvisionedPassword(password string) (string, error) {
|
||||
return hashPassword(password, 1)
|
||||
}
|
||||
|
||||
func hashPassword(password string, minimumLength int) (string, error) {
|
||||
if !utf8.ValidString(password) {
|
||||
return "", errors.New("Passwort muss gültiger UTF-8-Text sein")
|
||||
}
|
||||
length := utf8.RuneCountInString(password)
|
||||
if length < minimumLength {
|
||||
if minimumLength == 1 {
|
||||
return "", errors.New("Passwort darf nicht leer sein")
|
||||
}
|
||||
return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength)
|
||||
}
|
||||
if length > PasswordMaxLength {
|
||||
return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength)
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
@@ -142,6 +191,9 @@ func HashPassword(password string) (string, error) {
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
|
||||
return false
|
||||
@@ -152,12 +204,18 @@ func VerifyPassword(encoded, password string) bool {
|
||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
||||
return false
|
||||
}
|
||||
if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads {
|
||||
return false
|
||||
}
|
||||
if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
if err != nil || len(salt) != 16 {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
if err != nil || len(want) != argonKeyLen {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,13 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
|
||||
if permissions := info.Mode().Perm(); permissions != 0o600 {
|
||||
t.Fatalf("master key permissions = %o, want 600", permissions)
|
||||
}
|
||||
directoryInfo, err := os.Stat(filepath.Dir(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if permissions := directoryInfo.Mode().Perm(); permissions != 0o700 {
|
||||
t.Fatalf("master key directory permissions = %o, want 700", permissions)
|
||||
}
|
||||
ciphertext, nonce, err := first.Encrypt("persistent token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -69,6 +77,23 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCipherRejectsSymlinkedMasterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "target")
|
||||
key := make([]byte, 32)
|
||||
if err := os.WriteFile(target, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(directory, "master_key")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadCipher(link); err == nil {
|
||||
t.Fatal("symlinked master key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := HashPassword("a secure password")
|
||||
@@ -81,7 +106,34 @@ func TestPasswordHash(t *testing.T) {
|
||||
if VerifyPassword(hash, "wrong password") {
|
||||
t.Fatal("wrong password verified")
|
||||
}
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
if VerifyPassword(strings.Replace(hash, "m=65536", "m=4294967295", 1), "a secure password") {
|
||||
t.Fatal("password hash with unsafe Argon2 parameters was accepted")
|
||||
}
|
||||
if _, err := HashPassword(strings.Repeat("a", PasswordMinLength-1)); err == nil {
|
||||
t.Fatal("short password was accepted")
|
||||
}
|
||||
if _, err := HashPassword(strings.Repeat("a", PasswordMaxLength+1)); err == nil {
|
||||
t.Fatal("overlong password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordTimingHashMatchesCurrentArgonParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !VerifyPassword(PasswordTimingHash, "login timing normalization only") {
|
||||
t.Fatal("precomputed login timing hash no longer matches the configured Argon2 parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionedPasswordHasNoMinimumLengthBeyondNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := HashProvisionedPassword("x")
|
||||
if err != nil || !VerifyPassword(hash, "x") {
|
||||
t.Fatalf("short provisioned password was not hashed correctly: %v", err)
|
||||
}
|
||||
if _, err := HashProvisionedPassword(""); err == nil {
|
||||
t.Fatal("empty provisioned password was accepted")
|
||||
}
|
||||
if _, err := HashProvisionedPassword(strings.Repeat("x", PasswordMaxLength+1)); err == nil {
|
||||
t.Fatal("overlong provisioned password was accepted")
|
||||
}
|
||||
}
|
||||
+238
-9
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
)
|
||||
@@ -21,7 +22,12 @@ type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||
var (
|
||||
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
|
||||
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
|
||||
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
|
||||
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||
)
|
||||
|
||||
type ManagedTarget struct {
|
||||
Target model.Target
|
||||
@@ -116,10 +122,20 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
if err := s.applyMigrationV10(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.applyMigrationV11(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
|
||||
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
|
||||
return err
|
||||
}
|
||||
var appliedMigrations int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&appliedMigrations); err != nil {
|
||||
return err
|
||||
}
|
||||
if appliedMigrations != buildinfo.CurrentSchemaVersion {
|
||||
return fmt.Errorf("database migration chain is incomplete: have %d of %d", appliedMigrations, buildinfo.CurrentSchemaVersion)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -242,6 +258,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
|
||||
return fmt.Errorf("apply schema v11: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
||||
@@ -453,6 +486,22 @@ const schemaV10 = `
|
||||
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||
`
|
||||
|
||||
const schemaV11 = `
|
||||
CREATE TABLE software_update_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
registry_username TEXT NOT NULL DEFAULT '',
|
||||
token_ciphertext BLOB NOT NULL DEFAULT X'',
|
||||
token_nonce BLOB NOT NULL DEFAULT X'',
|
||||
latest_version TEXT NOT NULL DEFAULT '',
|
||||
release_url TEXT NOT NULL DEFAULT '',
|
||||
desired_version TEXT NOT NULL DEFAULT '',
|
||||
last_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
|
||||
`
|
||||
|
||||
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
||||
@@ -460,8 +509,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
|
||||
return err
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
|
||||
SELECT ?, ?, ?, 'admin', 1, ?, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created != 1 {
|
||||
return ErrAdminAlreadyConfigured
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
|
||||
@@ -481,6 +542,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
|
||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
|
||||
}
|
||||
@@ -531,18 +611,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var currentRole string
|
||||
var currentActive int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(¤tRole, ¤tActive); err != nil {
|
||||
return err
|
||||
}
|
||||
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
|
||||
var activeAdmins int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeAdmins <= 1 {
|
||||
return ErrLastActiveAdmin
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
// Administrators receive full access from their role. Removing stale member
|
||||
// grants prevents hidden permissions from resurfacing after a later demotion.
|
||||
if role == "admin" {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if currentRole != role || intBool(currentActive) != active {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
|
||||
return s.UpdatePassword(ctx, userID, passwordHash)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
|
||||
@@ -587,6 +715,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var role string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "admin" {
|
||||
return ErrAdminAccessImmutable
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -702,8 +837,48 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
|
||||
var settings model.SoftwareUpdateSettings
|
||||
var checkedAt, updatedAt int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
|
||||
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
|
||||
)
|
||||
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordSoftwareUpdateFailure intentionally retains the last known release so
|
||||
// a temporary Gitea outage never removes an already visible update reminder.
|
||||
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearDesiredSoftwareVersion(ctx context.Context, expected string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = '', updated_at = ? WHERE id = 1 AND desired_version = ?`, now(), expected)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
|
||||
_, err := s.db.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)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1095,8 +1270,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
|
||||
}
|
||||
|
||||
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
|
||||
return err
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
|
||||
@@ -1250,6 +1435,50 @@ func (s *Store) FinishUpdateRun(ctx context.Context, id int64, status, ipv4, ipv
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteUpdateRun commits history, dashboard state and audit together.
|
||||
func (s *Store) CompleteUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6), last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status); err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "ok" {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), "update."+status, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RecoverInterruptedRuns is called once at process startup, before workers start.
|
||||
func (s *Store) RecoverInterruptedRuns(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = 'error', summary = 'Durch Neustart unterbrochen; erneute Prüfung folgt automatisch' WHERE status = 'running'`, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = 'error' WHERE id = 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,16 +2,103 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
)
|
||||
|
||||
func TestRunCompletionIsAtomicAndInterruptedRunsRecover(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
id, err := database.CreateUpdateRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.db.Exec(`CREATE TRIGGER reject_state BEFORE UPDATE ON settings BEGIN SELECT RAISE(FAIL, 'storage failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err == nil {
|
||||
t.Fatal("storage failure silently ignored")
|
||||
}
|
||||
runs, err := database.RecentRuns(ctx, 1)
|
||||
if err != nil || runs[0].Status != "running" {
|
||||
t.Fatalf("partially committed run: %#v %v", runs, err)
|
||||
}
|
||||
if _, err := database.db.Exec(`DROP TRIGGER reject_state`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.CreateUpdateRun(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.RecoverInterruptedRuns(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err = database.RecentRuns(ctx, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || runs[1].Status != "ok" {
|
||||
t.Fatalf("incorrect restart recovery: %#v %v", runs, err)
|
||||
}
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil || settings.LastIPv4 != "198.51.100.42" || settings.LastRunStatus != "error" {
|
||||
t.Fatalf("restart corrupted state: %#v %v", settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
databasePath := filepath.Join(t.TempDir(), "legacy.db")
|
||||
legacy, err := sql.Open("sqlite3", databasePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := legacy.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := legacy.ExecContext(ctx, schemaV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := legacy.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := legacy.ExecContext(ctx, `INSERT INTO admins(id, password_hash, created_at, updated_at) VALUES(1, 'legacy-password-hash', 1, 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := legacy.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err := Open(ctx, databasePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
var applied int
|
||||
if err := database.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&applied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != buildinfo.CurrentSchemaVersion {
|
||||
t.Fatalf("direct migration applied %d of %d schemas", applied, buildinfo.CurrentSchemaVersion)
|
||||
}
|
||||
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||
if err != nil || admin.Role != "admin" || !admin.Active {
|
||||
t.Fatalf("legacy administrator was not retained: %#v, %v", admin, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -38,6 +125,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) {
|
||||
t.Fatalf("second first-run administrator was not rejected: %v", err)
|
||||
}
|
||||
if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil {
|
||||
t.Fatal("rejected second administrator was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -168,6 +275,114 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hash, err := secure.HashPassword("a secure administrator password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
|
||||
t.Fatalf("last active administrator could be removed: %v", err)
|
||||
}
|
||||
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||
t.Fatalf("administrator accepted editable individual grants: %v", err)
|
||||
}
|
||||
|
||||
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
|
||||
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
|
||||
}
|
||||
grants, err := database.ListAccessGrants(ctx, member.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grants) != 0 {
|
||||
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
|
||||
}
|
||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||
t.Fatalf("promoted administrator accepted individual grants: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkedAt := time.Now().UTC().Truncate(time.Second)
|
||||
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
|
||||
t.Fatalf("software update state was not persisted safely: %#v", settings)
|
||||
}
|
||||
if err := database.ClearDesiredSoftwareVersion(ctx, "v9.9.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err = database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil || settings.DesiredVersion != "v1.2.3" {
|
||||
t.Fatalf("unrelated update cleared the pending target: %#v, %v", settings, err)
|
||||
}
|
||||
if err := database.ClearDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err = database.GetSoftwareUpdateSettings(ctx)
|
||||
if err != nil || settings.DesiredVersion != "" {
|
||||
t.Fatalf("matching completed target remained pending: %#v, %v", settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -268,6 +483,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
|
||||
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
|
||||
}
|
||||
if err := database.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.DisableIPv6ForTargets(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -275,6 +493,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
|
||||
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
|
||||
}
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil || settings.LastIPv6 != "" {
|
||||
t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err)
|
||||
}
|
||||
if err := database.DeleteTarget(ctx, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -335,6 +557,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
|
||||
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateRunState(ctx, "", "", "error"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err = database.GetSettings(ctx)
|
||||
if err != nil || settings.LastIPv4 != "198.51.100.1" {
|
||||
t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err)
|
||||
}
|
||||
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
|
||||
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
type Runner interface {
|
||||
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
|
||||
}
|
||||
|
||||
type Controller struct {
|
||||
runner Runner
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
status updateprotocol.Status
|
||||
}
|
||||
|
||||
func NewController(runner Runner, logger *slog.Logger) *Controller {
|
||||
return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
|
||||
}
|
||||
|
||||
func agentStatus(phase string) updateprotocol.Status {
|
||||
return updateprotocol.Status{
|
||||
Phase: phase,
|
||||
AnonymousPull: true,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
AgentVersion: buildinfo.Version,
|
||||
Protocol: buildinfo.UpdateProtocolVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/status", c.statusHandler)
|
||||
mux.HandleFunc("POST /v1/update", c.updateHandler)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(c.Status())
|
||||
}
|
||||
|
||||
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
defer r.Body.Close()
|
||||
var request updateprotocol.Request
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
request.Version = strings.TrimSpace(request.Version)
|
||||
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
||||
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
||||
anonymous := request.RegistryUsername == "" && request.RegistryToken == ""
|
||||
credentialValid := request.RegistryUsername != "" && len(request.RegistryUsername) <= 128 && len(request.RegistryToken) >= 8 && len(request.RegistryToken) <= 1024
|
||||
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || (!anonymous && !credentialValid) {
|
||||
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !c.begin(request.Version) {
|
||||
http.Error(w, "update already running", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
|
||||
go c.run(request)
|
||||
}
|
||||
|
||||
func (c *Controller) begin(version string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.status.Busy() {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion, AnonymousPull: true}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Controller) run(request updateprotocol.Request) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
err := c.runner.Update(ctx, request, c.setPhase)
|
||||
registryCredential := request.RegistryToken
|
||||
request.RegistryToken = ""
|
||||
if err != nil {
|
||||
message := publicAgentError(err)
|
||||
c.setPhase(updateprotocol.PhaseFailed, message)
|
||||
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryCredential))
|
||||
return
|
||||
}
|
||||
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
|
||||
c.logger.Info("software update completed", "version", request.Version)
|
||||
}
|
||||
|
||||
func redactSecret(err error, secret string) error {
|
||||
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
|
||||
}
|
||||
|
||||
func (c *Controller) setPhase(phase, message string) {
|
||||
c.mu.Lock()
|
||||
c.status.Phase = phase
|
||||
c.status.Message = message
|
||||
c.status.UpdatedAt = time.Now().UTC()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Controller) Status() updateprotocol.Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func publicAgentError(err error) string {
|
||||
if err == nil {
|
||||
return "Update fehlgeschlagen"
|
||||
}
|
||||
var public interface{ PublicMessage() string }
|
||||
if errors.As(err, &public) {
|
||||
return public.PublicMessage()
|
||||
}
|
||||
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sensitive-registry-token"
|
||||
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
|
||||
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
|
||||
t.Fatalf("credential was not redacted: %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingRunner struct {
|
||||
started chan updateprotocol.Request
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
|
||||
progress(updateprotocol.PhasePulling, "image is being pulled")
|
||||
r.started <- request
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-r.release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
|
||||
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
handler := controller.Handler()
|
||||
|
||||
invalid := httptest.NewRecorder()
|
||||
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
|
||||
if invalid.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
|
||||
}
|
||||
trailing := httptest.NewRecorder()
|
||||
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
|
||||
if trailing.Code != http.StatusBadRequest {
|
||||
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
|
||||
}
|
||||
invalidCurrent := httptest.NewRecorder()
|
||||
handler.ServeHTTP(invalidCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"1.3","registry_username":"user","registry_token":"secret-token"}`)))
|
||||
if invalidCurrent.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid current version accepted: status=%d", invalidCurrent.Code)
|
||||
}
|
||||
missingCurrent := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missingCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","registry_username":"user","registry_token":"secret-token"}`)))
|
||||
if missingCurrent.Code != http.StatusBadRequest {
|
||||
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
|
||||
}
|
||||
|
||||
const secret = " registry-password-must-not-be-trimmed "
|
||||
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||
accepted := httptest.NewRecorder()
|
||||
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||
if accepted.Code != http.StatusAccepted {
|
||||
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
|
||||
}
|
||||
select {
|
||||
case request := <-runner.started:
|
||||
if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret {
|
||||
t.Fatalf("runner received an altered request: %#v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("update runner did not start")
|
||||
}
|
||||
|
||||
busy := httptest.NewRecorder()
|
||||
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||
if busy.Code != http.StatusConflict {
|
||||
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
|
||||
}
|
||||
status := httptest.NewRecorder()
|
||||
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
||||
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) || !strings.Contains(status.Body.String(), `"protocol_version":2`) {
|
||||
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
|
||||
}
|
||||
|
||||
close(runner.release)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if controller.Status().Phase != updateprotocol.PhaseComplete {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
|
||||
|
||||
const (
|
||||
labelImageVersion = "org.opencontainers.image.version"
|
||||
labelMinimumVersion = "de.j-mahler.domain-control.minimum-update-version"
|
||||
labelMinimumAgent = "de.j-mahler.domain-control.minimum-agent-protocol"
|
||||
labelRuntimeContract = "de.j-mahler.domain-control.runtime-contract"
|
||||
labelDatabaseContract = "de.j-mahler.domain-control.database-contract"
|
||||
labelSchemaVersion = "de.j-mahler.domain-control.schema-version"
|
||||
runtimeEntrypoint = "/usr/local/bin/domain-control"
|
||||
runtimeCommand = "serve"
|
||||
runtimeUser = "dyndns:dyndns"
|
||||
runtimeWorkingDirectory = "/data"
|
||||
)
|
||||
|
||||
type DockerRunner struct {
|
||||
docker *dockerClient
|
||||
imageRepository string
|
||||
targetLabel string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type updateError struct {
|
||||
message string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *updateError) Error() string { return e.err.Error() }
|
||||
func (e *updateError) Unwrap() error { return e.err }
|
||||
func (e *updateError) PublicMessage() string { return e.message }
|
||||
|
||||
func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner {
|
||||
if imageRepository == "" {
|
||||
imageRepository = defaultImageRepository
|
||||
}
|
||||
return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger}
|
||||
}
|
||||
|
||||
func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) {
|
||||
if !updateprotocol.ValidVersion(request.Version) {
|
||||
return errors.New("invalid target version")
|
||||
}
|
||||
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}
|
||||
}
|
||||
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
||||
if err != nil {
|
||||
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
|
||||
}
|
||||
inspected, err := r.docker.inspectContainer(ctx, target.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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")}
|
||||
}
|
||||
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die laufende Version konnte nicht sicher bestimmt werden; bitte einmal mit Docker Compose aktualisieren", err: err}
|
||||
}
|
||||
if comparison, compareErr := updateprotocol.CompareVersions(currentVersion, request.Version); compareErr != nil || comparison >= 0 {
|
||||
return &updateError{message: "Die Zielversion ist nicht neuer als die laufende Version", err: fmt.Errorf("invalid version transition %s to %s", currentVersion, request.Version)}
|
||||
}
|
||||
currentImage, err := r.docker.inspectImage(ctx, inspected.Image)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die laufende Container-Basis konnte nicht geprüft werden", err: err}
|
||||
}
|
||||
|
||||
targetReference := r.imageRepository + ":" + request.Version
|
||||
progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft")
|
||||
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
|
||||
if errors.Is(err, errRegistryAuthentication) {
|
||||
return &updateError{message: "Container-Image ist nicht öffentlich zugänglich oder der Zugang wurde abgelehnt. Bitte einen gültigen Gitea-Zugang mit Paket-Leserechten hinterlegen; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
targetImage, err := r.docker.inspectImage(ctx, targetReference)
|
||||
if err != nil {
|
||||
return &updateError{message: "Das geladene Image konnte nicht geprüft werden; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
if err := validateUpdateCompatibility(currentVersion, request.Version, currentImage, targetImage); err != nil {
|
||||
return &updateError{message: "Dieses Release unterstützt kein sicheres Direktupdate von der laufenden Version; bitte Docker Compose verwenden", err: err}
|
||||
}
|
||||
replacementConfig, err := buildReplacementConfig(inspected, currentImage.Config, targetImage.Config, targetReference)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die neue Container-Konfiguration konnte nicht sicher vorbereitet werden", err: err}
|
||||
}
|
||||
originalName := strings.TrimPrefix(inspected.Name, "/")
|
||||
if originalName == "" {
|
||||
return errors.New("target container has no name")
|
||||
}
|
||||
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano())
|
||||
var oldStopped, oldRenamed bool
|
||||
var newID string
|
||||
defer func() {
|
||||
if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") {
|
||||
return
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil {
|
||||
r.logger.Error("software update rollback failed", "error", rollbackErr)
|
||||
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
|
||||
}
|
||||
}()
|
||||
|
||||
progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet")
|
||||
// A lost Docker response does not mean the stop was not executed. Arm
|
||||
// rollback first so a stopped application is restarted on this error path.
|
||||
oldStopped = true
|
||||
if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
|
||||
return err
|
||||
}
|
||||
oldRenamed = true
|
||||
newID, err = r.docker.createReplacement(ctx, inspected, originalName, replacementConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.docker.startContainer(ctx, newID); err != nil {
|
||||
return err
|
||||
}
|
||||
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
|
||||
if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
|
||||
r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err)
|
||||
}
|
||||
oldStopped, oldRenamed, newID = false, false, ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error {
|
||||
var failures []string
|
||||
if newID != "" {
|
||||
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
|
||||
failures = append(failures, "stop replacement: "+err.Error())
|
||||
}
|
||||
if err := r.docker.removeContainer(ctx, newID, true); err != nil {
|
||||
failures = append(failures, "remove replacement: "+err.Error())
|
||||
}
|
||||
}
|
||||
if oldRenamed {
|
||||
if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil {
|
||||
failures = append(failures, "restore name: "+err.Error())
|
||||
}
|
||||
}
|
||||
if oldStopped {
|
||||
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
|
||||
failures = append(failures, "restart previous container: "+err.Error())
|
||||
} else if err := r.docker.waitHealthy(ctx, oldID, oldImageID, "", 60*time.Second); err != nil {
|
||||
failures = append(failures, "verify previous container: "+err.Error())
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return errors.New(strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dockerClient struct {
|
||||
socket string
|
||||
client *http.Client
|
||||
apiPrefix string
|
||||
}
|
||||
|
||||
type containerSummary struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
State string `json:"State"`
|
||||
}
|
||||
|
||||
type containerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Image string `json:"Image"`
|
||||
Name string `json:"Name"`
|
||||
Config json.RawMessage `json:"Config"`
|
||||
HostConfig json.RawMessage `json:"HostConfig"`
|
||||
NetworkSettings struct {
|
||||
Networks map[string]endpointSettings `json:"Networks"`
|
||||
} `json:"NetworkSettings"`
|
||||
State containerState `json:"State"`
|
||||
}
|
||||
|
||||
type imageInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Config json.RawMessage `json:"Config"`
|
||||
}
|
||||
|
||||
type imageRuntimeConfig struct {
|
||||
User string `json:"User"`
|
||||
Entrypoint []string `json:"Entrypoint"`
|
||||
Cmd []string `json:"Cmd"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
ExposedPorts map[string]any `json:"ExposedPorts"`
|
||||
Healthcheck *struct {
|
||||
Test []string `json:"Test"`
|
||||
} `json:"Healthcheck"`
|
||||
}
|
||||
|
||||
type endpointSettings struct {
|
||||
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
|
||||
Links []string `json:"Links,omitempty"`
|
||||
Aliases []string `json:"Aliases,omitempty"`
|
||||
MacAddress string `json:"MacAddress,omitempty"`
|
||||
DriverOpts map[string]string `json:"DriverOpts,omitempty"`
|
||||
GwPriority int `json:"GwPriority,omitempty"`
|
||||
}
|
||||
|
||||
type containerState struct {
|
||||
Running bool `json:"Running"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
}
|
||||
|
||||
func newDockerClient(socket string) *dockerClient {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket)
|
||||
},
|
||||
MaxIdleConns: 2,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &dockerClient{socket: socket, client: &http.Client{Transport: transport}}
|
||||
}
|
||||
|
||||
func (c *dockerClient) negotiate(ctx context.Context) error {
|
||||
response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
var version struct {
|
||||
APIVersion string `json:"ApiVersion"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil {
|
||||
return fmt.Errorf("decode Docker API version: %w", err)
|
||||
}
|
||||
for _, character := range version.APIVersion {
|
||||
if (character < '0' || character > '9') && character != '.' {
|
||||
return errors.New("Docker returned an invalid API version")
|
||||
}
|
||||
}
|
||||
if version.APIVersion == "" {
|
||||
return errors.New("Docker returned an empty API version")
|
||||
}
|
||||
c.apiPrefix = "/v" + version.APIVersion
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) {
|
||||
filters, _ := json.Marshal(map[string][]string{"label": {label}})
|
||||
query := url.Values{"all": {"1"}, "filters": {string(filters)}}
|
||||
response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true)
|
||||
if err != nil {
|
||||
return containerSummary{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return containerSummary{}, dockerStatusError(response)
|
||||
}
|
||||
var containers []containerSummary
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil {
|
||||
return containerSummary{}, err
|
||||
}
|
||||
var running []containerSummary
|
||||
for _, container := range containers {
|
||||
if container.State == "running" {
|
||||
running = append(running, container)
|
||||
}
|
||||
}
|
||||
if len(running) != 1 {
|
||||
return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running))
|
||||
}
|
||||
return running[0], nil
|
||||
}
|
||||
|
||||
var errRegistryAuthentication = errors.New("registry authentication required")
|
||||
|
||||
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]})
|
||||
headers = http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
|
||||
}
|
||||
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
err := dockerStatusError(response)
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || registryAuthFailure(err.Error()) {
|
||||
return fmt.Errorf("%w: %v", errRegistryAuthentication, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for scanner.Scan() {
|
||||
var event struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDetail struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
}
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||
return errors.New("Docker returned invalid pull progress")
|
||||
}
|
||||
message := event.Error
|
||||
if message == "" {
|
||||
message = event.ErrorDetail.Message
|
||||
}
|
||||
if message != "" {
|
||||
if event.ErrorDetail.Code == http.StatusUnauthorized || event.ErrorDetail.Code == http.StatusForbidden || registryAuthFailure(message) {
|
||||
return fmt.Errorf("%w: %s", errRegistryAuthentication, message)
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func registryAuthFailure(message string) bool {
|
||||
message = strings.ToLower(message)
|
||||
for _, marker := range []string{"unauthorized", "authentication required", "access denied", "access to the resource is denied", "pull access denied", "no basic auth credentials"} {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) {
|
||||
response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true)
|
||||
if err != nil {
|
||||
return containerInspect{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return containerInspect{}, dockerStatusError(response)
|
||||
}
|
||||
var inspected containerInspect
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
|
||||
return containerInspect{}, err
|
||||
}
|
||||
return inspected, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) inspectImage(ctx context.Context, reference string) (imageInspect, error) {
|
||||
if strings.TrimSpace(reference) == "" {
|
||||
return imageInspect{}, errors.New("image reference is empty")
|
||||
}
|
||||
response, err := c.request(ctx, http.MethodGet, "/images/"+url.PathEscape(reference)+"/json", nil, nil, true)
|
||||
if err != nil {
|
||||
return imageInspect{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return imageInspect{}, dockerStatusError(response)
|
||||
}
|
||||
var inspected imageInspect
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
|
||||
return imageInspect{}, err
|
||||
}
|
||||
if inspected.ID == "" || len(inspected.Config) == 0 {
|
||||
return imageInspect{}, errors.New("Docker returned incomplete image metadata")
|
||||
}
|
||||
return inspected, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
|
||||
query := url.Values{"t": {fmt.Sprint(seconds)}}
|
||||
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||
return nil
|
||||
}
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error {
|
||||
query := url.Values{"name": {name}}
|
||||
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) {
|
||||
var config struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
||||
return "", fmt.Errorf("decode running container version: %w", err)
|
||||
}
|
||||
prefix := strings.TrimSuffix(repository, ":") + ":"
|
||||
var imageVersion string
|
||||
if strings.HasPrefix(config.Image, prefix) {
|
||||
if version := strings.TrimPrefix(config.Image, prefix); updateprotocol.ValidVersion(version) {
|
||||
imageVersion = version
|
||||
}
|
||||
}
|
||||
labelVersion := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||
if labelVersion != "" && !updateprotocol.ValidVersion(labelVersion) {
|
||||
return "", errors.New("running container has an invalid version label")
|
||||
}
|
||||
if imageVersion == "" || labelVersion == "" {
|
||||
return "", errors.New("running container has no trustworthy version metadata")
|
||||
}
|
||||
if imageVersion != labelVersion {
|
||||
return "", fmt.Errorf("running image reference %s does not match version label %s", imageVersion, labelVersion)
|
||||
}
|
||||
actualVersion := imageVersion
|
||||
if reported != "" {
|
||||
if !updateprotocol.ValidVersion(reported) || reported != actualVersion {
|
||||
return "", fmt.Errorf("reported version %q does not match running version %q", reported, actualVersion)
|
||||
}
|
||||
}
|
||||
return actualVersion, nil
|
||||
}
|
||||
|
||||
func validateUpdateCompatibility(currentVersion, targetVersion string, currentImage, targetImage imageInspect) error {
|
||||
if comparison, err := updateprotocol.CompareVersions(currentVersion, targetVersion); err != nil || comparison >= 0 {
|
||||
return fmt.Errorf("target %s is not newer than %s", targetVersion, currentVersion)
|
||||
}
|
||||
var current, target imageRuntimeConfig
|
||||
if err := json.Unmarshal(currentImage.Config, ¤t); err != nil {
|
||||
return fmt.Errorf("decode current image config: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(targetImage.Config, &target); err != nil {
|
||||
return fmt.Errorf("decode target image config: %w", err)
|
||||
}
|
||||
if current.Labels[labelImageVersion] != currentVersion {
|
||||
return fmt.Errorf("current image version label is %q", current.Labels[labelImageVersion])
|
||||
}
|
||||
if target.Labels[labelImageVersion] != targetVersion {
|
||||
return fmt.Errorf("target image version label is %q", target.Labels[labelImageVersion])
|
||||
}
|
||||
minimumVersion := target.Labels[labelMinimumVersion]
|
||||
if !updateprotocol.ValidVersion(minimumVersion) {
|
||||
return errors.New("target image has no valid minimum update version")
|
||||
}
|
||||
if comparison, err := updateprotocol.CompareVersions(currentVersion, minimumVersion); err != nil || comparison < 0 {
|
||||
return fmt.Errorf("target requires at least %s", minimumVersion)
|
||||
}
|
||||
minimumAgent, err := strconv.Atoi(target.Labels[labelMinimumAgent])
|
||||
if err != nil || minimumAgent < buildinfo.MinimumAgentProtocol || minimumAgent > buildinfo.UpdateProtocolVersion {
|
||||
return fmt.Errorf("target requires unsupported update protocol %q", target.Labels[labelMinimumAgent])
|
||||
}
|
||||
runtimeContract := strconv.Itoa(buildinfo.RuntimeContractVersion)
|
||||
if current.Labels[labelRuntimeContract] != runtimeContract || target.Labels[labelRuntimeContract] != runtimeContract {
|
||||
return fmt.Errorf("unsupported runtime contract transition %q to %q", current.Labels[labelRuntimeContract], target.Labels[labelRuntimeContract])
|
||||
}
|
||||
databaseContract := strconv.Itoa(buildinfo.DatabaseContractVersion)
|
||||
if current.Labels[labelDatabaseContract] != databaseContract || target.Labels[labelDatabaseContract] != databaseContract {
|
||||
return fmt.Errorf("unsupported database contract transition %q to %q", current.Labels[labelDatabaseContract], target.Labels[labelDatabaseContract])
|
||||
}
|
||||
currentSchema, currentSchemaErr := strconv.Atoi(current.Labels[labelSchemaVersion])
|
||||
targetSchema, targetSchemaErr := strconv.Atoi(target.Labels[labelSchemaVersion])
|
||||
if currentSchemaErr != nil || targetSchemaErr != nil || currentSchema < buildinfo.CurrentSchemaVersion || targetSchema < currentSchema {
|
||||
return fmt.Errorf("unsupported schema transition %q to %q", current.Labels[labelSchemaVersion], target.Labels[labelSchemaVersion])
|
||||
}
|
||||
if !compatibleRuntimeConfig(current) {
|
||||
return errors.New("current image violates the stable runtime contract")
|
||||
}
|
||||
if !compatibleRuntimeConfig(target) {
|
||||
return errors.New("target image violates the stable runtime contract")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compatibleRuntimeConfig(config imageRuntimeConfig) bool {
|
||||
if config.User != runtimeUser || config.WorkingDir != runtimeWorkingDirectory || len(config.Entrypoint) != 1 || config.Entrypoint[0] != runtimeEntrypoint || len(config.Cmd) != 1 || config.Cmd[0] != runtimeCommand {
|
||||
return false
|
||||
}
|
||||
if _, ok := config.ExposedPorts["8080/tcp"]; !ok {
|
||||
return false
|
||||
}
|
||||
return config.Healthcheck != nil && strings.Join(config.Healthcheck.Test, "\x00") == strings.Join([]string{"CMD", runtimeEntrypoint, "healthcheck"}, "\x00")
|
||||
}
|
||||
|
||||
func buildReplacementConfig(inspected containerInspect, oldImageConfig, targetImageConfig json.RawMessage, targetReference string) (map[string]json.RawMessage, error) {
|
||||
oldContainer, err := decodeConfigMap(inspected.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode running container config: %w", err)
|
||||
}
|
||||
oldImage, err := decodeConfigMap(oldImageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode running image config: %w", err)
|
||||
}
|
||||
target, err := decodeConfigMap(targetImageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode target image config: %w", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{
|
||||
"Domainname", "AttachStdin", "AttachStdout", "AttachStderr", "Tty", "OpenStdin", "StdinOnce",
|
||||
"User", "Cmd", "Entrypoint", "WorkingDir", "Healthcheck", "StopSignal", "StopTimeout", "Shell",
|
||||
"NetworkDisabled", "MacAddress",
|
||||
} {
|
||||
preserveConfigOverride(target, oldContainer, oldImage, field)
|
||||
}
|
||||
preserveCustomHostname(target, oldContainer, inspected.ID)
|
||||
if err := mergeEnvironment(target, oldContainer, oldImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mergeLabels(target, oldContainer, oldImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, field := range []string{"ExposedPorts", "Volumes"} {
|
||||
if err := mergeAddedMapKeys(target, oldContainer, oldImage, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
encodedReference, _ := json.Marshal(targetReference)
|
||||
target["Image"] = encodedReference
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func decodeConfigMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||
var config map[string]json.RawMessage
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, errors.New("empty config")
|
||||
}
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func preserveConfigOverride(target, container, image map[string]json.RawMessage, field string) {
|
||||
containerValue, exists := container[field]
|
||||
if !exists || rawJSONEqual(containerValue, image[field]) {
|
||||
return
|
||||
}
|
||||
target[field] = append(json.RawMessage(nil), containerValue...)
|
||||
}
|
||||
|
||||
func preserveCustomHostname(target, container map[string]json.RawMessage, containerID string) {
|
||||
var hostname string
|
||||
if err := json.Unmarshal(container["Hostname"], &hostname); err != nil || hostname == "" {
|
||||
return
|
||||
}
|
||||
shortID := containerID
|
||||
if len(shortID) > 12 {
|
||||
shortID = shortID[:12]
|
||||
}
|
||||
if hostname == containerID || hostname == shortID {
|
||||
delete(target, "Hostname")
|
||||
return
|
||||
}
|
||||
target["Hostname"] = append(json.RawMessage(nil), container["Hostname"]...)
|
||||
}
|
||||
|
||||
func mergeEnvironment(target, container, image map[string]json.RawMessage) error {
|
||||
targetValues, err := decodeStringSlice(target["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target environment: %w", err)
|
||||
}
|
||||
containerValues, err := decodeStringSlice(container["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container environment: %w", err)
|
||||
}
|
||||
imageValues, err := decodeStringSlice(image["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image environment: %w", err)
|
||||
}
|
||||
result, positions := indexedEnvironment(targetValues)
|
||||
imageEnvironment := make(map[string]string, len(imageValues))
|
||||
for _, value := range imageValues {
|
||||
imageEnvironment[environmentKey(value)] = value
|
||||
}
|
||||
for _, value := range containerValues {
|
||||
key := environmentKey(value)
|
||||
if previous, exists := imageEnvironment[key]; exists && previous == value {
|
||||
continue
|
||||
}
|
||||
if position, exists := positions[key]; exists {
|
||||
result[position] = value
|
||||
} else {
|
||||
positions[key] = len(result)
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
target["Env"] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStringSlice(raw json.RawMessage) ([]string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func indexedEnvironment(values []string) ([]string, map[string]int) {
|
||||
result := append([]string(nil), values...)
|
||||
positions := make(map[string]int, len(values))
|
||||
for index, value := range values {
|
||||
positions[environmentKey(value)] = index
|
||||
}
|
||||
return result, positions
|
||||
}
|
||||
|
||||
func environmentKey(value string) string {
|
||||
if index := strings.IndexByte(value, '='); index >= 0 {
|
||||
return value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mergeLabels(target, container, image map[string]json.RawMessage) error {
|
||||
targetLabels, err := decodeStringMap(target["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target labels: %w", err)
|
||||
}
|
||||
containerLabels, err := decodeStringMap(container["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container labels: %w", err)
|
||||
}
|
||||
imageLabels, err := decodeStringMap(image["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image labels: %w", err)
|
||||
}
|
||||
if targetLabels == nil {
|
||||
targetLabels = make(map[string]string)
|
||||
}
|
||||
for key, value := range containerLabels {
|
||||
if protectedImageLabel(key) {
|
||||
continue
|
||||
}
|
||||
if imageValue, exists := imageLabels[key]; exists && imageValue == value {
|
||||
continue
|
||||
}
|
||||
targetLabels[key] = value
|
||||
}
|
||||
encoded, _ := json.Marshal(targetLabels)
|
||||
target["Labels"] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func protectedImageLabel(key string) bool {
|
||||
switch key {
|
||||
case labelImageVersion, labelMinimumVersion, labelMinimumAgent, labelRuntimeContract, labelDatabaseContract, labelSchemaVersion:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStringMap(raw json.RawMessage) (map[string]string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values map[string]string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func mergeAddedMapKeys(target, container, image map[string]json.RawMessage, field string) error {
|
||||
targetValues, err := decodeRawMap(target[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target %s: %w", field, err)
|
||||
}
|
||||
containerValues, err := decodeRawMap(container[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container %s: %w", field, err)
|
||||
}
|
||||
imageValues, err := decodeRawMap(image[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image %s: %w", field, err)
|
||||
}
|
||||
if targetValues == nil {
|
||||
targetValues = make(map[string]json.RawMessage)
|
||||
}
|
||||
for key, value := range containerValues {
|
||||
if imageValue, exists := imageValues[key]; exists && rawJSONEqual(value, imageValue) {
|
||||
continue
|
||||
}
|
||||
targetValues[key] = value
|
||||
}
|
||||
encoded, _ := json.Marshal(targetValues)
|
||||
target[field] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRawMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func rawJSONEqual(left, right json.RawMessage) bool {
|
||||
if len(left) == 0 && len(right) == 0 {
|
||||
return true
|
||||
}
|
||||
var a, b any
|
||||
if err := json.Unmarshal(left, &a); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(right, &b); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name string, body map[string]json.RawMessage) (string, error) {
|
||||
body["HostConfig"] = inspected.HostConfig
|
||||
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
|
||||
oldIDPrefix := inspected.ID
|
||||
if len(oldIDPrefix) > 12 {
|
||||
oldIDPrefix = oldIDPrefix[:12]
|
||||
}
|
||||
for network, endpoint := range inspected.NetworkSettings.Networks {
|
||||
aliases := endpoint.Aliases[:0]
|
||||
for _, alias := range endpoint.Aliases {
|
||||
if alias != inspected.ID && alias != oldIDPrefix {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
}
|
||||
endpoint.Aliases = aliases
|
||||
endpoints[network] = endpoint
|
||||
}
|
||||
networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints})
|
||||
body["NetworkingConfig"] = networkingJSON
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := url.Values{"name": {name}}
|
||||
response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
return "", dockerStatusError(response)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if created.ID == "" {
|
||||
return "", errors.New("Docker created a replacement without an ID")
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) startContainer(ctx context.Context, id string) error {
|
||||
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||
return nil
|
||||
}
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error {
|
||||
query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}}
|
||||
response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
consecutiveHealthy := 0
|
||||
startedAt := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
inspected, err := c.inspectContainer(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !inspected.State.Running {
|
||||
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
|
||||
}
|
||||
if expectedImageID != "" && inspected.Image != expectedImageID {
|
||||
return fmt.Errorf("container uses image %s instead of %s", inspected.Image, expectedImageID)
|
||||
}
|
||||
if expectedVersion != "" {
|
||||
var config struct {
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
if err := json.Unmarshal(inspected.Config, &config); err != nil {
|
||||
return fmt.Errorf("decode replacement version: %w", err)
|
||||
}
|
||||
version := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||
if version != expectedVersion {
|
||||
return fmt.Errorf("container version is %q instead of %q", version, expectedVersion)
|
||||
}
|
||||
}
|
||||
if inspected.State.Health == nil {
|
||||
if time.Since(startedAt) >= 8*time.Second {
|
||||
return errors.New("replacement container has no healthcheck")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inspected.State.Health.Status == "healthy" {
|
||||
consecutiveHealthy++
|
||||
if consecutiveHealthy >= 3 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
consecutiveHealthy = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if versioned {
|
||||
path = c.apiPrefix + path
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, values := range headers {
|
||||
for _, value := range values {
|
||||
request.Header.Add(name, value)
|
||||
}
|
||||
}
|
||||
return c.client.Do(request)
|
||||
}
|
||||
|
||||
func dockerStatusError(response *http.Response) error {
|
||||
message, _ := io.ReadAll(io.LimitReader(response.Body, 8<<10))
|
||||
trimmed := strings.TrimSpace(string(message))
|
||||
if trimmed == "" {
|
||||
trimmed = http.StatusText(response.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("Docker API HTTP %d: %s", response.StatusCode, trimmed)
|
||||
}
|
||||
|
||||
func isDockerNotModified(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "HTTP 304")
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
func TestLostStopResponseRestartsPreviousContainer(t *testing.T) {
|
||||
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||
current.ID = "sha256:current"
|
||||
target := compatibleImage("v1.5.0", "v1.4.0")
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal(current.Config, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config["Image"] = json.RawMessage(`"registry.example/app:v1.4.0"`)
|
||||
containerConfig, _ := json.Marshal(config)
|
||||
stopped, restarted := false, false
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
var body any
|
||||
status := http.StatusOK
|
||||
switch strings.TrimPrefix(r.URL.Path, "/v1.0") {
|
||||
case "/version":
|
||||
body = map[string]string{"ApiVersion": "1.0"}
|
||||
case "/containers/json":
|
||||
body = []containerSummary{{ID: "old", State: "running"}}
|
||||
case "/containers/old/json":
|
||||
body = map[string]any{"Id": "old", "Name": "/app", "Image": current.ID, "Config": json.RawMessage(containerConfig), "HostConfig": map[string]any{}, "State": map[string]any{"Running": !stopped, "Health": map[string]string{"Status": "healthy"}}}
|
||||
case "/images/sha256:current/json":
|
||||
body = current
|
||||
case "/images/registry.example/app:v1.5.0/json":
|
||||
body = target
|
||||
case "/images/create":
|
||||
body = map[string]string{"status": "downloaded"}
|
||||
case "/containers/old/stop":
|
||||
stopped = true
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
case "/containers/old/start":
|
||||
stopped = false
|
||||
restarted = true
|
||||
status = http.StatusNoContent
|
||||
default:
|
||||
t.Errorf("unexpected Docker call: %s %s", r.Method, r.URL.Path)
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}, nil
|
||||
})
|
||||
runner := &DockerRunner{docker: &dockerClient{client: &http.Client{Transport: transport}}, imageRepository: "registry.example/app", logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
err := runner.Update(context.Background(), updateprotocol.Request{Version: "v1.5.0", CurrentVersion: "v1.4.0"}, func(string, string) {})
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) || !restarted || stopped {
|
||||
t.Fatalf("lost stop response left service down: restarted=%v stopped=%v err=%v", restarted, stopped, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthDeadlineCancelsStalledDockerRequest(t *testing.T) {
|
||||
for _, replacement := range []bool{false, true} {
|
||||
t.Run(strconv.FormatBool(replacement), func(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
client := &dockerClient{client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
<-r.Context().Done()
|
||||
return nil, r.Context().Err()
|
||||
})}}
|
||||
// Parent outlives the health deadline; the request must use the latter.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
if replacement {
|
||||
err = client.waitHealthy(ctx, "id", "", "", 1200*time.Millisecond)
|
||||
} else {
|
||||
_, err = client.waitCurrentHealthy(ctx, "id", "", 30*time.Millisecond, time.Millisecond)
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil || requests.Load() != 1 {
|
||||
t.Fatalf("health deadline did not cancel the Docker request: %v requests=%d", err, requests.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
||||
t.Parallel()
|
||||
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||
target := compatibleImage("v2.0.0", "v1.4.0")
|
||||
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", current, target); err != nil {
|
||||
t.Fatalf("compatible direct update was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
||||
version, err := resolveCurrentVersion("", config, "registry.example/app")
|
||||
if err != nil || version != "v1.4.0" {
|
||||
t.Fatalf("exact image version was not resolved: version=%q err=%v", version, err)
|
||||
}
|
||||
if _, err := resolveCurrentVersion("v1.5.0", config, "registry.example/app"); err == nil {
|
||||
t.Fatal("untrusted reported version overrode the running image metadata")
|
||||
}
|
||||
mismatch := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.3.0"}}`)
|
||||
if _, err := resolveCurrentVersion("", mismatch, "registry.example/app"); err == nil {
|
||||
t.Fatal("mismatching image reference and label were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateCompatibilityRejectsUnsupportedJump(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{name: "minimum version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumVersion] = "v1.5.0" }},
|
||||
{name: "runtime contract", mutate: func(config map[string]any) {
|
||||
config["Labels"].(map[string]string)[labelRuntimeContract] = strconv.Itoa(buildinfo.RuntimeContractVersion + 1)
|
||||
}},
|
||||
{name: "database contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelDatabaseContract] = "2" }},
|
||||
{name: "agent protocol", mutate: func(config map[string]any) {
|
||||
config["Labels"].(map[string]string)[labelMinimumAgent] = strconv.Itoa(buildinfo.UpdateProtocolVersion + 1)
|
||||
}},
|
||||
{name: "wrong image version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelImageVersion] = "v1.9.9" }},
|
||||
{name: "missing healthcheck", mutate: func(config map[string]any) { delete(config, "Healthcheck") }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
image := compatibleImage("v2.0.0", "v1.4.0")
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(image.Config, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels := make(map[string]string)
|
||||
for key, value := range config["Labels"].(map[string]any) {
|
||||
labels[key] = value.(string)
|
||||
}
|
||||
config["Labels"] = labels
|
||||
test.mutate(config)
|
||||
image.Config, _ = json.Marshal(config)
|
||||
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", compatibleImage("v1.4.0", "v1.4.0"), image); err == nil {
|
||||
t.Fatal("incompatible image was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateCompatibilityRejectsSchemaRegressionAfterEarlierSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
current := compatibleImage("v2.0.0", "v1.4.0")
|
||||
target := compatibleImage("v2.1.0", "v1.4.0")
|
||||
current.Config = withImageLabel(t, current.Config, labelSchemaVersion, "20")
|
||||
target.Config = withImageLabel(t, target.Config, labelSchemaVersion, "19")
|
||||
if err := validateUpdateCompatibility("v2.0.0", "v2.1.0", current, target); err == nil {
|
||||
t.Fatal("target with an older schema contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplacementConfigUsesNewImageDefaultsAndKeepsDeploymentOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
oldImage := json.RawMessage(`{
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/old-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/old","OLD_DEFAULT=1"],
|
||||
"Labels":{"org.opencontainers.image.version":"v1.3.0","image-owned":"old"},
|
||||
"ExposedPorts":{"8080/tcp":{}},
|
||||
"Volumes":{"/data":{}}
|
||||
}`)
|
||||
targetImage := json.RawMessage(`{
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/new-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/new","NEW_DEFAULT=1"],
|
||||
"Labels":{"org.opencontainers.image.version":"v2.0.0","image-owned":"new"},
|
||||
"ExposedPorts":{"8080/tcp":{}},
|
||||
"Volumes":{"/data":{}}
|
||||
}`)
|
||||
container := containerInspect{
|
||||
ID: "0123456789abcdef",
|
||||
Config: json.RawMessage(`{
|
||||
"Hostname":"0123456789ab",
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/old-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/old","OLD_DEFAULT=1","CUSTOM=yes"],
|
||||
"Labels":{"org.opencontainers.image.version":"v0.0.0","image-owned":"old","com.docker.compose.project":"domain-control","de.j-mahler.domain-control.role":"app"},
|
||||
"ExposedPorts":{"8080/tcp":{},"9091/tcp":{}},
|
||||
"Volumes":{"/data":{},"/custom":{}}
|
||||
}`),
|
||||
}
|
||||
config, err := buildReplacementConfig(container, oldImage, targetImage, "registry.example/app:v2.0.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := config["Hostname"]; exists {
|
||||
t.Fatal("generated container hostname was copied")
|
||||
}
|
||||
var image, user string
|
||||
var entrypoint, environment []string
|
||||
var labels map[string]string
|
||||
var ports, volumes map[string]any
|
||||
_ = json.Unmarshal(config["Image"], &image)
|
||||
_ = json.Unmarshal(config["User"], &user)
|
||||
_ = json.Unmarshal(config["Entrypoint"], &entrypoint)
|
||||
_ = json.Unmarshal(config["Env"], &environment)
|
||||
_ = json.Unmarshal(config["Labels"], &labels)
|
||||
_ = json.Unmarshal(config["ExposedPorts"], &ports)
|
||||
_ = json.Unmarshal(config["Volumes"], &volumes)
|
||||
if image != "registry.example/app:v2.0.0" || user != runtimeUser || len(entrypoint) != 1 || entrypoint[0] != "/new-entrypoint" {
|
||||
t.Fatalf("new image defaults were not retained: image=%q user=%q entrypoint=%v", image, user, entrypoint)
|
||||
}
|
||||
if !contains(environment, "PATH=/new") || !contains(environment, "NEW_DEFAULT=1") || !contains(environment, "CUSTOM=yes") || contains(environment, "OLD_DEFAULT=1") {
|
||||
t.Fatalf("environment was merged incorrectly: %v", environment)
|
||||
}
|
||||
if labels[labelImageVersion] != "v2.0.0" || labels["image-owned"] != "new" || labels["com.docker.compose.project"] != "domain-control" || labels["de.j-mahler.domain-control.role"] != "app" {
|
||||
t.Fatalf("labels were merged incorrectly: %#v", labels)
|
||||
}
|
||||
if _, exists := ports["9091/tcp"]; !exists {
|
||||
t.Fatalf("custom exposed port was lost: %#v", ports)
|
||||
}
|
||||
if _, exists := volumes["/custom"]; !exists {
|
||||
t.Fatalf("custom volume was lost: %#v", volumes)
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleImage(version, minimum string) imageInspect {
|
||||
config := map[string]any{
|
||||
"User": runtimeUser,
|
||||
"Entrypoint": []string{runtimeEntrypoint},
|
||||
"Cmd": []string{runtimeCommand},
|
||||
"WorkingDir": runtimeWorkingDirectory,
|
||||
"ExposedPorts": map[string]any{
|
||||
"8080/tcp": map[string]any{},
|
||||
},
|
||||
"Healthcheck": map[string]any{
|
||||
"Test": []string{"CMD", runtimeEntrypoint, "healthcheck"},
|
||||
},
|
||||
"Labels": map[string]string{
|
||||
labelImageVersion: version,
|
||||
labelMinimumVersion: minimum,
|
||||
labelMinimumAgent: strconv.Itoa(buildinfo.MinimumAgentProtocol),
|
||||
labelRuntimeContract: strconv.Itoa(buildinfo.RuntimeContractVersion),
|
||||
labelDatabaseContract: strconv.Itoa(buildinfo.DatabaseContractVersion),
|
||||
labelSchemaVersion: strconv.Itoa(buildinfo.CurrentSchemaVersion),
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(config)
|
||||
return imageInspect{ID: "sha256:target", Config: encoded}
|
||||
}
|
||||
|
||||
func withImageLabel(t *testing.T, raw json.RawMessage, key, value string) json.RawMessage {
|
||||
t.Helper()
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, ok := config["Labels"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("image labels are missing")
|
||||
}
|
||||
labels[key] = value
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Socket string
|
||||
DockerSocket string
|
||||
ImageRepository string
|
||||
TargetLabel string
|
||||
}
|
||||
|
||||
func ServerConfigFromEnvironment() ServerConfig {
|
||||
return ServerConfig{
|
||||
Socket: environmentValue("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
|
||||
DockerSocket: environmentValue("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||
ImageRepository: environmentValue("UPDATE_IMAGE_REPOSITORY", defaultImageRepository),
|
||||
TargetLabel: environmentValue("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app"),
|
||||
}
|
||||
}
|
||||
|
||||
func Serve(ctx context.Context, config ServerConfig, logger *slog.Logger) error {
|
||||
if err := validateServerConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
directory := filepath.Dir(config.Socket)
|
||||
if err := os.MkdirAll(directory, 0o711); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("update agent socket directory is not a regular directory")
|
||||
}
|
||||
if err := os.Chmod(directory, 0o711); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Lstat(config.Socket); err == nil {
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return errors.New("update agent socket path is occupied by a non-socket file")
|
||||
}
|
||||
if err := os.Remove(config.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", config.Socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(config.Socket)
|
||||
// The agent container runs as uid 0 with gid 10001. Group-only access lets
|
||||
// the unprivileged web process connect without exposing the control socket.
|
||||
if err := os.Chmod(config.Socket, 0o660); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner := NewDockerRunner(config.DockerSocket, config.ImageRepository, config.TargetLabel, logger)
|
||||
controller := NewController(runner, logger)
|
||||
server := &http.Server{
|
||||
Handler: controller.Handler(),
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 15 * time.Second,
|
||||
MaxHeaderBytes: 8 << 10,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("update agent ready", "socket", config.Socket)
|
||||
serverErrors <- server.Serve(listener)
|
||||
}()
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
func validateServerConfig(config ServerConfig) error {
|
||||
socket := filepath.Clean(config.Socket)
|
||||
dockerSocket := filepath.Clean(config.DockerSocket)
|
||||
if !filepath.IsAbs(socket) || filepath.Dir(socket) == "/" {
|
||||
return errors.New("update agent socket must use a dedicated absolute directory")
|
||||
}
|
||||
if !filepath.IsAbs(dockerSocket) || socket == dockerSocket {
|
||||
return errors.New("Docker socket path is invalid")
|
||||
}
|
||||
if config.ImageRepository == "" || strings.ContainsAny(config.ImageRepository, " \t\r\n") {
|
||||
return errors.New("update image repository is invalid")
|
||||
}
|
||||
if len(config.TargetLabel) == 0 || len(config.TargetLabel) > 256 || !strings.Contains(config.TargetLabel, "=") {
|
||||
return errors.New("update target label is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func environmentValue(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateServerConfigRejectsUnsafeSocketPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := ServerConfig{
|
||||
Socket: "/run/domain-control-update/agent.sock",
|
||||
DockerSocket: "/var/run/docker.sock",
|
||||
ImageRepository: defaultImageRepository,
|
||||
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||
}
|
||||
if err := validateServerConfig(valid); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
for _, socket := range []string{"agent.sock", "/agent.sock", "/var/run/docker.sock"} {
|
||||
candidate := valid
|
||||
candidate.Socket = socket
|
||||
if err := validateServerConfig(candidate); err == nil {
|
||||
t.Errorf("unsafe socket path %q was accepted", socket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDoesNotDeleteRegularFileAtSocketPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
socket := filepath.Join(directory, "agent.sock")
|
||||
if err := os.WriteFile(socket, []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := ServerConfig{
|
||||
Socket: socket,
|
||||
DockerSocket: "/var/run/docker.sock",
|
||||
ImageRepository: defaultImageRepository,
|
||||
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||
}
|
||||
err := Serve(context.Background(), config, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("regular file at the socket path was silently replaced")
|
||||
}
|
||||
content, readErr := os.ReadFile(socket)
|
||||
if readErr != nil || string(content) != "keep" {
|
||||
t.Fatalf("regular file was changed: content=%q err=%v", content, readErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package updateprotocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||
|
||||
const (
|
||||
PhaseIdle = "idle"
|
||||
PhaseQueued = "queued"
|
||||
PhasePulling = "pulling"
|
||||
PhaseRestarting = "restarting"
|
||||
PhaseVerifying = "verifying"
|
||||
PhaseComplete = "complete"
|
||||
PhaseFailed = "failed"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Version string `json:"version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
RegistryUsername string `json:"registry_username"`
|
||||
// RegistryToken keeps the v2 wire name for compatibility and carries either
|
||||
// a registry password or an API token.
|
||||
RegistryToken string `json:"registry_token"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
// Optional capability; older v2 agents require registry credentials.
|
||||
AnonymousPull bool `json:"anonymous_pull,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
TargetVersion string `json:"target_version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
Protocol int `json:"protocol_version,omitempty"`
|
||||
}
|
||||
|
||||
func (s Status) Busy() bool {
|
||||
return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying
|
||||
}
|
||||
|
||||
func ValidVersion(version string) bool {
|
||||
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
|
||||
}
|
||||
|
||||
// CompareVersions compares strict stable vMAJOR.MINOR.PATCH versions. It does
|
||||
// not require callers to install intermediate releases.
|
||||
func CompareVersions(left, right string) (int, error) {
|
||||
a, err := parseVersion(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b, err := parseVersion(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] < b[index] {
|
||||
return -1, nil
|
||||
}
|
||||
if a[index] > b[index] {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func parseVersion(raw string) ([3]int, error) {
|
||||
if !ValidVersion(raw) {
|
||||
return [3]int{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
|
||||
var values [3]int
|
||||
for index, part := range parts {
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 || value > 1_000_000 {
|
||||
return [3]int{}, fmt.Errorf("version contains an invalid number")
|
||||
}
|
||||
values[index] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+30
-27
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
@@ -45,18 +45,12 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
|
||||
var view *piHoleView
|
||||
if len(integrations) > 0 {
|
||||
integration := integrations[0]
|
||||
current := piHoleView{Integration: integration}
|
||||
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
|
||||
var links []model.PiHoleRecordLink
|
||||
if accessErr == nil {
|
||||
var hosts []string
|
||||
hosts, accessErr = client.ListHosts(r.Context(), password)
|
||||
if accessErr == nil {
|
||||
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
|
||||
}
|
||||
}
|
||||
if accessErr != nil {
|
||||
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||
current := piHoleView{Integration: integration, FetchError: integration.LastError}
|
||||
// Navigation and removing an obsolete connection must work offline.
|
||||
links, err := s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
for _, link := range links {
|
||||
record := parsePiHoleRecord(link.Value)
|
||||
@@ -69,15 +63,6 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
|
||||
current.ArchivedRecords = append(current.ArchivedRecords, record)
|
||||
}
|
||||
}
|
||||
if accessErr != nil {
|
||||
current.FetchError = safeMessage(accessErr)
|
||||
current.Integration.LastError = current.FetchError
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError)
|
||||
} else {
|
||||
current.Integration.LastError = ""
|
||||
current.Integration.LastSyncAt = time.Now().UTC()
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||
}
|
||||
sortPiHoleRecords(current.Records)
|
||||
sortPiHoleRecords(current.ArchivedRecords)
|
||||
view = ¤t
|
||||
@@ -123,7 +108,8 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||
hosts, err := client.ListHosts(r.Context(), password)
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
@@ -141,6 +127,10 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/pihole", message, "error")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.")
|
||||
@@ -181,7 +171,8 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||
hosts, err := client.ListHosts(r.Context(), password)
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
@@ -195,6 +186,10 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.")
|
||||
@@ -213,7 +208,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
||||
return
|
||||
}
|
||||
@@ -255,16 +250,24 @@ func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
client, password, err := s.piHoleAccess(ctx, id)
|
||||
var hosts []string
|
||||
if err == nil {
|
||||
_, err = client.ListHosts(r.Context(), password)
|
||||
hosts, err = client.ListHosts(ctx, password)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts)
|
||||
}
|
||||
if err != nil {
|
||||
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_failed", Target: "pihole", Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
|
||||
message := safeMessage(err)
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
|
||||
return
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_ok", Target: "pihole"})
|
||||
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
||||
}
|
||||
|
||||
+423
-77
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"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/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||
@@ -32,13 +34,20 @@ import (
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
type Server struct {
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
instanceID string
|
||||
restartContainer func()
|
||||
restartMu sync.Mutex
|
||||
restartPending bool
|
||||
}
|
||||
|
||||
type viewData struct {
|
||||
@@ -74,6 +83,8 @@ type viewData struct {
|
||||
ErrorPiHoles []model.PiHoleIntegration
|
||||
ErrorCount int
|
||||
PiHoleView *piHoleView
|
||||
SoftwareUpdate appupdate.State
|
||||
InstanceID string
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
@@ -91,12 +102,33 @@ type accessZone struct {
|
||||
ManagedTargetIDs map[int64]bool
|
||||
}
|
||||
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
|
||||
type schedulerRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
StartedAtUnix int64 `json:"started_at_unix"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type schedulerResponse struct {
|
||||
NextRunUnix int64 `json:"next_run_unix"`
|
||||
Running bool `json:"running"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
LastIPv4 string `json:"last_ipv4"`
|
||||
LastIPv6 string `json:"last_ipv6"`
|
||||
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, restartContainer func()) (*Server, error) {
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
|
||||
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 {
|
||||
@@ -113,22 +145,22 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /", s.authenticated(s.dashboard))
|
||||
mux.Handle("GET /domains", s.authenticated(s.domains))
|
||||
mux.Handle("GET /domains/new", s.adminOnly(s.domainNew))
|
||||
mux.Handle("POST /domains", s.adminOnly(s.domainCreate))
|
||||
mux.Handle("POST /domains", s.adminOnly(s.dnsChange(s.domainCreate)))
|
||||
mux.Handle("GET /domains/{id}", s.authenticated(s.domainDetail))
|
||||
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.domainImport))
|
||||
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.domainTTLSave))
|
||||
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.domainRemove))
|
||||
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.domainDelete))
|
||||
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.domainToken))
|
||||
mux.Handle("POST /targets", s.authenticated(s.targetCreate))
|
||||
mux.Handle("POST /targets/{id}/save", s.authenticated(s.targetSave))
|
||||
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.targetDelete))
|
||||
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.targetArchive))
|
||||
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.targetRestore))
|
||||
mux.Handle("POST /cnames", s.adminOnly(s.cnameCreate))
|
||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
|
||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
||||
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.dnsChange(s.domainImport)))
|
||||
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.dnsChange(s.domainTTLSave)))
|
||||
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.dnsChange(s.domainRemove)))
|
||||
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.dnsChange(s.domainDelete)))
|
||||
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.dnsChange(s.domainToken)))
|
||||
mux.Handle("POST /targets", s.authenticated(s.dnsChange(s.targetCreate)))
|
||||
mux.Handle("POST /targets/{id}/save", s.authenticated(s.dnsChange(s.targetSave)))
|
||||
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.dnsChange(s.targetDelete)))
|
||||
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.dnsChange(s.targetArchive)))
|
||||
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.dnsChange(s.targetRestore)))
|
||||
mux.Handle("POST /cnames", s.adminOnly(s.dnsChange(s.cnameCreate)))
|
||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.dnsChange(s.cnameSave)))
|
||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.dnsChange(s.cnameRemove)))
|
||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.dnsChange(s.cnameDelete)))
|
||||
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
|
||||
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
|
||||
mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave))
|
||||
@@ -142,13 +174,22 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /pihole/{id}/records/archive", s.adminOnly(s.piHoleRecordArchive))
|
||||
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
|
||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||
mux.Handle("POST /settings", s.adminOnly(s.dnsChange(s.settingsSave)))
|
||||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||||
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
|
||||
mux.Handle("GET /errors/diagnostics", s.adminOnly(s.downloadDiagnostics))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityPage))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
|
||||
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /access", s.adminOnly(s.usersPage))
|
||||
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
|
||||
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
|
||||
mux.Handle("POST /users", s.adminOnly(s.userCreate))
|
||||
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
|
||||
@@ -157,11 +198,16 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
|
||||
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
|
||||
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
|
||||
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
|
||||
return s.securityHeaders(mux)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
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.Write([]byte("ok\n"))
|
||||
}
|
||||
@@ -226,6 +272,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
|
||||
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -278,10 +328,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username, usernameErr := normalizeUsername(r.FormValue("username"))
|
||||
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
|
||||
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
lookupOK := usernameErr == nil && err == nil
|
||||
if !lookupOK {
|
||||
hash = s.dummyHash
|
||||
}
|
||||
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
|
||||
if !lookupOK || !user.Active || !passwordOK {
|
||||
s.limiter.Fail(clientIP)
|
||||
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
||||
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
|
||||
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientIP)
|
||||
@@ -294,6 +349,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirectNotice(w, r, "/", "Angemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
|
||||
if s.authSlots == nil {
|
||||
return secure.VerifyPassword(hash, password)
|
||||
}
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case s.authSlots <- struct{}{}:
|
||||
defer func() { <-s.authSlots }()
|
||||
return secure.VerifyPassword(hash, password)
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
@@ -641,7 +713,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
|
||||
return
|
||||
}
|
||||
@@ -748,7 +820,7 @@ func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *m
|
||||
if !target.Active && hasStaticIP(target) {
|
||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||
if syncErr != nil {
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
|
||||
s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||
return
|
||||
}
|
||||
@@ -862,7 +934,7 @@ func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *mod
|
||||
if !target.Active && hasStaticIP(target) {
|
||||
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
|
||||
if syncErr != nil {
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
|
||||
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
|
||||
s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
|
||||
return
|
||||
}
|
||||
@@ -1191,11 +1263,13 @@ func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request, session *m
|
||||
s.redirectNotice(w, r, "/settings", "Für aktiviertes IPv6 ist mindestens ein HTTPS-Endpoint erforderlich.", "error")
|
||||
return
|
||||
}
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
if !slices.Equal(ipv4, current.IPv4Endpoints) {
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if ipv6Enabled {
|
||||
if ipv6Enabled && !slices.Equal(ipv6, current.IPv6Endpoints) {
|
||||
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil {
|
||||
s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error")
|
||||
return
|
||||
@@ -1240,6 +1314,185 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
|
||||
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||
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(), InstanceID: s.instanceID})
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
|
||||
s.updates.TriggerCheck()
|
||||
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
err := s.updates.CheckNow(checkCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
||||
s.redirectNotice(w, r, "/system#update-access", appupdate.ErrCredentialMissing.Error(), "info")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(r.FormValue("version"))
|
||||
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
err := s.updates.RequestUpdate(startCtx, version)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
|
||||
return
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
|
||||
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, "version": version})
|
||||
return
|
||||
}
|
||||
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
if s.updates == nil {
|
||||
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
status, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
state := s.updates.State()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"agent_available": statusErr == nil,
|
||||
"phase": status.Phase,
|
||||
"message": status.Message,
|
||||
"target_version": status.TargetVersion,
|
||||
"started_at": status.StartedAt,
|
||||
"updated_at": status.UpdatedAt,
|
||||
"current_version": state.CurrentVersion,
|
||||
"latest_version": state.LatestVersion,
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||
if err != nil {
|
||||
@@ -1277,8 +1530,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
|
||||
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
|
||||
}
|
||||
|
||||
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1290,26 +1547,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
|
||||
return
|
||||
}
|
||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||
return
|
||||
}
|
||||
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
||||
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||
return
|
||||
}
|
||||
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
||||
if err != nil {
|
||||
s.redirectNotice(w, r, "/security", err.Error(), "error")
|
||||
s.redirectNotice(w, r, "/account", err.Error(), "error")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
|
||||
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
|
||||
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
||||
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||
}
|
||||
|
||||
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
@@ -1326,19 +1582,40 @@ func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Sess
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
response := schedulerResponse{
|
||||
NextRunUnix: unixSeconds(s.scheduler.NextRunAt()),
|
||||
Running: s.scheduler.IsRunning(),
|
||||
Enabled: settings.UpdaterEnabled,
|
||||
IPv6Enabled: settings.IPv6Enabled,
|
||||
LastIPv4: settings.LastIPv4,
|
||||
LastIPv6: settings.LastIPv6,
|
||||
}
|
||||
if session.Role == "admin" && r.URL.Query().Get("runs") == "1" {
|
||||
runs, err := s.store.RecentRuns(r.Context(), 6)
|
||||
if err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
recentRuns := make([]schedulerRunResponse, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
recentRuns = append(recentRuns, schedulerRunResponse{
|
||||
ID: run.ID,
|
||||
StartedAtUnix: unixSeconds(run.StartedAt),
|
||||
Status: run.Status,
|
||||
Summary: run.Summary,
|
||||
})
|
||||
}
|
||||
response.RecentRuns = &recentRuns
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
||||
"running": s.scheduler.IsRunning(),
|
||||
"enabled": settings.UpdaterEnabled,
|
||||
})
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1347,11 +1624,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1380,7 +1661,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
|
||||
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
|
||||
return
|
||||
}
|
||||
hash, err := secure.HashPassword(r.FormValue("password"))
|
||||
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderUserNewError(w, session, err.Error(), form)
|
||||
return
|
||||
@@ -1438,7 +1719,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
|
||||
}
|
||||
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
|
||||
}
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1524,24 +1805,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
|
||||
return
|
||||
}
|
||||
if managedUser.IsAdmin() && (role != "admin" || !active) {
|
||||
count, countErr := s.store.CountActiveAdmins(r.Context())
|
||||
if countErr != nil {
|
||||
s.serverError(w, countErr)
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
if errors.Is(err, store.ErrLastActiveAdmin) {
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
if role != managedUser.Role || active != managedUser.Active {
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
}
|
||||
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
|
||||
}
|
||||
@@ -1576,11 +1847,34 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||||
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
|
||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
||||
}
|
||||
|
||||
// The lock spans reading the old target, remote changes and saving the new
|
||||
// configuration. Locking individual HTTP calls still permits stale worker writes.
|
||||
func (s *Server) dnsChange(next func(http.ResponseWriter, *http.Request, *model.Session)) func(http.ResponseWriter, *http.Request, *model.Session) {
|
||||
return func(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültiges CSRF-Token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
unlock, err := s.scheduler.LockChanges(waitCtx)
|
||||
if err != nil {
|
||||
w.Header().Set("Retry-After", "5")
|
||||
http.Error(w, "Eine DNS-Aktualisierung läuft noch. Bitte die Änderung in Kürze erneut senden.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
next(w, r, session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := s.session(r.Context(), r)
|
||||
@@ -1695,7 +1989,13 @@ func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool
|
||||
|
||||
func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
var err error
|
||||
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
|
||||
err = r.ParseMultipartForm(64 << 10)
|
||||
} else {
|
||||
err = r.ParseForm()
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
@@ -1719,6 +2019,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
|
||||
if settings, err := s.store.GetSettings(context.Background()); err == nil {
|
||||
data.UpdaterEnabled = settings.UpdaterEnabled
|
||||
}
|
||||
if data.IsAdmin && s.updates != nil {
|
||||
data.SoftwareUpdate = s.updates.State()
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -1732,7 +2035,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
|
||||
}
|
||||
|
||||
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||
}
|
||||
|
||||
func (s *Server) serverError(w http.ResponseWriter, err error) {
|
||||
@@ -1774,7 +2077,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func loadTemplates() (map[string]*template.Template, error) {
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
|
||||
functions := template.FuncMap{
|
||||
"formatTime": func(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
@@ -2043,15 +2346,30 @@ func remoteIP(r *http.Request) string {
|
||||
}
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
loginAttemptLimit = 5
|
||||
loginAttemptWindow = 15 * time.Minute
|
||||
loginLimiterCleanupEvery = time.Minute
|
||||
loginLimiterMaxTrackedIPs = 4096
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
|
||||
func (l *loginLimiter) Allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := time.Now().Add(-15 * time.Minute)
|
||||
now := time.Now()
|
||||
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
|
||||
l.cleanupLocked(now)
|
||||
}
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
values := l.attempts[ip]
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
@@ -2059,12 +2377,22 @@ func (l *loginLimiter) Allow(ip string) bool {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
l.attempts[ip] = kept
|
||||
return len(kept) < 5
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return false
|
||||
}
|
||||
return len(kept) < loginAttemptLimit
|
||||
}
|
||||
func (l *loginLimiter) Fail(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||
return
|
||||
}
|
||||
l.attempts[ip] = append(l.attempts[ip], time.Now())
|
||||
}
|
||||
func (l *loginLimiter) Reset(ip string) {
|
||||
@@ -2072,3 +2400,21 @@ func (l *loginLimiter) Reset(ip string) {
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) cleanupLocked(now time.Time) {
|
||||
cutoff := now.Add(-loginAttemptWindow)
|
||||
for ip, values := range l.attempts {
|
||||
kept := values[:0]
|
||||
for _, value := range values {
|
||||
if value.After(cutoff) {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.attempts, ip)
|
||||
} else {
|
||||
l.attempts[ip] = kept
|
||||
}
|
||||
}
|
||||
l.lastCleanup = now
|
||||
}
|
||||
+595
-4
@@ -4,22 +4,100 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
func TestDNSMutationHoldsUpdaterLockAndRequiresCSRF(t *testing.T) {
|
||||
worker := scheduler.New(nil, nil, nil, nil, nil)
|
||||
server := &Server{scheduler: worker}
|
||||
session := &model.Session{CSRFToken: "valid"}
|
||||
called := false
|
||||
handler := server.dnsChange(func(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||
called = true
|
||||
if _, err := worker.Run(r.Context()); !errors.Is(err, scheduler.ErrRunInProgress) {
|
||||
t.Fatalf("updater entered mutation: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
for _, csrf := range []string{"wrong", "valid"} {
|
||||
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf="+csrf))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, request, session)
|
||||
if csrf == "wrong" && (called || response.Code != http.StatusForbidden) {
|
||||
t.Fatal("invalid CSRF entered mutation")
|
||||
}
|
||||
if csrf == "valid" && (!called || response.Code != http.StatusNoContent) {
|
||||
t.Fatal("valid mutation was blocked")
|
||||
}
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
unlock, err := worker.LockChanges(waitCtx)
|
||||
if err != nil {
|
||||
t.Fatal("mutation did not release lock")
|
||||
}
|
||||
defer unlock()
|
||||
called = false
|
||||
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf=valid"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, request.WithContext(ctx), session)
|
||||
if called || response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatal("canceled edit entered lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedEndpointsCanBeSavedWhileDNSIsUnavailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
settings, err := database.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(ctx, 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No resolver/provider: unchanged endpoints must not require a network call.
|
||||
server := &Server{store: database, scheduler: scheduler.New(database, nil, nil, nil, nil)}
|
||||
form := url.Values{"csrf": {"valid"}, "interval": {"600"}, "ipv4_endpoints": {strings.Join(settings.IPv4Endpoints, "\n")}, "ipv6_endpoints": {strings.Join(settings.IPv6Endpoints, "\n")}}
|
||||
request := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
server.dnsChange(server.settingsSave)(response, request, &model.Session{CSRFToken: "valid", Role: "admin"})
|
||||
settings, err = database.GetSettings(ctx)
|
||||
if err != nil || response.Code != http.StatusSeeOther || settings.UpdaterEnabled || settings.UpdateIntervalSeconds != 600 {
|
||||
t.Fatalf("offline settings save failed: status=%d enabled=%v err=%v", response.Code, settings.UpdaterEnabled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputNormalizers(t *testing.T) {
|
||||
t.Parallel()
|
||||
username, err := normalizeUsername("Alice_Admin")
|
||||
@@ -199,6 +277,31 @@ func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormAcceptsMultipartUpdateFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("csrf", "csrf-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.WriteField("version", "v1.4.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/system/update", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
response := httptest.NewRecorder()
|
||||
server := &Server{}
|
||||
if err := server.parseForm(response, request); err != nil {
|
||||
t.Fatalf("multipart update form was rejected: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if request.FormValue("csrf") != "csrf-token" || request.FormValue("version") != "v1.4.2" {
|
||||
t.Fatalf("multipart fields were not parsed: %#v", request.Form)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
@@ -223,6 +326,459 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||
if strings.Contains(body, `class="flash"`) {
|
||||
t.Error("legacy in-flow flash message is still rendered")
|
||||
}
|
||||
for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} {
|
||||
if strings.Contains(body, removed) {
|
||||
t.Errorf("login still renders removed security marketing copy %q", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
now := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
|
||||
data := viewData{
|
||||
Title: "Übersicht",
|
||||
Page: "dashboard",
|
||||
CSRF: "csrf-token",
|
||||
User: model.User{DisplayName: "Admin", Username: "admin"},
|
||||
IsAdmin: true,
|
||||
Dashboard: model.Dashboard{
|
||||
Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true},
|
||||
SchedulerRunning: true,
|
||||
RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: now, Status: "running"}},
|
||||
Zones: []model.Zone{{ID: 7, Name: "example.de", LastSyncAt: now}},
|
||||
},
|
||||
}
|
||||
if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil {
|
||||
t.Fatalf("render dashboard: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{
|
||||
`data-run-now-button disabled`,
|
||||
`data-dashboard-ipv4`,
|
||||
`data-dashboard-ipv6`,
|
||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||
`Aktualisierung läuft`,
|
||||
`features-30`,
|
||||
`data-local-time="1788001920"`,
|
||||
`dashboard-zone-item`,
|
||||
`DNS-Zone · example.de`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("rendered dashboard is missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerAPIIncludesRecentRunsOnlyForAdministrators(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() })
|
||||
runID, err := database.CreateUpdateRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.FinishUpdateRun(ctx, runID, "ok", "198.51.100.10", "", "1 Ziel aktualisiert"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := scheduler.New(database, nil, nil, nil, slog.Default())
|
||||
server := &Server{store: database, scheduler: schedule}
|
||||
|
||||
adminResponse := httptest.NewRecorder()
|
||||
adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||
server.schedulerAPI(adminResponse, adminRequest, &model.Session{Role: "admin"})
|
||||
if adminResponse.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected admin response: status=%d body=%s", adminResponse.Code, adminResponse.Body.String())
|
||||
}
|
||||
if got := adminResponse.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("unexpected cache policy: %q", got)
|
||||
}
|
||||
var adminState schedulerResponse
|
||||
if err := json.NewDecoder(adminResponse.Body).Decode(&adminState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adminState.RecentRuns == nil || len(*adminState.RecentRuns) != 1 {
|
||||
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" {
|
||||
t.Fatalf("unexpected recent run: %#v", run)
|
||||
}
|
||||
|
||||
memberResponse := httptest.NewRecorder()
|
||||
memberRequest := httptest.NewRequest(http.MethodGet, "/api/v1/scheduler?runs=1", nil)
|
||||
server.schedulerAPI(memberResponse, memberRequest, &model.Session{Role: "member"})
|
||||
var memberState schedulerResponse
|
||||
if err := json.NewDecoder(memberResponse.Body).Decode(&memberState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memberState.RecentRuns != nil {
|
||||
t.Fatal("member received administrator-only update runs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
limiter := newLoginLimiter()
|
||||
limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)}
|
||||
limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second)
|
||||
if !limiter.Allow("fresh") {
|
||||
t.Fatal("fresh client was rejected after expired entries should have been pruned")
|
||||
}
|
||||
if _, exists := limiter.attempts["expired"]; exists {
|
||||
t.Fatal("expired login limiter entry was not pruned")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for index := 0; index < loginLimiterMaxTrackedIPs; index++ {
|
||||
limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now}
|
||||
}
|
||||
limiter.lastCleanup = now
|
||||
if limiter.Allow("overflow") {
|
||||
t.Fatal("untracked client was accepted after the bounded limiter reached capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembersCannotSeeOrUseAccessAdministration(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() })
|
||||
hash, err := secure.HashPassword("a sufficiently secure test password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const rawToken = "member-session-token"
|
||||
if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{config: config.Config{}, store: database}
|
||||
handler := server.Handler()
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{method: http.MethodGet, path: "/access"},
|
||||
{method: http.MethodGet, path: "/users"},
|
||||
{method: http.MethodGet, path: "/users/new"},
|
||||
{method: http.MethodGet, path: "/system"},
|
||||
{method: http.MethodGet, path: "/errors/diagnostics"},
|
||||
{method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"},
|
||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||
}
|
||||
for _, test := range requests {
|
||||
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken})
|
||||
if test.body != "" {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false}
|
||||
if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
if !strings.Contains(body, `href="/account"`) {
|
||||
t.Error("member navigation does not expose the personal account")
|
||||
}
|
||||
for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("member UI exposes administrator control %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}}
|
||||
if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("immutable administrator access UI is missing %q", expected)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("administrator detail renders editable permission input %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}}
|
||||
if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
navStart, navEnd := strings.Index(body, "<nav "), strings.Index(body, "</nav>")
|
||||
if navStart < 0 || navEnd <= navStart {
|
||||
t.Fatal("administrator navigation was not rendered")
|
||||
}
|
||||
navigation := body[navStart:navEnd]
|
||||
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff & Sicherheit") {
|
||||
t.Fatal("combined access administration entry is missing")
|
||||
}
|
||||
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
|
||||
t.Fatal("removed credential policy card is still rendered")
|
||||
}
|
||||
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
|
||||
if strings.Contains(navigation, legacy) {
|
||||
t.Errorf("legacy separate navigation entry remains: %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
checkedAt := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
|
||||
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", LastCheckedAt: checkedAt, UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true, CredentialUsername: "registry-user"}
|
||||
var notification bytes.Buffer
|
||||
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||
if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-banner="v2.0.0"`, `data-software-update-form`, `data-software-update-overlay`} {
|
||||
if !strings.Contains(notification.String(), 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
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Privater Zugang gespeichert", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
||||
if !strings.Contains(page.String(), expected) {
|
||||
t.Errorf("software update page is missing %q", expected)
|
||||
}
|
||||
}
|
||||
var freshPage bytes.Buffer
|
||||
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
|
||||
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Bereit", "Privater Zugang (optional)"} {
|
||||
if !strings.Contains(freshPage.String(), expected) {
|
||||
t.Errorf("fresh software update page is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(freshPage.String(), "Update-Kanal") {
|
||||
t.Error("confusing legacy update channel box is still rendered")
|
||||
}
|
||||
var currentPage bytes.Buffer
|
||||
currentData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.1", LatestVersion: "v1.4.1", LastCheckedAt: checkedAt, AgentAvailable: true, AgentCompatible: true, CredentialConfigured: true}}
|
||||
if err := templates["system"].ExecuteTemplate(¤tPage, "system", currentData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(currentPage.String(), `Status</span><strong>Aktuell</strong>`) || strings.Count(currentPage.String(), `>v1.4.1<`) != 1 {
|
||||
t.Fatal("an installed current release is still rendered as a second available version")
|
||||
}
|
||||
var syncPage bytes.Buffer
|
||||
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
|
||||
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(syncPage.String(), "Compose einmal aktualisieren") || strings.Contains(syncPage.String(), "Jetzt aktualisieren") {
|
||||
t.Fatal("compose synchronization state was not rendered cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{Title: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
|
||||
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("user creation form is missing %q", expected)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
|
||||
start := strings.Index(body, field)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
end := strings.Index(body[start:], ">")
|
||||
if end < 0 {
|
||||
t.Fatalf("password input %q has no closing bracket", field)
|
||||
}
|
||||
if strings.Contains(body[start:start+end], "minlength=") {
|
||||
t.Errorf("password input %q still has a minimum length", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "Ersteinrichtung",
|
||||
Page: "setup",
|
||||
Mode: "lan",
|
||||
CSRF: "csrf",
|
||||
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
|
||||
}
|
||||
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
|
||||
t.Fatalf("render setup: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("first-run onboarding is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Kein E-Mail-Reset") {
|
||||
t.Error("first-run onboarding still renders the removed password reset footnote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
||||
@@ -238,7 +794,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: true},
|
||||
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
||||
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
||||
}
|
||||
@@ -247,11 +803,11 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
||||
}
|
||||
body := rendered.String()
|
||||
statusAt := strings.Index(body, `A unverändert`)
|
||||
timeAt := strings.Index(body, `class="target-meta"><time>`)
|
||||
timeAt := strings.Index(body, `class="target-meta"><time data-local-time=`)
|
||||
if timeAt < 0 || statusAt < timeAt {
|
||||
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
||||
}
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("domain detail is missing %q", expected)
|
||||
}
|
||||
@@ -373,14 +929,23 @@ func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T)
|
||||
|
||||
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
var phase atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if phase.Load() == 2 {
|
||||
http.Error(w, "Pi-hole unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
||||
if phase.Load() == 1 {
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.21 nas.lan"]}}}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
||||
}
|
||||
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
@@ -425,6 +990,10 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
|
||||
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" {
|
||||
t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err)
|
||||
}
|
||||
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
|
||||
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.20 nas.lan" {
|
||||
t.Fatalf("initial import not persisted: %#v %v", links, err)
|
||||
}
|
||||
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -433,6 +1002,28 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
|
||||
if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") {
|
||||
t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err)
|
||||
}
|
||||
// A successful refresh persists the new state without a subsequent page
|
||||
// load; an outage must retain that state instead of treating it as empty.
|
||||
for _, step := range []int32{1, 2} {
|
||||
phase.Store(step)
|
||||
id := strconv.FormatInt(integrations[0].ID, 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/refresh", strings.NewReader("csrf=csrf-token"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("id", id)
|
||||
response := httptest.NewRecorder()
|
||||
server.piHoleRefresh(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("refresh response: %d", response.Code)
|
||||
}
|
||||
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
|
||||
if err != nil || len(links) != 2 || links[0].Active || !links[1].Active || links[1].Value != "192.168.1.21 nas.lan" {
|
||||
t.Fatalf("refresh lost cached records in phase %d: %#v %v", step, links, err)
|
||||
}
|
||||
integration, err := database.GetPiHoleIntegration(ctx, integrations[0].ID)
|
||||
if err != nil || (integration.LastError != "") != (step == 2) {
|
||||
t.Fatalf("refresh status incorrect: %#v %v", integration, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
||||
|
||||
+468
-9
@@ -1,4 +1,32 @@
|
||||
(() => {
|
||||
const localTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
for (const node of document.querySelectorAll('[data-local-time]')) {
|
||||
const unixTime = Number(node.dataset.localTime || 0);
|
||||
if (!Number.isFinite(unixTime) || unixTime <= 0) continue;
|
||||
node.textContent = localTimeFormatter.format(new Date(unixTime * 1000)).replace(',', ' ·');
|
||||
}
|
||||
|
||||
const updateBanner = document.querySelector('[data-software-update-banner]');
|
||||
if (updateBanner) {
|
||||
const version = updateBanner.dataset.softwareUpdateBanner || 'unknown';
|
||||
const storageKey = 'domain-control:update-banner-seen-version';
|
||||
try {
|
||||
if (window.localStorage.getItem(storageKey) !== version) {
|
||||
window.localStorage.setItem(storageKey, version);
|
||||
window.requestAnimationFrame(() => updateBanner.classList.add('should-animate'));
|
||||
}
|
||||
} catch {
|
||||
// A blocked storage API must not make the banner flicker on every page.
|
||||
}
|
||||
}
|
||||
|
||||
const toast = document.querySelector('[data-toast]');
|
||||
if (toast) {
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
@@ -91,6 +119,32 @@
|
||||
syncIPv6Settings();
|
||||
}
|
||||
|
||||
const setupForm = document.querySelector('[data-setup-form]');
|
||||
if (setupForm) {
|
||||
const password = setupForm.querySelector('[data-setup-password]');
|
||||
const confirmation = setupForm.querySelector('[data-setup-confirm]');
|
||||
const lengthHint = setupForm.querySelector('[data-password-length]');
|
||||
const matchHint = setupForm.querySelector('[data-password-match]');
|
||||
|
||||
const syncPasswordGuidance = () => {
|
||||
const length = password ? [...password.value].length : 0;
|
||||
const longEnough = length >= 15 && length <= 128;
|
||||
const matches = Boolean(confirmation?.value) && confirmation.value === password?.value;
|
||||
lengthHint?.classList.toggle('is-valid', longEnough);
|
||||
matchHint?.classList.toggle('is-valid', matches);
|
||||
lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending');
|
||||
matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending');
|
||||
password?.classList.toggle('is-valid', longEnough);
|
||||
confirmation?.classList.toggle('is-valid', matches);
|
||||
setupForm.classList.toggle('is-password-ready', longEnough && matches);
|
||||
confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : '');
|
||||
};
|
||||
|
||||
password?.addEventListener('input', syncPasswordGuidance);
|
||||
confirmation?.addEventListener('input', syncPasswordGuidance);
|
||||
syncPasswordGuidance();
|
||||
}
|
||||
|
||||
for (const form of document.querySelectorAll('[data-target-form]')) {
|
||||
const active = form.querySelector('[data-target-active]');
|
||||
const staticFields = form.querySelector('[data-static-ip-fields]');
|
||||
@@ -226,13 +280,327 @@
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
if (countdowns.length === 0) return;
|
||||
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
|
||||
if (softwareUpdateForms.length > 0) {
|
||||
const overlay = document.querySelector('[data-software-update-overlay]');
|
||||
const title = overlay?.querySelector('[data-software-update-title]');
|
||||
const message = overlay?.querySelector('[data-software-update-message]');
|
||||
const progress = overlay?.querySelector('[data-software-update-progress]');
|
||||
const progressTrack = overlay?.querySelector('[data-software-update-progress-track]');
|
||||
const progressLabel = overlay?.querySelector('[data-software-update-progress-label]');
|
||||
const percent = overlay?.querySelector('[data-software-update-percent]');
|
||||
const close = overlay?.querySelector('[data-software-update-close]');
|
||||
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
|
||||
const phaseOrder = ['pulling', 'restarting', 'verifying'];
|
||||
const phasePresentation = {
|
||||
queued: {title: 'Update wird vorbereitet', label: 'Vorbereitung', start: 4, end: 9, timeConstant: 10000},
|
||||
pulling: {title: 'Image wird sicher geladen', label: 'Image-Download', start: 12, end: 42, timeConstant: 22000},
|
||||
restarting: {title: 'Container wird gewechselt', label: 'Containerwechsel', start: 44, end: 51, timeConstant: 6000},
|
||||
reconnecting: {title: 'Verbindung wird aufgebaut', label: 'Neustart', start: 52, end: 57, timeConstant: 15000},
|
||||
verifying: {title: 'Neue Instanz wird geprüft', label: 'Healthcheck', start: 58, end: 98, timeConstant: 35000},
|
||||
complete: {title: 'Update erfolgreich', label: 'Abgeschlossen', start: 100, end: 100, timeConstant: 1},
|
||||
failed: {title: 'Update nicht abgeschlossen', label: 'Angehalten'},
|
||||
};
|
||||
let polling = false;
|
||||
let targetVersion = '';
|
||||
let startedAt = 0;
|
||||
let lastKnownPhase = 'pulling';
|
||||
let currentPhase = '';
|
||||
let phaseStartedAt = 0;
|
||||
let displayedProgress = 0;
|
||||
let progressTimer;
|
||||
const progressTickInterval = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1000 : 500;
|
||||
|
||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
||||
let running = countdowns[0].dataset.running === 'true';
|
||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
||||
const formatElapsed = (milliseconds) => {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const stopProgressTimer = () => {
|
||||
if (progressTimer) window.clearTimeout(progressTimer);
|
||||
progressTimer = undefined;
|
||||
};
|
||||
|
||||
const renderProgress = (value) => {
|
||||
const bounded = Math.max(0, Math.min(100, value));
|
||||
displayedProgress = Math.max(displayedProgress, bounded);
|
||||
if (progress) progress.style.width = `${displayedProgress.toFixed(2)}%`;
|
||||
if (percent) percent.textContent = `${Math.floor(displayedProgress)}%`;
|
||||
progressTrack?.setAttribute('aria-valuenow', String(Math.floor(displayedProgress)));
|
||||
};
|
||||
|
||||
const phaseTime = (raw) => {
|
||||
const now = Date.now();
|
||||
const parsed = Date.parse(String(raw || ''));
|
||||
if (Number.isFinite(parsed) && parsed >= now - (12 * 60 * 1000) && parsed <= now + 60000) {
|
||||
return Math.min(parsed, now);
|
||||
}
|
||||
return now;
|
||||
};
|
||||
|
||||
const tickProgress = () => {
|
||||
progressTimer = undefined;
|
||||
const presentation = phasePresentation[currentPhase];
|
||||
if (!presentation || typeof presentation.end !== 'number' || presentation.end <= presentation.start) return;
|
||||
const elapsed = Math.max(0, Date.now() - phaseStartedAt);
|
||||
const distance = presentation.end - presentation.start;
|
||||
const next = presentation.start + distance * (1 - Math.exp(-elapsed / presentation.timeConstant));
|
||||
renderProgress(next);
|
||||
if (progressLabel) {
|
||||
progressLabel.textContent = currentPhase === 'verifying' || currentPhase === 'reconnecting'
|
||||
? `${presentation.label} · ${formatElapsed(elapsed)}`
|
||||
: presentation.label;
|
||||
}
|
||||
progressTimer = window.setTimeout(tickProgress, progressTickInterval);
|
||||
};
|
||||
|
||||
const setUpdatePhase = (phase, text, updatedAt = '') => {
|
||||
if (!overlay) return;
|
||||
const phaseChanged = currentPhase !== phase;
|
||||
if (phaseChanged) {
|
||||
currentPhase = phase;
|
||||
phaseStartedAt = phaseTime(updatedAt);
|
||||
stopProgressTimer();
|
||||
} else if (updatedAt) {
|
||||
phaseStartedAt = Math.min(phaseStartedAt, phaseTime(updatedAt));
|
||||
}
|
||||
overlay.dataset.updateState = phase;
|
||||
overlay.setAttribute('aria-busy', String(phase !== 'complete' && phase !== 'failed'));
|
||||
const presentation = phasePresentation[phase] || phasePresentation.queued;
|
||||
if (title) title.textContent = presentation.title;
|
||||
if (progressLabel) progressLabel.textContent = presentation.label;
|
||||
if (typeof presentation.start === 'number') renderProgress(presentation.start);
|
||||
if (phaseChanged && presentation.end > presentation.start) tickProgress();
|
||||
if (message && text) message.textContent = text;
|
||||
if (phaseOrder.includes(phase)) lastKnownPhase = phase;
|
||||
const visualPhase = phase === 'reconnecting' || phase === 'failed' ? lastKnownPhase : phase;
|
||||
const currentIndex = phaseOrder.indexOf(visualPhase);
|
||||
for (const node of phaseNodes) {
|
||||
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
|
||||
node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed');
|
||||
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
|
||||
}
|
||||
};
|
||||
|
||||
const pollSoftwareUpdate = async () => {
|
||||
if (!polling) return;
|
||||
if (Date.now() - startedAt > 12 * 60 * 1000) {
|
||||
polling = false;
|
||||
setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.');
|
||||
if (close) close.hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('status unavailable');
|
||||
const state = await response.json();
|
||||
const phase = state.phase || 'queued';
|
||||
const messages = {
|
||||
queued: 'Update ist eingereiht.',
|
||||
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
|
||||
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
|
||||
verifying: 'Die neue Instanz durchläuft den Healthcheck. Dieser Schritt kann etwas dauern.',
|
||||
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
|
||||
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
|
||||
};
|
||||
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at);
|
||||
if (phase === 'complete') {
|
||||
polling = false;
|
||||
window.setTimeout(() => window.location.assign('/system'), 1400);
|
||||
return;
|
||||
}
|
||||
if (phase === 'failed') {
|
||||
polling = false;
|
||||
if (close) close.hidden = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
const recoveryPhase = currentPhase === 'verifying' ? 'verifying' : 'reconnecting';
|
||||
const recoveryMessage = recoveryPhase === 'verifying'
|
||||
? 'Der Healthcheck läuft weiter – die Statusverbindung wird automatisch wiederhergestellt.'
|
||||
: 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.';
|
||||
setUpdatePhase(recoveryPhase, recoveryMessage);
|
||||
}
|
||||
window.setTimeout(pollSoftwareUpdate, 1500);
|
||||
};
|
||||
|
||||
for (const form of softwareUpdateForms) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (polling || !overlay) return;
|
||||
const formData = new FormData(form);
|
||||
const requestBody = new URLSearchParams();
|
||||
for (const [name, value] of formData) {
|
||||
if (typeof value === 'string') requestBody.append(name, value);
|
||||
}
|
||||
targetVersion = String(formData.get('version') || '');
|
||||
stopProgressTimer();
|
||||
currentPhase = '';
|
||||
phaseStartedAt = 0;
|
||||
displayedProgress = 0;
|
||||
lastKnownPhase = 'pulling';
|
||||
if (progress) progress.style.width = '0%';
|
||||
if (percent) percent.textContent = '0%';
|
||||
progressTrack?.setAttribute('aria-valuenow', '0');
|
||||
startedAt = Date.now();
|
||||
overlay.hidden = false;
|
||||
if (close) close.hidden = true;
|
||||
document.body.classList.add('software-update-open');
|
||||
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
|
||||
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
|
||||
try {
|
||||
const response = await fetch(form.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let result = {};
|
||||
if (contentType.includes('application/json')) {
|
||||
result = await response.json().catch(() => ({}));
|
||||
} else {
|
||||
const responseText = (await response.text()).trim();
|
||||
if (responseText && !responseText.startsWith('<')) result.message = responseText.slice(0, 240);
|
||||
}
|
||||
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
|
||||
polling = true;
|
||||
window.setTimeout(pollSoftwareUpdate, 400);
|
||||
} catch (error) {
|
||||
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
|
||||
if (close) close.hidden = false;
|
||||
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close?.addEventListener('click', () => {
|
||||
overlay.hidden = true;
|
||||
document.body.classList.remove('software-update-open');
|
||||
close.hidden = true;
|
||||
stopProgressTimer();
|
||||
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
|
||||
});
|
||||
}
|
||||
|
||||
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 currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
||||
const dashboardIPv6 = document.querySelector('[data-dashboard-ipv6]');
|
||||
const recentRuns = document.querySelector('[data-recent-runs]');
|
||||
const runNowButton = document.querySelector('[data-run-now-button]');
|
||||
if (countdowns.length === 0 && currentIPv4Fields.length === 0 && !dashboardIPv4 && !dashboardIPv6 && !recentRuns && !runNowButton) return;
|
||||
|
||||
let nextRun = Number(countdowns[0]?.dataset.countdown || 0);
|
||||
let running = countdowns[0]?.dataset.running === 'true';
|
||||
let enabled = countdowns[0]?.dataset.enabled !== 'false';
|
||||
let lastRefresh = 0;
|
||||
let refreshing = false;
|
||||
let recentRunsSignature = '';
|
||||
|
||||
const formatRemaining = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
@@ -242,23 +610,106 @@
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const runStatusClass = (value) => {
|
||||
const status = String(value || '').toLowerCase();
|
||||
if (status.includes('ok') || status.includes('unverändert') || status.includes('aktualisiert') || status.includes('angelegt')) return 'status-good';
|
||||
if (status.includes('error') || status.includes('fehler') || status.includes('partial')) return 'status-bad';
|
||||
return 'status-warn';
|
||||
};
|
||||
|
||||
const formatRunTime = (unixTime) => {
|
||||
const date = new Date(Number(unixTime || 0) * 1000);
|
||||
if (Number.isNaN(date.getTime())) return '–';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}.${month}.${date.getFullYear()} · ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const renderRecentRuns = (runs) => {
|
||||
if (!recentRuns || !Array.isArray(runs)) return;
|
||||
const signature = JSON.stringify(runs.map((run) => [run.id, run.started_at_unix, run.status, run.summary]));
|
||||
if (signature === recentRunsSignature) return;
|
||||
recentRunsSignature = signature;
|
||||
|
||||
if (runs.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.textContent = 'Noch kein Update-Lauf protokolliert.';
|
||||
recentRuns.replaceChildren(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'data-list';
|
||||
for (const run of runs) {
|
||||
const status = String(run.status || 'unbekannt');
|
||||
const statusClass = runStatusClass(status);
|
||||
const summary = String(run.summary || '').trim() || (status === 'running' ? 'Aktualisierung läuft' : 'Update-Lauf');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'data-row';
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `state-dot ${statusClass}`;
|
||||
const details = document.createElement('span');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = summary;
|
||||
const time = document.createElement('small');
|
||||
time.textContent = formatRunTime(run.started_at_unix);
|
||||
details.append(title, time);
|
||||
const pill = document.createElement('span');
|
||||
pill.className = `status-pill ${statusClass}`;
|
||||
pill.textContent = status;
|
||||
row.append(dot, details, pill);
|
||||
list.append(row);
|
||||
}
|
||||
recentRuns.replaceChildren(list);
|
||||
};
|
||||
|
||||
const renderScheduleState = (state) => {
|
||||
const lastIPv4 = typeof state.last_ipv4 === 'string' ? state.last_ipv4.trim() : '';
|
||||
const lastIPv6 = typeof state.last_ipv6 === 'string' ? state.last_ipv6.trim() : '';
|
||||
if (lastIPv4) {
|
||||
for (const field of currentIPv4Fields) field.placeholder = lastIPv4;
|
||||
}
|
||||
if (dashboardIPv4) dashboardIPv4.textContent = lastIPv4 || 'Nicht ermittelt';
|
||||
if (dashboardIPv6) dashboardIPv6.textContent = state.ipv6_enabled === false ? 'Deaktiviert' : (lastIPv6 || 'Nicht ermittelt');
|
||||
if (runNowButton) {
|
||||
runNowButton.disabled = !enabled || running;
|
||||
runNowButton.textContent = running ? 'Update läuft' : 'Update jetzt ausführen';
|
||||
if (!enabled) runNowButton.title = 'Updater ist deaktiviert';
|
||||
else runNowButton.removeAttribute('title');
|
||||
}
|
||||
if (recentRuns) recentRuns.setAttribute('aria-busy', String(running));
|
||||
renderRecentRuns(state.recent_runs);
|
||||
};
|
||||
|
||||
const refreshSchedule = async () => {
|
||||
if (document.hidden || refreshing) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < 2500) return;
|
||||
lastRefresh = now;
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/scheduler', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
const endpoint = recentRuns ? '/api/v1/scheduler?runs=1' : '/api/v1/scheduler';
|
||||
const response = await fetch(endpoint, {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) return;
|
||||
const state = await response.json();
|
||||
nextRun = Number(state.next_run_unix || 0);
|
||||
running = Boolean(state.running);
|
||||
enabled = state.enabled !== false;
|
||||
renderScheduleState(state);
|
||||
} catch {
|
||||
// The visible countdown keeps its last known state when the status request fails.
|
||||
// Visible scheduler data keeps its last known state when the request fails.
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (document.hidden) return;
|
||||
if (countdowns.length === 0) return;
|
||||
if (!enabled) {
|
||||
for (const node of countdowns) {
|
||||
node.textContent = 'Inaktiv';
|
||||
@@ -276,8 +727,16 @@
|
||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||
};
|
||||
|
||||
tick();
|
||||
if (countdowns.length > 0) {
|
||||
tick();
|
||||
window.setInterval(tick, 1000);
|
||||
}
|
||||
refreshSchedule();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setInterval(refreshSchedule, 30000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
lastRefresh = 0;
|
||||
tick();
|
||||
refreshSchedule();
|
||||
});
|
||||
})();
|
||||
+389
-11
@@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
||||
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
@@ -59,12 +60,15 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
||||
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
||||
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
||||
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.nav-update-dot { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.14), 0 0 12px rgba(96,165,250,.42); }
|
||||
.sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.sidebar-account-link { display: grid; min-width: 0; grid-template-columns: 32px minmax(0,1fr); gap: 9px; align-items: center; padding: 3px; border-radius: 7px; }
|
||||
.sidebar-account-link:hover { background: #1b2739; color: #fff; }
|
||||
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
||||
.sidebar-account small { color: #748298; font-size: 9px; }
|
||||
.sidebar-account form { display: contents; }
|
||||
.sidebar-account form { display: block; }
|
||||
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
||||
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
||||
|
||||
@@ -72,10 +76,22 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
||||
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
||||
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); }
|
||||
.software-update-banner.should-animate { animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
|
||||
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
|
||||
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.software-update-banner div > strong { margin-top: 2px; font-size: 12px; }
|
||||
.software-update-banner-actions { display: flex; gap: 7px; align-items: center; }
|
||||
.software-update-banner-actions form { display: contents; }
|
||||
.software-update-banner .button-quiet { border-color: rgba(191,219,254,.2); background: rgba(255,255,255,.055); color: #dbeafe; }
|
||||
.software-update-banner .button-quiet:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
||||
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
||||
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
||||
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
|
||||
.auth-shell { display: grid; min-height: 100vh; min-height: 100svh; place-items: center; padding: clamp(18px, 4vw, 48px); background: radial-gradient(circle at 12% 18%, rgba(37,99,235,.1), transparent 28%), linear-gradient(145deg, #edf2f7 0%, #f8fafc 55%, #eef2f6 100%); }
|
||||
.page-setup { overflow: hidden; }
|
||||
.page-setup .auth-shell { width: 100%; height: 100vh; height: 100svh; min-height: 0; overflow: hidden; padding: 0; }
|
||||
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
||||
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
||||
@@ -156,13 +172,32 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.status-pill.status-warn { background: var(--warning-soft); }
|
||||
.status-pill.status-bad { background: var(--danger-soft); }
|
||||
|
||||
.domain-list { display: grid; }
|
||||
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.domain-list > a:first-child { border-top: 0; }
|
||||
.domain-list strong, .domain-list small { display: block; }
|
||||
.domain-list strong { font-size: 12px; }
|
||||
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.dashboard-zones-panel { overflow: hidden; background: radial-gradient(circle at 100% 0,rgba(37,99,235,.055),transparent 35%),var(--surface); }
|
||||
.dashboard-zone-count { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.dashboard-zone-list { display: grid; gap: 8px; }
|
||||
.dashboard-zone-item { display: grid; min-width: 0; grid-template-columns: 44px minmax(0,1fr) auto; gap: 11px; align-items: center; padding: 11px; border: 1px solid #e1e7ee; border-radius: 10px; background: linear-gradient(135deg,#fff,#f8faff); transition: border-color .18s, box-shadow .18s, transform .18s; }
|
||||
.dashboard-zone-item:hover { border-color: #b8cae4; color: inherit; box-shadow: 0 10px 24px rgba(31,64,111,.08); transform: translateY(-1px); }
|
||||
.dashboard-zone-glyph { position: relative; display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #bfd0ea; border-radius: 12px; background: linear-gradient(145deg,#edf4ff,#dfeaff); color: #315eaa; font-family: var(--mono); }
|
||||
.dashboard-zone-glyph::before, .dashboard-zone-glyph::after { position: absolute; border: 1px solid rgba(49,94,170,.2); border-radius: 50%; content: ""; }
|
||||
.dashboard-zone-glyph::before { width: 26px; height: 12px; }
|
||||
.dashboard-zone-glyph::after { width: 12px; height: 26px; }
|
||||
.dashboard-zone-glyph b { position: relative; z-index: 1; padding: 2px 3px; border-radius: 3px; background: #e7effd; font-size: 7px; }
|
||||
.dashboard-zone-glyph i { position: absolute; right: 5px; bottom: 5px; width: 5px; height: 5px; border: 1px solid #fff; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 2px rgba(15,118,110,.12); }
|
||||
.dashboard-zone-glyph.is-error { border-color: #ecc3ca; background: linear-gradient(145deg,#fff5f6,#ffe9ed); color: var(--danger); }
|
||||
.dashboard-zone-glyph.is-error i { background: var(--danger); box-shadow: 0 0 0 2px rgba(180,35,60,.11); }
|
||||
.dashboard-zone-copy, .dashboard-zone-copy > small, .dashboard-zone-copy > strong { display: block; min-width: 0; }
|
||||
.dashboard-domain-label { margin-bottom: 1px; color: #7a899b; font-family: var(--mono); font-size: 7px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.dashboard-zone-copy > strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-zone-branch { position: relative; display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 5px; align-items: center; margin-top: 6px; padding-top: 5px; border-top: 1px solid #ebeff5; }
|
||||
.dashboard-zone-branch > i { position: relative; width: 8px; height: 8px; border: 1px solid #8eacd4; border-radius: 3px; background: #edf4ff; }
|
||||
.dashboard-zone-branch > i::before { position: absolute; bottom: 6px; left: 3px; width: 1px; height: 9px; background: #c8d6e8; content: ""; }
|
||||
.dashboard-zone-branch span, .dashboard-zone-branch b, .dashboard-zone-branch small { display: block; min-width: 0; }
|
||||
.dashboard-zone-branch b { color: #536780; font-size: 8px; }
|
||||
.dashboard-zone-branch small { overflow: hidden; margin-top: 1px; color: #8795a6; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-zone-state { display: grid; gap: 8px; justify-items: end; }
|
||||
.dashboard-zone-state > i { color: #8799af; font-size: 14px; font-style: normal; transition: color .18s, transform .18s; }
|
||||
.dashboard-zone-item:hover .dashboard-zone-state > i { color: var(--accent); transform: translateX(3px); }
|
||||
.empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.audit-panel { margin-top: 12px; }
|
||||
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
|
||||
@@ -174,6 +209,7 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.zone-card:hover { border-color: #b6c4d4; }
|
||||
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.domain-service-label { display: inline-flex; min-height: 22px; align-items: center; padding: 0 7px; border: 1px solid #c7d6e8; border-radius: 5px; background: #f1f6fd; color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .03em; }
|
||||
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
|
||||
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
@@ -315,6 +351,91 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
||||
.security-strip span { color: var(--success); }
|
||||
.login-meta { text-align: center; }
|
||||
.login-card { position: relative; overflow: hidden; width: min(460px, 100%); padding: 34px; border-radius: 18px; }
|
||||
.login-card::before { position: absolute; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, var(--accent), #2dd4bf); content: ""; }
|
||||
.login-card-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.login-mode { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 99px; background: var(--warning-soft); color: var(--warning); font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.login-mode.is-secure { border-color: #b4ddd4; background: #effaf7; color: var(--success); }
|
||||
.login-card h1 { margin-top: 36px; }
|
||||
.login-card .lede { margin-bottom: 24px; }
|
||||
.onboarding-shell { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: minmax(400px, .9fr) minmax(560px, 1.1fr); overflow: hidden; background: #f8fafc; }
|
||||
.onboarding-intro { position: relative; display: flex; min-width: 0; overflow: hidden; flex-direction: column; padding: clamp(34px, 4vw, 64px); background: radial-gradient(circle at 12% -5%, rgba(59,130,246,.27), transparent 34%), radial-gradient(circle at 105% 88%, rgba(45,212,191,.12), transparent 36%), linear-gradient(150deg, #0e1726 0%, #152238 100%); color: #fff; }
|
||||
.onboarding-intro::before { position: absolute; inset: 0; background-image: linear-gradient(rgba(148,163,184,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.035) 1px, transparent 1px); background-size: 38px 38px; content: ""; pointer-events: none; }
|
||||
.onboarding-intro::after { position: absolute; top: 0; right: 0; width: 1px; height: 100%; background: rgba(255,255,255,.12); content: ""; pointer-events: none; }
|
||||
.onboarding-brand-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.onboarding-edition { color: #718096; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.auth-brand-inverse { color: #f8fafc; }
|
||||
.auth-brand-inverse span { border: 1px solid #3a4b65; background: rgba(28,41,64,.88); color: #b9d0ff; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
|
||||
.onboarding-copy { position: relative; z-index: 1; margin-top: clamp(38px, 6vh, 68px); }
|
||||
.onboarding-eyebrow { display: inline-flex; margin-bottom: 15px; padding: 5px 8px; border: 1px solid rgba(147,164,186,.2); border-radius: 99px; background: rgba(15,23,42,.24); color: #9fb0c7; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.onboarding-copy h1 { max-width: 620px; margin-bottom: 16px; color: #f8fafc; font-size: clamp(40px, 4.3vw, 64px); line-height: 1; letter-spacing: -.06em; }
|
||||
.onboarding-copy p { max-width: 500px; margin: 0; color: #9aa8ba; font-size: clamp(11px, 1vw, 14px); line-height: 1.6; }
|
||||
.onboarding-progress { position: relative; z-index: 1; max-width: 520px; margin-top: clamp(28px, 4vh, 42px); }
|
||||
.onboarding-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #77869a; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.onboarding-progress strong { color: #aec8f6; font-size: 8px; }
|
||||
.onboarding-progress-track { display: block; height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: rgba(148,163,184,.14); }
|
||||
.onboarding-progress-track i { display: block; width: 33.333%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #3b82f6, #75a7ff); box-shadow: 0 0 16px rgba(59,130,246,.55); }
|
||||
.onboarding-steps { position: relative; z-index: 1; display: grid; max-width: 520px; gap: 7px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.onboarding-steps::before { position: absolute; top: 35px; bottom: 35px; left: 29px; z-index: -1; width: 1px; background: rgba(148,163,184,.14); content: ""; }
|
||||
.onboarding-steps li { position: relative; display: grid; min-height: 62px; grid-template-columns: 36px minmax(0,1fr); gap: 13px; align-items: center; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; color: #718096; }
|
||||
.onboarding-steps li > span { position: relative; z-index: 1; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(148,163,184,.16); border-radius: 10px; background: #152238; color: #718096; font-family: var(--mono); font-size: 9px; font-weight: 750; }
|
||||
.onboarding-steps strong, .onboarding-steps small { display: block; }
|
||||
.onboarding-steps strong { color: #bac5d4; font-size: 12px; }
|
||||
.onboarding-steps small { margin-top: 2px; color: #718096; font-size: 9px; line-height: 1.45; }
|
||||
.onboarding-step-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.onboarding-step-title em { padding: 3px 6px; border-radius: 99px; background: rgba(59,130,246,.18); color: #a8c7ff; font-family: var(--mono); font-size: 7px; font-style: normal; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.onboarding-steps .is-current { border-color: rgba(96,165,250,.26); background: linear-gradient(90deg, rgba(37,99,235,.18), rgba(37,99,235,.055)); box-shadow: inset 3px 0 #4f8ff7, 0 10px 28px rgba(2,8,23,.12); }
|
||||
.onboarding-steps .is-current > span { border-color: #4f8ff7; background: #2563eb; color: #fff; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
|
||||
.onboarding-steps .is-current strong { color: #fff; }
|
||||
.onboarding-card { display: grid; min-width: 0; place-items: center; padding: clamp(32px, 4vw, 64px) clamp(36px, 6vw, 96px) clamp(48px, 7vw, 96px); background: #f8fafc; }
|
||||
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||
.onboarding-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: clamp(20px, 3vh, 28px); color: #7b8797; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.setup-ready { display: inline-flex; align-items: center; gap: 8px; color: #4f6074; }
|
||||
.setup-ready i { width: 7px; height: 7px; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.1); }
|
||||
.onboarding-form-heading h2 { margin: 0 0 10px; font-size: clamp(30px, 3.2vw, 44px); line-height: 1.08; letter-spacing: -.05em; }
|
||||
.onboarding-lede { max-width: 590px; margin-bottom: clamp(18px, 2.6vh, 24px); color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.setup-form { gap: 14px; }
|
||||
.setup-field-row { gap: 16px; }
|
||||
.setup-form label { gap: 6px; color: #334155; font-size: 10px; }
|
||||
.setup-form input { min-height: 46px; border-radius: 8px; background: #fff; }
|
||||
.setup-form input.is-valid:not(:focus) { border-color: #91cfc3; box-shadow: 0 0 0 3px rgba(15,118,110,.055); }
|
||||
.password-guidance { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
|
||||
.password-guidance span { display: flex; min-width: 0; min-height: 38px; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid #dbe2e9; border-radius: 9px; background: #fff; color: #738093; font-size: 9px; font-weight: 700; line-height: 1.25; transition: border-color .24s ease, background .24s ease, color .24s ease, box-shadow .24s ease, transform .24s ease; }
|
||||
.password-guidance i { display: grid; width: 20px; height: 20px; flex: none; place-items: center; border-radius: 7px; background: #edf1f5; color: transparent; font-family: var(--mono); font-size: 10px; font-style: normal; font-weight: 900; transform: scale(.82); transition: background .24s ease, color .18s ease, transform .3s cubic-bezier(.2,1.5,.4,1); }
|
||||
.password-guidance span.is-valid { border-color: #a8dacf; background: linear-gradient(120deg, #f4fbf9, #edf9f6); color: #0d6f67; box-shadow: 0 5px 16px rgba(15,118,110,.08); transform: translateY(-1px); animation: guidance-pop .38s cubic-bezier(.2,.9,.3,1.35) both; }
|
||||
.password-guidance span.is-valid i { background: #0f8a7f; color: #fff; box-shadow: 0 4px 10px rgba(15,118,110,.2); transform: scale(1); animation: guidance-check .42s cubic-bezier(.2,1.55,.4,1) both; }
|
||||
.setup-submit { position: relative; width: 100%; min-height: 48px; overflow: hidden; margin-top: 2px; border-radius: 8px; transition: border-color .2s, background .2s, box-shadow .24s, transform .2s; }
|
||||
.setup-submit > span { position: relative; z-index: 1; transition: transform .22s ease; }
|
||||
.setup-submit:hover > span { transform: translateX(3px); }
|
||||
.setup-form.is-password-ready .setup-submit { box-shadow: 0 10px 24px rgba(37,99,235,.18); }
|
||||
.setup-form.is-password-ready .setup-submit::after { position: absolute; top: -80%; bottom: -80%; left: -35%; width: 24%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.48), transparent); content: ""; pointer-events: none; transform: rotate(16deg); animation: setup-button-sheen .75s ease-out both; }
|
||||
.onboarding-footer { margin-top: clamp(16px, 2.5vh, 22px); padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.onboarding-mode { display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 8px; align-items: center; color: #7a5a16; }
|
||||
.onboarding-mode > span { display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid #d7bd80; border-radius: 50%; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.onboarding-mode p { margin: 0; }
|
||||
.onboarding-mode strong, .onboarding-mode small { display: block; }
|
||||
.onboarding-mode strong { font-size: 9px; }
|
||||
.onboarding-mode small { margin-top: 1px; color: #887347; font-size: 8px; font-weight: 500; line-height: 1.4; }
|
||||
.onboarding-mode.is-secure { color: var(--success); }
|
||||
.onboarding-mode.is-secure > span { border-color: #8bc5ba; }
|
||||
.onboarding-mode.is-secure small { color: #59877f; }
|
||||
@keyframes setup-reveal { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes setup-panel-reveal { from { opacity: 0; transform: translateY(18px) scale(.988); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes setup-progress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@keyframes setup-ready-pulse { 0% { box-shadow: 0 0 0 0 rgba(34,160,107,.32); } 65% { box-shadow: 0 0 0 7px rgba(34,160,107,0); } 100% { box-shadow: 0 0 0 3px rgba(34,160,107,.1); } }
|
||||
@keyframes guidance-pop { 0% { transform: translateY(0) scale(.97); } 70% { transform: translateY(-2px) scale(1.015); } 100% { transform: translateY(-1px) scale(1); } }
|
||||
@keyframes guidance-check { 0% { opacity: 0; transform: scale(.35) rotate(-18deg); } 70% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { opacity: 1; transform: scale(1) rotate(0); } }
|
||||
@keyframes setup-button-sheen { from { left: -35%; } to { left: 118%; } }
|
||||
.onboarding-brand-row { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-copy { animation: setup-reveal .55s .06s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-progress { animation: setup-reveal .55s .12s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-progress-track i { transform-origin: left; animation: setup-progress-fill .75s .38s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-steps li { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.onboarding-steps li:nth-child(1) { animation-delay: .18s; }
|
||||
.onboarding-steps li:nth-child(2) { animation-delay: .24s; }
|
||||
.onboarding-steps li:nth-child(3) { animation-delay: .3s; }
|
||||
.onboarding-form-wrap { animation: setup-panel-reveal .58s .08s cubic-bezier(.2,.8,.3,1) both; }
|
||||
.setup-ready i { animation: setup-ready-pulse .8s .58s ease-out both; }
|
||||
.token-card { width: min(680px, 100%); }
|
||||
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
||||
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
||||
@@ -336,6 +457,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
||||
.row-arrow { color: var(--accent); }
|
||||
.access-panel { margin-top: 12px; }
|
||||
.access-policy-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.access-policy-card { display: grid; min-height: 76px; grid-template-columns: 36px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.access-policy-card.is-admin { border-color: #b9cdef; background: linear-gradient(120deg,#fff 62%,#f0f5ff); }
|
||||
.access-policy-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; background: #edf1f5; color: #526174; font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||
.access-policy-card.is-admin .access-policy-icon { background: var(--accent-soft); color: #315eaa; }
|
||||
.access-policy-card strong, .access-policy-card small { display: block; }
|
||||
.access-policy-card strong { font-size: 11px; }
|
||||
.access-policy-card small { margin-top: 2px; color: var(--muted); font-size: 8px; line-height: 1.4; }
|
||||
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
||||
.access-list { display: grid; gap: 9px; }
|
||||
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
||||
@@ -349,6 +478,153 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.access-target strong, .access-target small { display: block; }
|
||||
.access-target small { color: var(--muted); font-size: 8px; }
|
||||
.access-manage-switch { white-space: nowrap; }
|
||||
.admin-full-access-card { display: grid; grid-template-columns: 46px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 17px; border: 1px solid #acd8ce; border-radius: var(--radius); background: linear-gradient(110deg,#fff 65%,#eefaf7); box-shadow: var(--shadow); }
|
||||
.admin-full-access-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; background: #dff5ef; color: var(--success); font-family: var(--mono); font-size: 13px; font-weight: 900; }
|
||||
.admin-full-access-card h2 { margin-bottom: 3px; }
|
||||
.admin-full-access-card p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.account-password-panel { width: min(760px,100%); }
|
||||
.user-create-panel { width: min(930px,100%); }
|
||||
.user-create-panel .wide-form { max-width: none; }
|
||||
.user-create-grid { gap: 14px; align-items: stretch; }
|
||||
.user-create-grid > label { min-width: 0; align-content: start; }
|
||||
.user-create-grid input { height: 42px; }
|
||||
.user-create-identity-grid > label small { min-height: 27px; }
|
||||
.field-spacer { visibility: hidden; }
|
||||
|
||||
.software-version-card { position: relative; display: grid; min-height: 164px; overflow: hidden; grid-template-columns: 62px minmax(0,1fr) auto; gap: 20px; align-items: center; margin-bottom: 12px; padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow); }
|
||||
.software-version-card::after { position: absolute; top: -90px; right: -70px; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle,rgba(37,99,235,.08),transparent 68%); content: ""; pointer-events: none; }
|
||||
.software-version-card.has-update { border-color: #b4c9ec; background: linear-gradient(120deg,#fff 55%,#f1f6ff); box-shadow: 0 16px 38px rgba(37,99,235,.09); }
|
||||
.software-version-visual { position: relative; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid #cbd8e8; border-radius: 16px; background: #f4f7fb; color: #315eaa; font-family: var(--mono); font-size: 11px; font-weight: 900; }
|
||||
.software-version-visual i { position: absolute; right: -3px; bottom: -3px; width: 14px; height: 14px; border: 3px solid #fff; border-radius: 50%; background: #22a06b; }
|
||||
.software-version-copy { min-width: 0; }
|
||||
.software-version-values { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
|
||||
.software-version-cell { display: grid; min-width: 150px; gap: 3px; align-content: center; padding-right: 24px; }
|
||||
.software-version-cell + .software-version-cell { padding-left: 24px; border-left: 1px solid var(--line); }
|
||||
.software-version-cell span { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.software-version-cell strong { color: var(--text); font-family: var(--mono); font-size: clamp(22px,2.6vw,32px); line-height: 1.1; letter-spacing: -.04em; }
|
||||
.software-version-cell.is-latest strong { color: var(--accent); }
|
||||
.software-version-state { margin: 9px 0 0; color: var(--muted); font-size: 10px; }
|
||||
.software-version-meta { display: flex; flex-wrap: wrap; gap: 6px 15px; margin-top: 10px; color: var(--muted); font-size: 8px; }
|
||||
.software-version-meta b { color: #4b5c70; font-weight: 750; }
|
||||
.software-version-action { position: relative; z-index: 1; }
|
||||
.software-check-button { gap: 7px; }
|
||||
.software-check-button i { font-style: normal; font-size: 15px; line-height: 1; transition: transform .2s ease; }
|
||||
.software-check-button:hover i { transform: rotate(45deg); }
|
||||
.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: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.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-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #a9d8ce; border-radius: 12px; background: linear-gradient(145deg,#e9faf6,#d9f3ed); color: var(--success); box-shadow: 0 8px 18px rgba(15,118,110,.08); font-family: var(--mono); font-size: 15px; font-weight: 900; }
|
||||
.update-access-state .section-label { margin-bottom: 2px; color: #598b82; }
|
||||
.update-access-state h2 { color: #174f49; }
|
||||
.update-access-state p { margin: 3px 0 0; color: var(--muted); font-size: 9px; }
|
||||
.update-access-state p strong { color: #315f5a; font-family: var(--mono); }
|
||||
.update-access-state .status-pill { gap: 6px; }
|
||||
.update-access-state .status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #1d9b87; box-shadow: 0 0 0 3px rgba(29,155,135,.11); }
|
||||
.update-credential-editor { border-top: 1px solid #d8e9e5; background: rgba(255,255,255,.72); }
|
||||
.update-credential-editor summary { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 18px; color: #315f5a; list-style: none; }
|
||||
.update-credential-editor summary::-webkit-details-marker { display: none; }
|
||||
.update-credential-editor summary span, .update-credential-editor summary strong, .update-credential-editor summary small { display: block; }
|
||||
.update-credential-editor summary strong { font-size: 10px; }
|
||||
.update-credential-editor summary small { margin-top: 1px; color: var(--muted); font-size: 8px; font-weight: 500; }
|
||||
.update-credential-editor summary > i { color: #6d918b; font-size: 16px; font-style: normal; transition: transform .18s; }
|
||||
.update-credential-editor[open] summary > i { transform: rotate(180deg); }
|
||||
.update-credential-editor[open] summary { border-bottom: 1px solid #e4eeec; }
|
||||
.update-credential-editor .stacked-form { padding: 18px; }
|
||||
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
|
||||
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
|
||||
.update-inline-warning p { margin: 0; }
|
||||
.update-inline-warning strong, .update-inline-warning small { display: block; }
|
||||
.update-inline-warning strong { color: #74540b; font-size: 9px; }
|
||||
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
|
||||
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at 50% 35%, rgba(37,99,235,.18), transparent 38%), rgba(5,10,20,.82); backdrop-filter: blur(14px) saturate(1.1); animation: update-overlay-in .3s ease both; }
|
||||
.software-update-dialog { position: relative; isolation: isolate; width: min(560px,100%); overflow: hidden; padding: 28px 32px 30px; border: 1px solid rgba(147,197,253,.2); border-radius: 24px; background: radial-gradient(circle at 50% -18%, rgba(59,130,246,.26), transparent 42%), linear-gradient(155deg,#111d31 0%,#0d1727 100%); color: #f8fafc; box-shadow: 0 38px 100px rgba(0,0,0,.46), 0 0 0 1px rgba(255,255,255,.025) inset; text-align: center; animation: update-dialog-in .46s cubic-bezier(.2,.9,.25,1) both; }
|
||||
.software-update-dialog::before { position: absolute; z-index: -2; inset: 0; background-image: linear-gradient(rgba(147,197,253,.035) 1px, transparent 1px), linear-gradient(90deg,rgba(147,197,253,.035) 1px,transparent 1px); background-size: 28px 28px; mask-image: linear-gradient(to bottom,rgba(0,0,0,.8),transparent 72%); content: ""; animation: update-grid-drift 10s linear infinite; }
|
||||
.software-update-dialog::after { position: absolute; z-index: -1; top: -105px; left: 50%; width: 330px; height: 250px; border-radius: 50%; background: rgba(37,99,235,.19); filter: blur(58px); content: ""; transform: translateX(-50%); transition: background .35s; }
|
||||
.software-update-dialog .section-label { position: relative; color: #7f94af; }
|
||||
.software-update-dialog h2 { position: relative; margin: 7px 0 8px; color: #fff; font-size: 25px; letter-spacing: -.025em; }
|
||||
.software-update-dialog p { position: relative; min-height: 36px; margin: 0 auto 17px; color: #a8b8cc; font-size: 11px; line-height: 1.55; }
|
||||
.software-update-visual { position: relative; display: grid; width: 166px; height: 112px; place-items: center; margin: -2px auto 13px; }
|
||||
.software-update-visual::before, .software-update-visual::after { position: absolute; border: 1px solid rgba(96,165,250,.18); border-radius: 50%; content: ""; }
|
||||
.software-update-visual::before { width: 136px; height: 78px; transform: rotate(-9deg); animation: update-ring-left 4.8s linear infinite; }
|
||||
.software-update-visual::after { width: 104px; height: 104px; border-color: rgba(45,212,191,.13); transform: rotate(21deg); animation: update-ring-right 3.8s linear infinite reverse; }
|
||||
.software-update-scan { position: absolute; z-index: 0; width: 150px; height: 1px; overflow: visible; background: linear-gradient(90deg,transparent,#60a5fa,transparent); box-shadow: 0 0 16px rgba(96,165,250,.6); opacity: .7; animation: update-scan 2.2s ease-in-out infinite; }
|
||||
.software-update-orbit { position: relative; z-index: 2; display: grid; width: 68px; height: 68px; place-items: center; border: 1px solid #3c5f8c; border-radius: 21px; background: linear-gradient(145deg,#1c3455,#14243b); color: #c7dcff; box-shadow: 0 16px 34px rgba(0,0,0,.3), 0 0 30px rgba(59,130,246,.15), inset 0 1px rgba(255,255,255,.08); font-family: var(--mono); font-size: 11px; font-weight: 900; animation: update-core-float 2.8s ease-in-out infinite; }
|
||||
.software-update-orbit::before { position: absolute; top: 7px; right: 7px; width: 5px; height: 5px; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 9px #60a5fa; content: ""; animation: update-status-blink 1.1s ease-in-out infinite; }
|
||||
.software-update-orbit span::after { content: ""; }
|
||||
.software-update-orbit i { position: absolute; inset: -10px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.28); border-radius: 26px; animation: update-orbit 1.25s linear infinite; }
|
||||
.update-signal { position: absolute; z-index: 1; width: 5px; height: 5px; border-radius: 50%; background: #93c5fd; box-shadow: 0 0 10px rgba(147,197,253,.85); }
|
||||
.signal-a { animation: update-signal-a 2.4s ease-in-out infinite; }
|
||||
.signal-b { animation: update-signal-b 3s ease-in-out -.7s infinite; }
|
||||
.signal-c { width: 4px; height: 4px; background: #5eead4; animation: update-signal-c 2.7s ease-in-out -1.3s infinite; }
|
||||
.software-update-progress-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; color: #71839a; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.software-update-progress-meta strong { color: #a9c9ff; font-size: 9px; font-variant-numeric: tabular-nums; transition: color .25s; }
|
||||
.software-update-progress { position: relative; height: 7px; overflow: hidden; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
|
||||
.software-update-progress i { display: block; width: 4%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); transition: width 1s linear, background .25s; }
|
||||
.software-update-progress > span { position: absolute; inset: 0; background: linear-gradient(105deg,transparent 34%,rgba(255,255,255,.3) 48%,transparent 62%); transform: translateX(-100%); animation: update-progress-shine 1.65s ease-in-out infinite; }
|
||||
.software-update-overlay[data-update-state="verifying"] .software-update-progress > span { animation-duration: 3.2s; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-progress i { background: #e45c70; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-dialog { border-color: rgba(52,211,153,.35); }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-dialog::after { background: rgba(16,185,129,.2); }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-dialog::after { background: rgba(225,29,72,.18); }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit, .software-update-overlay[data-update-state="failed"] .software-update-orbit { animation: update-result-pop .5s cubic-bezier(.2,.9,.25,1) both; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit { border-color: #34d399; color: #6ee7b7; box-shadow: 0 0 36px rgba(52,211,153,.22); }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-orbit { border-color: #fb7185; color: #fda4af; box-shadow: 0 0 36px rgba(251,113,133,.18); }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit i, .software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: currentColor; animation: none; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit span, .software-update-overlay[data-update-state="failed"] .software-update-orbit span { font-size: 0; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit span::after { content: "✓"; font-family: inherit; font-size: 22px; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-orbit span::after { content: "!"; font-family: inherit; font-size: 22px; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-orbit::before { background: #34d399; box-shadow: 0 0 9px #34d399; animation: none; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-orbit::before { background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: none; }
|
||||
.software-update-overlay[data-update-state="complete"] .software-update-progress-meta strong { color: #6ee7b7; }
|
||||
.software-update-overlay[data-update-state="failed"] .software-update-progress-meta strong { color: #fda4af; }
|
||||
.software-update-phases { position: relative; display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 20px 0 0; padding: 0; list-style: none; }
|
||||
.software-update-phases::before { position: absolute; top: 16px; right: 16%; left: 16%; height: 1px; background: #263a55; content: ""; }
|
||||
.software-update-phases li { position: relative; z-index: 1; display: grid; gap: 2px; justify-items: center; color: #61738b; }
|
||||
.software-update-phases li > span { display: grid; width: 32px; height: 32px; place-items: center; margin-bottom: 3px; border: 1px solid #2c3e57; border-radius: 10px; background: #111d2e; font-family: var(--mono); }
|
||||
.software-update-phases li > span b { font-size: 7px; }
|
||||
.software-update-phases li > strong { color: inherit; font-size: 8px; font-weight: 750; }
|
||||
.software-update-phases li > small { color: #4d5e73; font-family: var(--mono); font-size: 7px; text-transform: uppercase; }
|
||||
.software-update-phases li.is-active { color: #c5dcff; }
|
||||
.software-update-phases li.is-active > span { border-color: #4b83d5; background: #172a46; color: #a9ccff; box-shadow: 0 0 0 4px rgba(59,130,246,.08), 0 8px 18px rgba(0,0,0,.18); animation: update-phase-pulse 1.4s ease-in-out infinite; }
|
||||
.software-update-phases li.is-active > small { color: #7094c7; }
|
||||
.software-update-phases li.is-complete { color: #76cbbb; }
|
||||
.software-update-phases li.is-complete > span { border-color: #327e73; background: #12302f; color: #75d4c4; }
|
||||
.software-update-phases li.is-complete > span b { display: none; }
|
||||
.software-update-phases li.is-complete > span::after { content: "✓"; font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||
.software-update-phases li.is-complete > small { color: #527f78; }
|
||||
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||
body.software-update-open { overflow: hidden; }
|
||||
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes update-dialog-in { from { opacity: 0; transform: translateY(16px) scale(.965); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes update-grid-drift { to { background-position: 28px 28px, 28px 28px; } }
|
||||
@keyframes update-orbit { to { transform: rotate(360deg); } }
|
||||
@keyframes update-ring-left { to { transform: rotate(351deg); } }
|
||||
@keyframes update-ring-right { to { transform: rotate(381deg); } }
|
||||
@keyframes update-core-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||||
@keyframes update-status-blink { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
|
||||
@keyframes update-scan { 0%,100% { opacity: .12; transform: translateY(-34px) scaleX(.55); } 50% { opacity: .75; transform: translateY(34px) scaleX(1); } }
|
||||
@keyframes update-signal-a { 0%,100% { opacity: 0; transform: translate(-68px,23px) scale(.5); } 45% { opacity: 1; } 70% { opacity: .65; transform: translate(50px,-34px) scale(1); } }
|
||||
@keyframes update-signal-b { 0%,100% { opacity: 0; transform: translate(60px,28px) scale(.5); } 48% { opacity: .9; } 72% { opacity: .5; transform: translate(-55px,-28px) scale(1); } }
|
||||
@keyframes update-signal-c { 0%,100% { opacity: 0; transform: translate(-44px,-42px); } 50% { opacity: 1; } 75% { opacity: .4; transform: translate(65px,18px); } }
|
||||
@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-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 article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||
@@ -381,6 +657,23 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.onboarding-shell { grid-template-columns: minmax(300px, .72fr) minmax(500px, 1.28fr); }
|
||||
.onboarding-intro { padding: 34px; }
|
||||
.onboarding-copy { margin-top: 38px; }
|
||||
.onboarding-copy h1 { font-size: 43px; }
|
||||
.onboarding-progress { margin-top: 27px; }
|
||||
.onboarding-steps small { display: none; }
|
||||
.onboarding-card { padding: 34px 42px; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px), (max-height: 600px) {
|
||||
.onboarding-shell { grid-template-columns: 1fr; }
|
||||
.onboarding-intro { display: none; }
|
||||
.onboarding-card { padding: clamp(20px, 5vw, 38px); }
|
||||
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-layout { display: block; }
|
||||
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
||||
@@ -413,6 +706,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.archive-row .button { width: 100%; }
|
||||
.info-box { grid-template-columns: 1fr; }
|
||||
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
||||
.access-policy-grid { grid-template-columns: 1fr; }
|
||||
.access-zone-controls { width: 100%; }
|
||||
.access-target { grid-template-columns: 1fr; }
|
||||
.error-summary-grid { grid-template-columns: 1fr; }
|
||||
@@ -420,9 +714,93 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
||||
.error-item > a { grid-column: 2; }
|
||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||
.pihole-connect-stage { min-height: 0; }
|
||||
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
|
||||
.software-update-banner-actions { grid-column: 1 / -1; }
|
||||
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
|
||||
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
|
||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||
.software-version-action { grid-column: 1 / -1; }
|
||||
.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 > .status-pill { grid-column: 2; justify-self: start; }
|
||||
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||
.admin-full-access-card > .status-pill { grid-column: 2; }
|
||||
.auth-shell { align-items: start; padding: 12px; }
|
||||
.page-setup .auth-shell { align-items: stretch; padding: 0; }
|
||||
.setup-field-row { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.onboarding-card { padding: 24px; }
|
||||
.onboarding-card-meta { margin-bottom: 24px; }
|
||||
.onboarding-form-heading h2 { font-size: 32px; }
|
||||
.onboarding-lede { margin-bottom: 20px; }
|
||||
.setup-form { gap: 12px; }
|
||||
.setup-form input { min-height: 43px; }
|
||||
.onboarding-footer { margin-top: 17px; padding-top: 15px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.access-policy-card { grid-template-columns: 34px minmax(0,1fr); }
|
||||
.access-policy-card > .status-pill { grid-column: 2; justify-self: start; }
|
||||
.software-version-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.software-version-cell { min-width: 0; padding-right: 12px; }
|
||||
.software-version-cell + .software-version-cell { padding-left: 12px; }
|
||||
.software-version-cell strong { font-size: 21px; }
|
||||
.software-update-dialog { padding: 22px 18px 24px; border-radius: 19px; }
|
||||
.software-update-visual { width: 148px; height: 96px; margin-bottom: 10px; transform: scale(.9); }
|
||||
.software-update-dialog h2 { font-size: 22px; }
|
||||
.software-update-phases li > strong { font-size: 7px; }
|
||||
.software-update-phases li > small { display: none; }
|
||||
.onboarding-card { padding: 18px; }
|
||||
.onboarding-card-meta { margin-bottom: 18px; }
|
||||
.onboarding-form-heading h2 { font-size: 28px; }
|
||||
.onboarding-lede { margin-bottom: 15px; font-size: 10px; }
|
||||
.setup-field-row { grid-template-columns: 1fr; gap: 9px; }
|
||||
.setup-form { gap: 9px; }
|
||||
.setup-form label { gap: 4px; }
|
||||
.setup-form input { min-height: 39px; }
|
||||
.setup-submit { min-height: 42px; }
|
||||
.onboarding-footer { margin-top: 12px; padding-top: 11px; }
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.software-update-dialog { padding-block: 20px 22px; }
|
||||
.software-update-visual { height: 78px; margin-bottom: 8px; transform: scale(.82); }
|
||||
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
||||
.software-update-phases { 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-copy { margin-top: 28px; }
|
||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||
.onboarding-copy p { font-size: 11px; }
|
||||
.onboarding-progress { margin-top: 20px; }
|
||||
.onboarding-progress-track { margin-top: 7px; }
|
||||
.onboarding-steps { gap: 4px; margin-top: 9px; }
|
||||
.onboarding-steps li { min-height: 50px; padding-block: 7px; }
|
||||
.onboarding-steps small { display: none; }
|
||||
.onboarding-card { padding-block: 24px; }
|
||||
.onboarding-card-meta { margin-bottom: 18px; }
|
||||
.onboarding-form-heading h2 { font-size: 31px; }
|
||||
.onboarding-lede { margin-bottom: 17px; }
|
||||
.setup-form { gap: 10px; }
|
||||
.setup-form input { min-height: 41px; }
|
||||
.onboarding-footer { margin-top: 13px; padding-top: 13px; }
|
||||
}
|
||||
|
||||
@media (max-height: 560px) {
|
||||
.onboarding-card-meta { margin-bottom: 12px; }
|
||||
.onboarding-lede { display: none; }
|
||||
.onboarding-form-heading h2 { margin-bottom: 14px; }
|
||||
.setup-form input { min-height: 38px; }
|
||||
.password-guidance span { min-height: 32px; padding: 5px 7px; }
|
||||
.password-guidance i { width: 18px; height: 18px; }
|
||||
.setup-submit { min-height: 40px; }
|
||||
.onboarding-footer { padding-top: 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
.toast, .toast-progress { animation: none !important; }
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="heading-actions">
|
||||
<form action="/run" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
|
||||
<button class="button button-primary" type="submit" data-run-now-button {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{else if .Dashboard.SchedulerRunning}}disabled{{end}}>{{if .Dashboard.SchedulerRunning}}Update läuft{{else}}Update jetzt ausführen{{end}}</button>
|
||||
</form>
|
||||
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
|
||||
</div>
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
<section class="metric-grid" aria-label="DynDNS-Status">
|
||||
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
|
||||
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
||||
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
||||
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value" data-dashboard-ipv4>{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
||||
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact" data-dashboard-ipv6>{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
||||
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
|
||||
</section>
|
||||
|
||||
@@ -28,21 +28,30 @@
|
||||
{{if .IsAdmin}}
|
||||
<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 data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
|
||||
{{if .Dashboard.RecentRuns}}
|
||||
<div class="data-list">
|
||||
{{range .Dashboard.RecentRuns}}
|
||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</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}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
|
||||
<article class="panel dashboard-zones-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">DNS-Landschaft</span><h2>Domains & Zonen</h2></div><div class="panel-links"><span class="dashboard-zone-count">{{len .Dashboard.Zones}} Zonen</span><a href="/domains">Verwalten</a></div></div>
|
||||
{{if .Dashboard.Zones}}
|
||||
<div class="domain-list">
|
||||
<div class="dashboard-zone-list">
|
||||
{{range .Dashboard.Zones}}
|
||||
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
|
||||
<a class="dashboard-zone-item" href="/domains/{{.ID}}">
|
||||
<span class="dashboard-zone-glyph {{if .LastError}}is-error{{end}}" aria-hidden="true"><b>DNS</b><i></i></span>
|
||||
<span class="dashboard-zone-copy">
|
||||
<small class="dashboard-domain-label">Domain</small><strong>{{.Name}}</strong>
|
||||
<span class="dashboard-zone-branch"><i aria-hidden="true"></i><span><b>DNS-Zone · {{.Name}}</b><small>{{if .LastError}}Synchronisierung prüfen{{else}}Hetzner · synchronisiert <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time>{{end}}</small></span></span>
|
||||
</span>
|
||||
<span class="dashboard-zone-state"><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span><i aria-hidden="true">→</i></span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
|
||||
@@ -52,7 +61,7 @@
|
||||
{{if and .IsAdmin .Dashboard.RecentEvents}}
|
||||
<section class="panel audit-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
|
||||
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
||||
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time data-local-time="{{unixSeconds .CreatedAt}}">{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<section class="zone-summary" aria-label="Zonenstatus">
|
||||
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
|
||||
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
|
||||
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds .Zone.LastSyncAt}}">{{formatTime .Zone.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
|
||||
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
|
||||
</section>
|
||||
@@ -27,12 +27,12 @@
|
||||
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
|
||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
|
||||
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
||||
<div class="target-meta"><time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
||||
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder value="{{.StaticIPv4}}" inputmode="decimal" placeholder="{{if $.Settings.LastIPv4}}{{$.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
||||
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
@@ -97,7 +97,7 @@
|
||||
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
|
||||
<div class="archive-list">
|
||||
{{range .ArchivedTargets}}
|
||||
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -106,7 +106,7 @@
|
||||
{{if or .IsAdmin .CanCreateTargets}}
|
||||
<section class="action-grid admin-actions-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder inputmode="decimal" placeholder="{{if .Settings.LastIPv4}}{{.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<section class="form-panel compact-panel">
|
||||
<form action="/domains" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
||||
<div class="form-grid domain-create-identity-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"><small class="field-spacer" aria-hidden="true"> </small></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
||||
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
|
||||
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
{{if .Zones}}
|
||||
{{range .Zones}}
|
||||
<article class="zone-card">
|
||||
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
||||
<div class="zone-card-top"><span class="domain-service-label">DynDNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
||||
<h2>{{.Name}}</h2>
|
||||
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
||||
<dl><div><dt>Letzter Import</dt><dd><time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
||||
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
|
||||
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
|
||||
</article>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.</p></div>
|
||||
<div class="form-actions"><a class="button button-quiet" href="/errors/diagnostics" download>Diagnose-Logs herunterladen</a>
|
||||
<form action="/errors/clear" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||
</form>
|
||||
</form></div>
|
||||
</header>
|
||||
|
||||
{{if eq .ErrorCount 0}}
|
||||
@@ -23,7 +24,7 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorTargets}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich <time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time></small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -34,7 +35,7 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorPiHoles}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -45,7 +46,7 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorZones}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -56,7 +57,7 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
|
||||
<div class="error-list run-error-list">
|
||||
{{range .ErrorRuns}}
|
||||
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
||||
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time> · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · DNS Control</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-15">
|
||||
<script src="/static/app.js?v=features-15" defer></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-30">
|
||||
<script src="/static/app.js?v=features-30" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
@@ -26,13 +26,12 @@
|
||||
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
|
||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
|
||||
<a href="/access" {{if eq .Page "access"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Zugriff</a>
|
||||
<a href="/system" {{if eq .Page "system"}}aria-current="page"{{end}}><span class="nav-icon">07</span>Update{{if .SoftwareUpdate.UpdateAvailable}}<i class="nav-update-dot" aria-label="Update verfügbar"></i>{{end}}</a>
|
||||
{{end}}
|
||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{else}}03{{end}}</span>Sicherheit</a>
|
||||
</nav>
|
||||
<div class="sidebar-account">
|
||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
|
||||
<a class="sidebar-account-link" href="/account" aria-label="Eigenes Konto öffnen"><span class="account-avatar">{{initial .User.DisplayName}}</span><span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span></a>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
||||
@@ -44,6 +43,16 @@
|
||||
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
|
||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||
</header>
|
||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||
<aside class="software-update-banner" data-software-update-banner="{{.SoftwareUpdate.LatestVersion}}" aria-label="Software-Update verfügbar">
|
||||
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
|
||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||
<div class="software-update-banner-actions">
|
||||
<a class="button button-quiet" href="/system">Details</a>
|
||||
{{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>
|
||||
</aside>
|
||||
{{end}}
|
||||
<main id="main" class="app-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
@@ -67,6 +76,32 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
|
||||
{{template "software_update_overlay" .}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "software_update_overlay"}}
|
||||
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title{{if ne .Page "system"}}-global{{end}}">
|
||||
<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>
|
||||
<i class="update-signal signal-a"></i><i class="update-signal signal-b"></i><i class="update-signal signal-c"></i>
|
||||
</div>
|
||||
<span class="section-label">Systemupdate</span>
|
||||
<h2 id="software-update-title{{if ne .Page "system"}}-global{{end}}" data-software-update-title>Update wird vorbereitet</h2>
|
||||
<p data-software-update-message aria-live="polite">Das neue Container-Image wird sicher vorbereitet.</p>
|
||||
<div class="software-update-progress-meta"><span data-software-update-progress-label>Vorbereitung</span><strong data-software-update-percent>04%</strong></div>
|
||||
<div class="software-update-progress" data-software-update-progress-track role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="4"><i data-software-update-progress></i><span></span></div>
|
||||
<ol class="software-update-phases" aria-label="Update-Fortschritt">
|
||||
<li data-update-phase="pulling"><span><b>01</b></span><strong>Image laden</strong><small>Registry</small></li>
|
||||
<li data-update-phase="restarting"><span><b>02</b></span><strong>Dienst wechseln</strong><small>Container</small></li>
|
||||
<li data-update-phase="verifying"><span><b>03</b></span><strong>Healthcheck</strong><small>Verifizieren</small></li>
|
||||
</ol>
|
||||
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,18 +1,18 @@
|
||||
{{define "login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>Anmelden</h1>
|
||||
<section class="auth-card login-card">
|
||||
<div class="login-card-top"><div class="auth-brand"><span>DD</span> DNS Control</div><span class="login-mode {{if eq .Mode "https"}}is-secure{{end}}">{{if eq .Mode "https"}}HTTPS{{else}}LAN{{end}}</span></div>
|
||||
<h1>Willkommen zurück</h1>
|
||||
<p class="lede">Melde dich mit deinem persönlichen Zugang an.</p>
|
||||
<form action="/login" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Benutzername
|
||||
<input name="username" type="text" autocomplete="username" required autofocus>
|
||||
<input name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
<input name="password" type="password" autocomplete="current-password" maxlength="128" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -12,7 +12,7 @@
|
||||
{{$integration := $view.Integration}}
|
||||
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
||||
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div>
|
||||
<div><span>Letzte Prüfung</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
|
||||
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||
</section>
|
||||
@@ -21,6 +21,8 @@
|
||||
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted">Gespeicherter Stand. „Records neu einlesen“ prüft die Verbindung und aktualisiert die Einträge. Die Verbindung lässt sich auch ohne erreichbaren Pi-hole <a href="#pihole-connection">verwalten und entfernen</a>.</p>
|
||||
|
||||
<section class="targets-section" id="local-dns">
|
||||
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
|
||||
{{if $view.Records}}
|
||||
@@ -57,7 +59,7 @@
|
||||
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
|
||||
<div class="archive-list">
|
||||
{{range $view.ArchivedRecords}}
|
||||
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -67,7 +69,7 @@
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
|
||||
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Zuletzt erreichbar{{end}}</span></div>
|
||||
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
||||
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
||||
<div class="panel-divider"></div>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{{define "security"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
|
||||
<section class="split-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
|
||||
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
|
||||
</section>
|
||||
<header class="page-heading"><div><span class="page-kicker">Persönlich</span><h1>Mein Konto</h1><p>@{{.User.Username}} · {{if .IsAdmin}}Administrator{{else}}Mitglied{{end}}</p></div>{{if .IsAdmin}}<a class="button button-quiet" href="/access">Zugriff verwalten</a>{{end}}</header>
|
||||
<section class="form-panel account-password-panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Eigenes Passwort ändern</h2></div></div><form action="/account/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" maxlength="128" required></label><div class="form-grid"><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="15" maxlength="128" required></label><label>Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="15" maxlength="128" required></label></div><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></section>
|
||||
{{end}}
|
||||
@@ -24,7 +24,7 @@
|
||||
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
|
||||
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
|
||||
</label>
|
||||
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
||||
<div class="technical-summary"><span>Letzter Lauf</span><strong><time data-local-time="{{unixSeconds .Settings.LastRunAt}}">{{formatTime .Settings.LastRunAt}}</time></strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="form-grid endpoint-grid">
|
||||
<label>IPv4-Endpunkte
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
{{define "setup"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>System einrichten</h1>
|
||||
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
|
||||
<form action="/setup" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Admin-Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
|
||||
</label></div>
|
||||
<label>Admin-Passwort
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
|
||||
</label>
|
||||
<label>Passwort wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
|
||||
<section class="onboarding-shell">
|
||||
<aside class="onboarding-intro">
|
||||
<div class="onboarding-brand-row">
|
||||
<div class="auth-brand auth-brand-inverse"><span>DD</span> DNS Control</div>
|
||||
<span class="onboarding-edition">Self-hosted</span>
|
||||
</div>
|
||||
<div class="onboarding-copy">
|
||||
<span class="onboarding-eyebrow">Erster Start</span>
|
||||
<h1>DNS-Verwaltung,<br>die einfach läuft.</h1>
|
||||
<p>Einrichten, verbinden, automatisch aktuell halten.</p>
|
||||
</div>
|
||||
<div class="onboarding-progress" aria-label="Einrichtung: Schritt 1 von 3">
|
||||
<div><span>Einrichtung</span><strong>1 von 3</strong></div>
|
||||
<span class="onboarding-progress-track" aria-hidden="true"><i></i></span>
|
||||
</div>
|
||||
<ol class="onboarding-steps" aria-label="Einrichtung">
|
||||
<li class="is-current" aria-current="step"><span>01</span><div><div class="onboarding-step-title"><strong>Administrator anlegen</strong><em>Aktuell</em></div><small>Sicheren Zugang für diese Instanz erstellen.</small></div></li>
|
||||
<li><span>02</span><div><strong>Dienste verbinden</strong><small>Hetzner und optional Pi-hole hinzufügen.</small></div></li>
|
||||
<li><span>03</span><div><strong>Automatisieren</strong><small>DNS-Einträge zuverlässig aktuell halten.</small></div></li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<section class="onboarding-card" aria-labelledby="setup-title">
|
||||
<div class="onboarding-form-wrap">
|
||||
<div class="onboarding-card-meta">
|
||||
<span class="setup-ready"><i aria-hidden="true"></i> Instanz bereit</span>
|
||||
<span>Schritt 1 von 3</span>
|
||||
</div>
|
||||
<header class="onboarding-form-heading">
|
||||
<h2 id="setup-title">Administrator anlegen</h2>
|
||||
<p class="onboarding-lede">Lege den ersten Administrator für diese Instanz an.</p>
|
||||
</header>
|
||||
|
||||
<form action="/setup" method="post" class="stacked-form setup-form" data-setup-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid setup-field-row"><label>Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" autocomplete="name" maxlength="80" required>
|
||||
</label></div>
|
||||
<div class="form-grid setup-field-row"><label>Admin-Passphrase
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="15" maxlength="128" aria-describedby="password-guidance" data-setup-password required>
|
||||
</label>
|
||||
<label>Passphrase wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="15" maxlength="128" data-setup-confirm required>
|
||||
</label></div>
|
||||
<div class="password-guidance" id="password-guidance" aria-live="polite">
|
||||
<span data-password-length><i aria-hidden="true">✓</i> Mindestens 15 Zeichen</span>
|
||||
<span data-password-match><i aria-hidden="true">✓</i> Eingaben stimmen überein</span>
|
||||
</div>
|
||||
<button class="button button-primary setup-submit" type="submit">Administrator erstellen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
|
||||
<div class="onboarding-footer onboarding-mode {{if eq .Mode "https"}}is-secure{{end}}">
|
||||
<span aria-hidden="true">{{if eq .Mode "https"}}●{{else}}!{{end}}</span>
|
||||
<p><strong>{{if eq .Mode "https"}}HTTPS-Modus aktiv{{else}}LAN-Modus aktiv{{end}}</strong><small>{{if eq .Mode "https"}}Verschlüsselte Verbindung{{else}}Nur im lokalen Netz verwenden{{end}}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,82 @@
|
||||
{{define "system"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading system-heading">
|
||||
<div><span class="page-kicker">Wartung</span><h1>Software-Update</h1><p>Version prüfen und die Instanz kontrolliert aktualisieren.</p></div>
|
||||
<form action="/system/check" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet software-check-button" type="submit"><i aria-hidden="true">↻</i> Nach Update suchen</button></form>
|
||||
</header>
|
||||
|
||||
<section class="software-version-card {{if .SoftwareUpdate.UpdateAvailable}}has-update{{end}}">
|
||||
<div class="software-version-visual" aria-hidden="true"><span>DC</span><i></i></div>
|
||||
<div class="software-version-copy">
|
||||
<div class="software-version-values">
|
||||
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
||||
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}–{{end}}</strong></div>
|
||||
</div>
|
||||
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
||||
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
|
||||
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die benötigte Update-Funktion noch nicht. Für Updates ohne Anmeldung muss auch der Agent diese Funktion unterstützen.</small></p></div>{{end}}
|
||||
</div>
|
||||
<div class="software-version-action">
|
||||
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||
<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 software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||
</form>
|
||||
{{else if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.ComposeSyncRequired}}
|
||||
<span class="status-pill status-warn">Compose aktualisieren</span>
|
||||
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
||||
{{else}}
|
||||
<span class="status-pill {{if or .SoftwareUpdate.LastError .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}Bereit{{end}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<p>Öffentliche Releases und Container-Images werden ohne Anmeldung geladen. Für private Repositories oder Images kannst du einen Zugang hinterlegen; er wird nur bei Bedarf verwendet.</p>
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||
<div class="update-access-state">
|
||||
<span class="update-access-state-icon" aria-hidden="true">✓</span>
|
||||
<div><span class="section-label">Update-Zugang</span><h2>Privater Zugang gespeichert</h2><p>Hinterlegt für <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
|
||||
<span class="status-pill status-good"><i aria-hidden="true"></i> Gespeichert</span>
|
||||
</div>
|
||||
<details class="update-credential-editor">
|
||||
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
|
||||
{{else}}
|
||||
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Privater Zugang (optional)</h2></div></div>
|
||||
{{end}}
|
||||
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<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>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
|
||||
</form>
|
||||
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
||||
</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" .}}
|
||||
{{end}}
|
||||
@@ -2,7 +2,7 @@
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a class="back-link" href="/users">← Benutzer</a>
|
||||
<a class="back-link" href="/access">← Zugriff & Sicherheit</a>
|
||||
<span class="page-kicker">Benutzerprofil</span>
|
||||
<h1>{{.ManagedUser.DisplayName}}</h1>
|
||||
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
||||
@@ -25,8 +25,8 @@
|
||||
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
||||
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
||||
</form>
|
||||
@@ -68,6 +68,6 @@
|
||||
</form>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
|
||||
<section class="admin-full-access-card access-panel"><span class="admin-full-access-icon" aria-hidden="true">A</span><div><span class="section-label">Administratorrolle</span><h2>Vollzugriff ist fest mit der Rolle verbunden</h2><p>Für Administratoren gibt es keine bearbeitbaren Domain- oder Host-Einzelrechte.</p></div><span class="status-pill status-good">Alle Rechte</span></section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,13 +1,13 @@
|
||||
{{define "user_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<header class="page-heading"><div><a class="back-link" href="/access">← Zugriff & Sicherheit</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten starten ohne Freigaben.</p></div></header>
|
||||
<section class="form-panel compact-panel user-create-panel">
|
||||
<form action="/users" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
|
||||
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
|
||||
<div class="form-grid user-create-grid user-create-identity-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required><small class="field-spacer" aria-hidden="true"> </small></label></div>
|
||||
<div class="form-grid user-create-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" maxlength="128" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" maxlength="128" required></label></div>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/access">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,5 +1,9 @@
|
||||
{{define "users"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
|
||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||
<header class="page-heading"><div><span class="page-kicker">Administration</span><h1>Zugriff & Sicherheit</h1><p>Konten, Rollen und Freigaben an einem Ort.</p></div><div class="heading-actions"><a class="button button-quiet" href="/account">Mein Konto</a><a class="button button-primary" href="/users/new">Benutzer anlegen</a></div></header>
|
||||
<section class="access-policy-grid" aria-label="Rollenmodell">
|
||||
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
|
||||
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
|
||||
</section>
|
||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell"><time data-local-time="{{unixSeconds .LastLoginAt}}">{{formatTime .LastLoginAt}}</time></span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user