Compare commits

...
4 Commits
Author SHA1 Message Date
admin 1bbb14470c fix: align domain creation fields 2026-08-30 02:05:27 +02:00
admin 26a960c6bd fix: stabilize software updates 2026-08-29 18:38:38 +02:00
admin fc59092030 fix: refresh dashboard update runs live
Container-Image veröffentlichen / container (push) Multi-Arch-Image veröffentlicht und verifiziert
2026-08-29 12:45:58 +02:00
admin bb60c06c0e feat: establish secure update foundation
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
2026-08-29 11:30:43 +02:00
40 changed files with 2100 additions and 423 deletions

No files matched your search

+12 -6
View File
@@ -44,13 +44,18 @@ Ersetze die bisherige Version mindestens an diesen Stellen:
- `compose.yml` - `compose.yml`
- Image-Beispiel in `README.md` - 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 ```bash
PREVIOUS=v1.2.1 PREVIOUS=v1.2.1
if grep -n "$PREVIOUS" README.md compose.yml; then if grep -n "domain-control-webinterface-prod:$PREVIOUS" README.md compose.yml; then
echo "Alte Version $PREVIOUS ist noch eingetragen." >&2 echo "Alte Image-Version $PREVIOUS ist noch eingetragen." >&2
exit 1 exit 1
fi fi
docker compose -f compose.yml config --quiet docker compose -f compose.yml config --quiet
@@ -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 ## 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 ### 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 ### Manueller Multi-Arch-Fallback
@@ -185,6 +190,7 @@ Baue und pushe anschließend beide Plattformen in einem gemeinsamen Manifest:
docker buildx build \ docker buildx build \
--builder domain-control-release-builder \ --builder domain-control-release-builder \
--platform linux/amd64,linux/arm64 \ --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:$VERSION" \
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:stable" \ --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 \ --label org.opencontainers.image.source=https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface \
+28 -7
View File
@@ -1,7 +1,14 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG VERSION=v1.4.3
ARG MINIMUM_UPDATE_VERSION=v1.4.0
ARG MINIMUM_AGENT_PROTOCOL=2
ARG RUNTIME_CONTRACT_VERSION=2
ARG DATABASE_CONTRACT_VERSION=1
ARG SCHEMA_VERSION=11
FROM golang:1.25.13-alpine AS build FROM golang:1.25.13-alpine AS build
ARG VERSION=v1.3.0 ARG VERSION
RUN apk add --no-cache build-base RUN apk add --no-cache build-base
WORKDIR /src WORKDIR /src
@@ -10,23 +17,37 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
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/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
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/update-agent ./cmd/update-agent
FROM alpine:3.23.2 AS runtime 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 && \ RUN apk add --no-cache ca-certificates && \
addgroup -S -g 10001 dyndns && \ addgroup -S -g 10001 dyndns && \
adduser -S -D -H -u 10001 -G dyndns dyndns && \ adduser -S -D -H -u 10001 -G dyndns dyndns && \
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets 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
COPY --from=build /out/update-agent /usr/local/bin/update-agent 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 USER dyndns:dyndns
WORKDIR /data WORKDIR /data
EXPOSE 8080 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"]
+23 -40
View File
@@ -23,54 +23,35 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
## Das Compose ## Das Compose
```yaml ```yaml
x-domain-control-image: &domain-control-image
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
services: services:
dyndns: dyndns:
<<: *domain-control-image image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
restart: unless-stopped restart: unless-stopped
ports: stop_grace_period: 20s
- "9090:8080" ports: ["9090:8080"]
volumes: volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
- dyndns-data:/data labels: {de.j-mahler.domain-control.role: app}
- dyndns-secrets:/secrets
- dyndns-update-control:/run/domain-control-update
labels:
de.j-mahler.domain-control.role: app
read_only: true read_only: true
tmpfs: tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 security_opt: [no-new-privileges:true]
security_opt: cap_drop: [ALL]
- no-new-privileges:true
cap_drop:
- ALL
update-agent: update-agent:
<<: *domain-control-image image: *image
restart: unless-stopped restart: unless-stopped
user: "0:10001" user: "0:10001"
entrypoint: [/usr/local/bin/update-agent] command: [update-agent]
healthcheck: healthcheck: {disable: true}
disable: true
network_mode: none network_mode: none
volumes: volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
- /var/run/docker.sock:/var/run/docker.sock
- dyndns-update-control:/run/domain-control-update
read_only: true read_only: true
tmpfs: tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777 security_opt: [no-new-privileges:true]
security_opt: cap_drop: [ALL]
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32 pids_limit: 32
mem_limit: 64m mem_limit: 64m
volumes: volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
dyndns-data:
dyndns-secrets:
dyndns-update-control:
``` ```
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden. Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden.
@@ -80,18 +61,20 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose, Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md). Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Gitea-Benutzername und wahlweise Passwort oder Token werden einmalig in der Weboberfläche hinterlegt; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben.
`v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an.
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten. Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur die eine gepinnte Image-Zeile unter `x-domain-control-image` ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend: Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
```bash ```bash
docker compose pull docker compose pull
docker compose up -d docker compose up -d --force-recreate
``` ```
Compose ersetzt geänderte 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; wer später wieder per Compose aktualisiert, setzt dort einfach die gewünschte konkrete Version und führt die beiden Befehle aus. 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. 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.
@@ -108,7 +91,7 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung - eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen - lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. 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. Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`. Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`.
+27 -9
View File
@@ -1,36 +1,49 @@
# Domain Control aktualisieren # Domain Control aktualisieren
Die aktuelle Compose-Version ist `v1.3.0`. Datenbank, Einstellungen und Die aktuelle Compose-Version ist `v1.4.3`. Datenbank, Einstellungen und
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
den folgenden Verfahren erhalten. den folgenden Verfahren erhalten.
## Direkt in der Weboberfläche ## Direkt in der Weboberfläche
1. Als Administrator anmelden und **Update** öffnen. 1. Als Administrator anmelden und **Update** öffnen.
2. Falls noch nicht geschehen, Gitea-Benutzername und einen Token mit 2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder einen
Lesezugriff auf Repository und Container-Paket hinterlegen. Token mit Lesezugriff auf Repository und Container-Paket hinterlegen.
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen. 3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen 4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
Container wiederherzustellen. Container wiederherzustellen.
Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden
DynDNS-Dienst nicht. 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.
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 ## Mit Docker Compose
In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
```yaml ```yaml
x-domain-control-image: &domain-control-image services:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
update-agent:
image: *image
``` ```
Danach im Ordner der Compose-Datei ausführen: Danach im Ordner der Compose-Datei ausführen:
```bash ```bash
docker compose pull docker compose pull
docker compose up -d docker compose up -d --force-recreate
docker compose ps docker compose ps
``` ```
@@ -39,6 +52,11 @@ Version. Für das Update ist kein `docker compose down` nötig. Insbesondere
niemals `docker compose down -v` verwenden, da `-v` die Daten- und niemals `docker compose down -v` verwenden, da `-v` die Daten- und
Secret-Volumes löschen würde. 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: Der Healthcheck lässt sich anschließend direkt prüfen:
```bash ```bash
@@ -50,7 +68,7 @@ Die erwartete Antwort lautet `ok`.
## Portainer oder Dockge ## Portainer oder Dockge
1. Den bestehenden Stack bearbeiten. 1. Den bestehenden Stack bearbeiten.
2. Nur den Tag der zentralen Image-Zeile auf `v1.3.0` setzen. 2. Nur den Tag der zentralen Image-Zeile auf `v1.4.3` setzen.
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. 3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. 4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
@@ -63,7 +81,7 @@ zuvor eingesetzte konkrete Version setzen und erneut ausführen:
```bash ```bash
docker compose pull docker compose pull
docker compose up -d docker compose up -d --force-recreate
``` ```
Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und
+35 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -18,20 +19,52 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" "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/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web"
) )
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" { command := commandForInvocation(os.Args)
if command == "healthcheck" {
os.Exit(healthcheck()) os.Exit(healthcheck())
} }
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) 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) logger.Error("application stopped", "error", err)
os.Exit(1) 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 { func run(logger *slog.Logger) error {
// Credentials and session data must never inherit a permissive process umask. // Credentials and session data must never inherit a permissive process umask.
syscall.Umask(0o077) syscall.Umask(0o077)
+24
View File
@@ -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)
}
}
}
-88
View File
@@ -1,88 +0,0 @@
package main
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := run(logger); err != nil {
logger.Error("update agent stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
socket := env("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock")
dockerSocket := env("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock")
image := env("UPDATE_IMAGE_REPOSITORY", "git.j-mahler.de/admin/domain-control-webinterface-prod")
targetLabel := env("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app")
directory := filepath.Dir(socket)
if err := os.MkdirAll(directory, 0o711); err != nil {
return err
}
if err := os.Chmod(directory, 0o711); err != nil {
return err
}
if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
listener, err := net.Listen("unix", socket)
if err != nil {
return err
}
defer listener.Close()
defer os.Remove(socket)
// The container runs as uid 0 with gid 10001. Group-only access lets the
// unprivileged web process connect without exposing the socket to others.
if err := os.Chmod(socket, 0o660); err != nil {
return err
}
runner := updateagent.NewDockerRunner(dockerSocket, image, targetLabel, logger)
controller := updateagent.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,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErrors := make(chan error, 1)
go func() {
logger.Info("update agent ready", "socket", 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 env(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
+16 -36
View File
@@ -1,49 +1,29 @@
x-domain-control-image: &domain-control-image
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
services: services:
dyndns: dyndns:
<<: *domain-control-image image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
restart: unless-stopped restart: unless-stopped
ports: stop_grace_period: 20s
- "9090:8080" ports: ["9090:8080"]
volumes: volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
- dyndns-data:/data labels: {de.j-mahler.domain-control.role: app}
- dyndns-secrets:/secrets
- dyndns-update-control:/run/domain-control-update
labels:
de.j-mahler.domain-control.role: app
read_only: true read_only: true
tmpfs: tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 security_opt: [no-new-privileges:true]
security_opt: cap_drop: [ALL]
- no-new-privileges:true
cap_drop:
- ALL
update-agent: update-agent:
<<: *domain-control-image image: *image
restart: unless-stopped restart: unless-stopped
user: "0:10001" user: "0:10001"
entrypoint: command: [update-agent]
- /usr/local/bin/update-agent healthcheck: {disable: true}
healthcheck:
disable: true
network_mode: none network_mode: none
volumes: volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
- /var/run/docker.sock:/var/run/docker.sock
- dyndns-update-control:/run/domain-control-update
read_only: true read_only: true
tmpfs: tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777 security_opt: [no-new-privileges:true]
security_opt: cap_drop: [ALL]
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32 pids_limit: 32
mem_limit: 64m mem_limit: 64m
volumes: volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
dyndns-data:
dyndns-secrets:
dyndns-update-control:
+111 -17
View File
@@ -30,9 +30,14 @@ type State struct {
LastCheckedAt time.Time LastCheckedAt time.Time
LastError string LastError string
CredentialConfigured bool CredentialConfigured bool
CredentialUsername string
UpdateAvailable bool UpdateAvailable bool
AgentAvailable bool AgentAvailable bool
AgentCompatible bool
AgentStatus updateprotocol.Status AgentStatus updateprotocol.Status
AgentVersion string
AgentProtocol int
ComposeSyncRequired bool
} }
type Manager struct { type Manager struct {
@@ -76,6 +81,7 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin
}, },
}, },
} }
settings = manager.reconcileInstalledVersion(context.Background(), settings)
manager.setStoredState(settings) manager.setStoredState(settings)
return manager, nil return manager, nil
} }
@@ -131,15 +137,15 @@ func (m *Manager) State() State {
return m.state return m.state
} }
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error { func (m *Manager) SaveCredential(ctx context.Context, username, credential string) error {
username, token = strings.TrimSpace(username), strings.TrimSpace(token) username = strings.TrimSpace(username)
if username == "" || len(username) > 128 { if username == "" || len(username) > 128 {
return errors.New("Gitea-Benutzername fehlt oder ist zu lang") return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
} }
if len(token) < 8 || len(token) > 1024 { if len(credential) < 8 || len(credential) > 1024 {
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge") return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge")
} }
ciphertext, nonce, err := m.cipher.Encrypt(token) ciphertext, nonce, err := m.cipher.Encrypt(credential)
if err != nil { if err != nil {
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err) return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
} }
@@ -164,11 +170,11 @@ func (m *Manager) CheckNow(ctx context.Context) error {
m.setStoredState(settings) m.setStoredState(settings)
return ErrCredentialMissing return ErrCredentialMissing
} }
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil { if err != nil {
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")) return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
} }
release, err := m.fetchLatest(ctx, token) release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential)
if err != nil { if err != nil {
return m.recordFailure(ctx, err) return m.recordFailure(ctx, err)
} }
@@ -192,19 +198,28 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 { if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version") return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
} }
if !state.AgentAvailable {
return errors.New("Update-Agent ist nicht erreichbar")
}
if !state.AgentCompatible {
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
}
settings, err := m.store.GetSoftwareUpdateSettings(ctx) settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil { if err != nil {
return err return err
} }
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil || settings.RegistryUsername == "" { if err != nil || settings.RegistryUsername == "" {
return ErrCredentialMissing return ErrCredentialMissing
} }
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
return err return err
} }
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token} request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential}
if err := m.agent.Start(ctx, request); err != nil { if err := m.agent.Start(ctx, request); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
m.clearDesiredVersion(cleanupCtx, version)
cancel()
return err return err
} }
settings, err = m.store.GetSoftwareUpdateSettings(ctx) settings, err = m.store.GetSoftwareUpdateSettings(ctx)
@@ -217,25 +232,40 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) { func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
status, err := m.agent.Status(ctx) status, err := m.agent.Status(ctx)
m.stateMu.Lock() m.stateMu.Lock()
desiredVersion := m.state.DesiredVersion
m.state.AgentAvailable = err == nil m.state.AgentAvailable = err == nil
if err == nil { if err == nil {
m.state.AgentStatus = status 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
} else {
m.state.AgentCompatible = false
m.state.ComposeSyncRequired = false
} }
m.stateMu.Unlock() 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 return status, err
} }
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) { func (m *Manager) fetchLatest(ctx context.Context, username, credential string) (releaseResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) response, err := m.fetchLatestWithAuth(ctx, username, credential, true)
if err != nil { if err != nil {
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig") return releaseResponse{}, err
} }
request.Header.Set("Accept", "application/json") if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
request.Header.Set("Authorization", "token "+token) response.Body.Close()
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version) response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
response, err := m.httpClient.Do(request)
if err != nil { if err != nil {
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar") return releaseResponse{}, err
}
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
@@ -255,6 +285,25 @@ func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseRespons
return release, nil return release, nil
} }
func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
if err != nil {
return nil, errors.New("Update-Endpunkt ist ungültig")
}
request.Header.Set("Accept", "application/json")
if basic {
request.SetBasicAuth(username, credential)
} else {
request.Header.Set("Authorization", "token "+credential)
}
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
response, err := m.httpClient.Do(request)
if err != nil {
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
}
return response, nil
}
func (m *Manager) safeReleaseURL(raw string) string { func (m *Manager) safeReleaseURL(raw string) string {
apiURL, apiErr := url.Parse(m.apiURL) apiURL, apiErr := url.Parse(m.apiURL)
releaseURL, releaseErr := url.Parse(raw) releaseURL, releaseErr := url.Parse(raw)
@@ -284,17 +333,62 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
LastCheckedAt: settings.LastCheckedAt, LastCheckedAt: settings.LastCheckedAt,
LastError: settings.LastError, LastError: settings.LastError,
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0, CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
CredentialUsername: settings.RegistryUsername,
} }
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil { if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
state.UpdateAvailable = comparison < 0 state.UpdateAvailable = comparison < 0
} }
m.stateMu.Lock() m.stateMu.Lock()
state.AgentAvailable = m.state.AgentAvailable state.AgentAvailable = m.state.AgentAvailable
state.AgentCompatible = m.state.AgentCompatible
state.AgentStatus = m.state.AgentStatus state.AgentStatus = m.state.AgentStatus
state.AgentVersion = m.state.AgentVersion
state.AgentProtocol = m.state.AgentProtocol
state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible
m.state = state m.state = state
m.stateMu.Unlock() 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) { func (m *Manager) refreshAgentStatus(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second) statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
+100 -8
View File
@@ -13,16 +13,19 @@ import (
"testing" "testing"
"time" "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/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
) )
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel() t.Parallel()
const token = "test-token-that-must-stay-secret" const credential = " test-password-or-token-that-must-stay-secret "
var unavailable atomic.Bool var unavailable atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token "+token { username, password, ok := r.BasicAuth()
if !ok || username != "registry-user" || password != credential {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
@@ -31,7 +34,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`)) _, _ = w.Write([]byte(`{"tag_name":"v1.4.4","html_url":"https://example.invalid/releases/v1.4.4","draft":false,"prerelease":false}`))
})) }))
defer api.Close() defer api.Close()
@@ -57,22 +60,22 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil { if err := manager.SaveCredential(ctx, "registry-user", credential); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := manager.CheckNow(ctx); err != nil { if err := manager.CheckNow(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
state := manager.State() state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" { if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || state.LastError != "" || state.CredentialUsername != "registry-user" {
t.Fatalf("new release was not detected: %#v", state) t.Fatalf("new release was not detected: %#v", state)
} }
settings, err := database.GetSoftwareUpdateSettings(ctx) settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if strings.Contains(string(settings.TokenCiphertext), token) { if strings.Contains(string(settings.TokenCiphertext), credential) {
t.Fatal("registry token was stored as plaintext") t.Fatal("registry credential was stored as plaintext")
} }
unavailable.Store(true) unavailable.Store(true)
@@ -80,7 +83,96 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal("unavailable Gitea endpoint was reported as successful") t.Fatal("unavailable Gitea endpoint was reported as successful")
} }
state = manager.State() state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" { if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state) t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
} }
} }
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 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)
}
}
+3 -54
View File
@@ -1,66 +1,15 @@
package appupdate package appupdate
import ( import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
"fmt"
"strconv"
"strings"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type semanticVersion struct {
major int
minor int
patch int
}
func parseVersion(raw string) (semanticVersion, error) {
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
if len(parts) != 3 {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
values := make([]int, 3)
for index, part := range parts {
if part == "" || (len(part) > 1 && part[0] == '0') {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
value, err := strconv.Atoi(part)
if err != nil || value < 0 || value > 1_000_000 {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
values[index] = value
}
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
}
func compareVersions(left, right string) (int, error) { func compareVersions(left, right string) (int, error) {
a, err := parseVersion(left) return updateprotocol.CompareVersions(left, right)
if err != nil {
return 0, err
}
b, err := parseVersion(right)
if err != nil {
return 0, err
}
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
for index := range av {
if av[index] < bv[index] {
return -1, nil
}
if av[index] > bv[index] {
return 1, nil
}
}
return 0, nil
} }
func validReleaseVersion(version string) bool { func validReleaseVersion(version string) bool {
if !updateprotocol.ValidVersion(version) { if !updateprotocol.ValidVersion(version) {
return false return false
} }
_, err := parseVersion(version) _, err := updateprotocol.CompareVersions(version, version)
return err == nil return err == nil
} }
+1
View File
@@ -10,6 +10,7 @@ func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
want int want int
}{ }{
{left: "v1.2.2", right: "v1.2.3", want: -1}, {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: "v2.0.0", right: "v1.99.99", want: 1},
{left: "v1.2.2", right: "v1.2.2", want: 0}, {left: "v1.2.2", right: "v1.2.2", want: 0},
} }
+17 -1
View File
@@ -1,5 +1,21 @@
package buildinfo 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 // Version is replaced through -ldflags for release images. The source fallback
// keeps local builds and tests honest about the currently checked-out release. // keeps local builds and tests honest about the currently checked-out release.
var Version = "v1.3.0" var Version = "v1.4.3"
+50
View File
@@ -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 -1
View File
@@ -91,7 +91,7 @@ type PiHoleCredential struct {
} }
// SoftwareUpdateSettings contains the locally persisted update state. The // SoftwareUpdateSettings contains the locally persisted update state. The
// encrypted registry token is returned only to trusted backend components and // encrypted registry credential is returned only to trusted backend components and
// is never passed to templates. // is never passed to templates.
type SoftwareUpdateSettings struct { type SoftwareUpdateSettings struct {
RegistryUsername string RegistryUsername string
+21 -3
View File
@@ -37,6 +37,16 @@ type Cipher struct {
} }
func LoadCipher(path string) (*Cipher, error) { 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) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read master key: %w", err) return nil, fmt.Errorf("read master key: %w", err)
@@ -47,6 +57,17 @@ func LoadCipher(path string) (*Cipher, error) {
// LoadOrCreateCipher loads an existing master key or creates one on first start. // 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. // The key must live on a persistent volume separate from the application data.
func LoadOrCreateCipher(path string) (*Cipher, error) { 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) cipher, err := LoadCipher(path)
if err == nil { if err == nil {
return cipher, nil return cipher, nil
@@ -55,9 +76,6 @@ func LoadOrCreateCipher(path string) (*Cipher, error) {
return nil, err 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) key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil { if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("create master key: %w", err) return nil, fmt.Errorf("create master key: %w", err)
+24
View File
@@ -56,6 +56,13 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
if permissions := info.Mode().Perm(); permissions != 0o600 { if permissions := info.Mode().Perm(); permissions != 0o600 {
t.Fatalf("master key permissions = %o, want 600", permissions) 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") ciphertext, nonce, err := first.Encrypt("persistent token")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -70,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) { func TestPasswordHash(t *testing.T) {
t.Parallel() t.Parallel()
hash, err := HashPassword("a secure password") hash, err := HashPassword("a secure password")
+13
View File
@@ -13,6 +13,7 @@ import (
_ "github.com/mattn/go-sqlite3" _ "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/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
) )
@@ -128,6 +129,13 @@ func (s *Store) migrate(ctx context.Context) error {
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil { 300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err 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() return tx.Commit()
} }
@@ -861,6 +869,11 @@ func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) e
return err 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 { func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
_, err := s.db.ExecContext(ctx, `UPDATE settings SET _, err := s.db.ExecContext(ctx, `UPDATE settings SET
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
+58
View File
@@ -2,16 +2,60 @@ package store
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "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/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
) )
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) { func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@@ -280,6 +324,20 @@ func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" { 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) 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) { func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
+16 -6
View File
@@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
) )
@@ -26,7 +27,16 @@ type Controller struct {
} }
func NewController(runner Runner, logger *slog.Logger) *Controller { func NewController(runner Runner, logger *slog.Logger) *Controller {
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}} return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
}
func agentStatus(phase string) updateprotocol.Status {
return updateprotocol.Status{
Phase: phase,
UpdatedAt: time.Now().UTC(),
AgentVersion: buildinfo.Version,
Protocol: buildinfo.UpdateProtocolVersion,
}
} }
func (c *Controller) Handler() http.Handler { func (c *Controller) Handler() http.Handler {
@@ -57,9 +67,9 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
request.Version = strings.TrimSpace(request.Version) request.Version = strings.TrimSpace(request.Version)
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername) request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken) if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest) http.Error(w, "invalid update request", http.StatusBadRequest)
return return
} }
@@ -80,7 +90,7 @@ func (c *Controller) begin(version string) bool {
return false return false
} }
now := time.Now().UTC() now := time.Now().UTC()
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now} c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion}
return true return true
} }
@@ -88,12 +98,12 @@ func (c *Controller) run(request updateprotocol.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel() defer cancel()
err := c.runner.Update(ctx, request, c.setPhase) err := c.runner.Update(ctx, request, c.setPhase)
registryToken := request.RegistryToken registryCredential := request.RegistryToken
request.RegistryToken = "" request.RegistryToken = ""
if err != nil { if err != nil {
message := publicAgentError(err) message := publicAgentError(err)
c.setPhase(updateprotocol.PhaseFailed, message) c.setPhase(updateprotocol.PhaseFailed, message)
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken)) c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryCredential))
return return
} }
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert") c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
+14 -4
View File
@@ -56,9 +56,19 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
if trailing.Code != http.StatusBadRequest { if trailing.Code != http.StatusBadRequest {
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code) 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-token-must-not-leak" const secret = " registry-password-must-not-be-trimmed "
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`) body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder() accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if accepted.Code != http.StatusAccepted { if accepted.Code != http.StatusAccepted {
@@ -66,7 +76,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
} }
select { select {
case request := <-runner.started: case request := <-runner.started:
if request.Version != "v1.2.3" || request.RegistryToken != secret { if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret {
t.Fatalf("runner received an altered request: %#v", request) t.Fatalf("runner received an altered request: %#v", request)
} }
case <-time.After(time.Second): case <-time.After(time.Second):
@@ -80,7 +90,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
} }
status := httptest.NewRecorder() status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil)) 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"`) { 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()) t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
} }
+444 -19
View File
@@ -13,14 +13,30 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"reflect"
"strconv"
"strings" "strings"
"time" "time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
) )
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod" 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 { type DockerRunner struct {
docker *dockerClient docker *dockerClient
imageRepository string imageRepository string
@@ -51,10 +67,6 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
if err := r.docker.negotiate(ctx); err != nil { if err := r.docker.negotiate(ctx); err != nil {
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err} return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
} }
progress(updateprotocol.PhasePulling, "Container-Image wird geladen")
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
}
target, err := r.docker.findTarget(ctx, r.targetLabel) target, err := r.docker.findTarget(ctx, r.targetLabel)
if err != nil { if err != nil {
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err} return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
@@ -63,11 +75,42 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
if err != nil { if err != nil {
return err return err
} }
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
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 {
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, "/") originalName := strings.TrimPrefix(inspected.Name, "/")
if originalName == "" { if originalName == "" {
return errors.New("target container has no name") return errors.New("target container has no name")
} }
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix()) backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano())
var oldStopped, oldRenamed bool var oldStopped, oldRenamed bool
var newID string var newID string
defer func() { defer func() {
@@ -76,7 +119,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
} }
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil { if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil {
r.logger.Error("software update rollback failed", "error", rollbackErr) 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)} resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
} }
@@ -91,7 +134,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return err return err
} }
oldRenamed = true oldRenamed = true
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version) newID, err = r.docker.createReplacement(ctx, inspected, originalName, replacementConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -99,7 +142,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return err return err
} }
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft") progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil { if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil {
return err return err
} }
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil { if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
@@ -109,7 +152,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
return nil return nil
} }
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error { func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error {
var failures []string var failures []string
if newID != "" { if newID != "" {
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) { if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
@@ -127,6 +170,8 @@ func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string,
if oldStopped { if oldStopped {
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) { if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
failures = append(failures, "restart previous container: "+err.Error()) 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 { if len(failures) > 0 {
@@ -149,6 +194,7 @@ type containerSummary struct {
type containerInspect struct { type containerInspect struct {
ID string `json:"Id"` ID string `json:"Id"`
Image string `json:"Image"`
Name string `json:"Name"` Name string `json:"Name"`
Config json.RawMessage `json:"Config"` Config json.RawMessage `json:"Config"`
HostConfig json.RawMessage `json:"HostConfig"` HostConfig json.RawMessage `json:"HostConfig"`
@@ -158,6 +204,23 @@ type containerInspect struct {
State containerState `json:"State"` 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 { type endpointSettings struct {
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"` IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
Links []string `json:"Links,omitempty"` Links []string `json:"Links,omitempty"`
@@ -241,8 +304,8 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS
return running[0], nil return running[0], nil
} }
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error { func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error {
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]}) authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]})
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}} headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
query := url.Values{"fromImage": {repository}, "tag": {tag}} query := url.Values{"fromImage": {repository}, "tag": {tag}}
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true) response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
@@ -282,6 +345,28 @@ func (c *dockerClient) inspectContainer(ctx context.Context, id string) (contain
return inspected, nil 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 { func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
query := url.Values{"t": {fmt.Sprint(seconds)}} query := url.Values{"t": {fmt.Sprint(seconds)}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true) response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
@@ -308,13 +393,338 @@ func (c *dockerClient) renameContainer(ctx context.Context, id, name string) err
return dockerStatusError(response) return dockerStatusError(response)
} }
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) { func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) {
var body map[string]json.RawMessage var config struct {
if err := json.Unmarshal(inspected.Config, &body); err != nil { Image string `json:"Image"`
return "", fmt.Errorf("decode container config: %w", err) Labels map[string]string `json:"Labels"`
} }
imageJSON, _ := json.Marshal(image) if err := json.Unmarshal(rawConfig, &config); err != nil {
body["Image"] = imageJSON 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, &current); 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 body["HostConfig"] = inspected.HostConfig
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks)) endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
oldIDPrefix := inspected.ID oldIDPrefix := inspected.ID
@@ -383,7 +793,7 @@ func (c *dockerClient) removeContainer(ctx context.Context, id string, force boo
return dockerStatusError(response) return dockerStatusError(response)
} }
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error { func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error {
deadline := time.NewTimer(timeout) deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second) ticker := time.NewTicker(time.Second)
defer deadline.Stop() defer deadline.Stop()
@@ -404,9 +814,24 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.
if !inspected.State.Running { if !inspected.State.Running {
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode) 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 inspected.State.Health == nil {
if time.Since(startedAt) >= 8*time.Second { if time.Since(startedAt) >= 8*time.Second {
return nil return errors.New("replacement container has no healthcheck")
} }
continue continue
} }
+205
View File
@@ -0,0 +1,205 @@
package updateagent
import (
"encoding/json"
"strconv"
"testing"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
)
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 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
}
+119
View File
@@ -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
}
+53
View File
@@ -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)
}
}
+46
View File
@@ -1,7 +1,10 @@
package updateprotocol package updateprotocol
import ( import (
"fmt"
"regexp" "regexp"
"strconv"
"strings"
"time" "time"
) )
@@ -19,7 +22,10 @@ const (
type Request struct { type Request struct {
Version string `json:"version"` Version string `json:"version"`
CurrentVersion string `json:"current_version"`
RegistryUsername string `json:"registry_username"` 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"` RegistryToken string `json:"registry_token"`
} }
@@ -29,6 +35,8 @@ type Status struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"` StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_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 { func (s Status) Busy() bool {
@@ -38,3 +46,41 @@ func (s Status) Busy() bool {
func ValidVersion(version string) bool { func ValidVersion(version string) bool {
return len(version) <= 64 && releaseVersionPattern.MatchString(version) 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
}
+53 -8
View File
@@ -96,6 +96,23 @@ type accessZone struct {
ManagedTargetIDs map[int64]bool ManagedTargetIDs map[int64]bool
} }
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) (*Server, error) { func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
templates, err := loadTemplates() templates, err := loadTemplates()
if err != nil { if err != nil {
@@ -1394,6 +1411,8 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
"phase": status.Phase, "phase": status.Phase,
"message": status.Message, "message": status.Message,
"target_version": status.TargetVersion, "target_version": status.TargetVersion,
"started_at": status.StartedAt,
"updated_at": status.UpdatedAt,
"current_version": state.CurrentVersion, "current_version": state.CurrentVersion,
"latest_version": state.LatestVersion, "latest_version": state.LatestVersion,
}) })
@@ -1488,20 +1507,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()) settings, err := s.store.GetSettings(r.Context())
if err != nil { if err != nil {
s.serverError(w, err) s.serverError(w, err)
return 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("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(response)
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
"running": s.scheduler.IsRunning(),
"enabled": settings.UpdaterEnabled,
"last_ipv4": settings.LastIPv4,
})
} }
func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) { func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1851,7 +1890,13 @@ func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool
func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error { func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10) 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) http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return err return err
} }
+143 -5
View File
@@ -4,6 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"log/slog"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -18,6 +21,7 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
) )
@@ -201,6 +205,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) { func TestLoginErrorRendersAsToast(t *testing.T) {
t.Parallel() t.Parallel()
templates, err := loadTemplates() templates, err := loadTemplates()
@@ -232,6 +261,98 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
} }
} }
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-28`,
`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) { func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
t.Parallel() t.Parallel()
limiter := newLoginLimiter() limiter := newLoginLimiter()
@@ -390,13 +511,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: 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 var notification bytes.Buffer
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState} accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil { if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} { 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) { if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected) t.Errorf("global update notification is missing %q", expected)
} }
@@ -407,7 +529,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil { if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} { for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`} {
if !strings.Contains(page.String(), expected) { if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", expected) t.Errorf("software update page is missing %q", expected)
} }
@@ -417,7 +539,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil { if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} { for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} {
if !strings.Contains(freshPage.String(), expected) { if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected) t.Errorf("fresh software update page is missing %q", expected)
} }
@@ -425,6 +547,22 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if strings.Contains(freshPage.String(), "Update-Kanal") { if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered") 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(&currentPage, "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 TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) { func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
@@ -510,7 +648,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
} }
body := rendered.String() body := rendered.String()
statusAt := strings.Index(body, `A unverändert`) 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 { if timeAt < 0 || statusAt < timeAt {
t.Fatalf("target time must be rendered before the status pill: %s", body) t.Fatalf("target time must be rendered before the status pill: %s", body)
} }
+230 -15
View File
@@ -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]'); const toast = document.querySelector('[data-toast]');
if (toast) { if (toast) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -255,22 +283,101 @@
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')]; const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
if (softwareUpdateForms.length > 0) { if (softwareUpdateForms.length > 0) {
const overlay = document.querySelector('[data-software-update-overlay]'); 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 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 close = overlay?.querySelector('[data-software-update-close]');
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])]; const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
const phaseOrder = ['pulling', 'restarting', 'verifying']; 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 polling = false;
let targetVersion = ''; let targetVersion = '';
let startedAt = 0; 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;
const setUpdatePhase = (phase, text) => { 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; 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.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 (message && text) message.textContent = text;
const currentIndex = phaseOrder.indexOf(phase); if (phaseOrder.includes(phase)) lastKnownPhase = phase;
const visualPhase = phase === 'reconnecting' || phase === 'failed' ? lastKnownPhase : phase;
const currentIndex = phaseOrder.indexOf(visualPhase);
for (const node of phaseNodes) { for (const node of phaseNodes) {
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase); const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
node.classList.toggle('is-active', nodeIndex === currentIndex); node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed');
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete'); node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
} }
}; };
@@ -292,11 +399,11 @@
queued: 'Update ist eingereiht.', queued: 'Update ist eingereiht.',
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.', pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.', restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
verifying: 'Die neue Instanz durchläuft den Healthcheck.', verifying: 'Die neue Instanz durchläuft den Healthcheck. Dieser Schritt kann etwas dauern.',
complete: `Version ${state.target_version || targetVersion} ist bereit.`, complete: `Version ${state.target_version || targetVersion} ist bereit.`,
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.', failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
}; };
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.'); setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at);
if (phase === 'complete') { if (phase === 'complete') {
polling = false; polling = false;
window.setTimeout(() => window.location.assign('/system'), 1400); window.setTimeout(() => window.location.assign('/system'), 1400);
@@ -308,7 +415,11 @@
return; return;
} }
} catch { } catch {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.'); 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); window.setTimeout(pollSoftwareUpdate, 1500);
}; };
@@ -318,17 +429,37 @@
event.preventDefault(); event.preventDefault();
if (polling || !overlay) return; if (polling || !overlay) return;
const formData = new FormData(form); 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') || ''); 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; overlay.hidden = false;
if (close) close.hidden = true;
document.body.classList.add('software-update-open'); document.body.classList.add('software-update-open');
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`); setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', ''); for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
try { try {
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}}); const response = await fetch(form.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
const result = await response.json().catch(() => ({})); 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.'); if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
polling = true; polling = true;
startedAt = Date.now();
window.setTimeout(pollSoftwareUpdate, 400); window.setTimeout(pollSoftwareUpdate, 400);
} catch (error) { } catch (error) {
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.'); setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
@@ -342,18 +473,25 @@
overlay.hidden = true; overlay.hidden = true;
document.body.classList.remove('software-update-open'); document.body.classList.remove('software-update-open');
close.hidden = true; close.hidden = true;
stopProgressTimer();
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled'); for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
}); });
} }
const countdowns = [...document.querySelectorAll('[data-countdown]')]; const countdowns = [...document.querySelectorAll('[data-countdown]')];
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')]; const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
if (countdowns.length === 0 && currentIPv4Fields.length === 0) return; 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 nextRun = Number(countdowns[0]?.dataset.countdown || 0);
let running = countdowns[0]?.dataset.running === 'true'; let running = countdowns[0]?.dataset.running === 'true';
let enabled = countdowns[0]?.dataset.enabled !== 'false'; let enabled = countdowns[0]?.dataset.enabled !== 'false';
let lastRefresh = 0; let lastRefresh = 0;
let refreshing = false;
let recentRunsSignature = '';
const formatRemaining = (seconds) => { const formatRemaining = (seconds) => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
@@ -363,23 +501,100 @@
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; 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 () => { const refreshSchedule = async () => {
if (document.hidden) return; if (document.hidden || refreshing) return;
const now = Date.now(); const now = Date.now();
if (now - lastRefresh < 2500) return; if (now - lastRefresh < 2500) return;
lastRefresh = now; lastRefresh = now;
refreshing = true;
try { 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; if (!response.ok) return;
const state = await response.json(); const state = await response.json();
nextRun = Number(state.next_run_unix || 0); nextRun = Number(state.next_run_unix || 0);
running = Boolean(state.running); running = Boolean(state.running);
enabled = state.enabled !== false; enabled = state.enabled !== false;
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) { renderScheduleState(state);
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
}
} catch { } catch {
// Visible scheduler data keeps its last known state when the request fails. // Visible scheduler data keeps its last known state when the request fails.
} finally {
refreshing = false;
} }
}; };
+129 -33
View File
@@ -76,7 +76,8 @@ 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 { 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 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; } .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); animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; } .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-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, .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 > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
@@ -171,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-warn { background: var(--warning-soft); }
.status-pill.status-bad { background: var(--danger-soft); } .status-pill.status-bad { background: var(--danger-soft); }
.domain-list { display: grid; } .dashboard-zones-panel { overflow: hidden; background: radial-gradient(circle at 100% 0,rgba(37,99,235,.055),transparent 35%),var(--surface); }
.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; } .dashboard-zone-count { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
.domain-list > a:first-child { border-top: 0; } .dashboard-zone-list { display: grid; gap: 8px; }
.domain-list strong, .domain-list small { display: block; } .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; }
.domain-list strong { font-size: 12px; } .dashboard-zone-item:hover { border-color: #b8cae4; color: inherit; box-shadow: 0 10px 24px rgba(31,64,111,.08); transform: translateY(-1px); }
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; } .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); }
.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-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-panel { margin-top: 12px; }
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; } .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; } .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; }
@@ -189,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 { 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:hover { border-color: #b6c4d4; }
.zone-card-top { display: flex; align-items: center; justify-content: space-between; } .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 h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; } .zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; } .zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
@@ -493,41 +514,105 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.software-update-button i { font-style: normal; transition: transform .2s ease; } .software-update-button i { font-style: normal; transition: transform .2s ease; }
.software-update-button:hover i { transform: translate(2px,-2px); } .software-update-button:hover i { transform: translate(2px,-2px); }
.update-credential-panel { width: min(930px,100%); } .update-credential-panel { width: min(930px,100%); }
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
.update-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 { 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 > 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 p { margin: 0; }
.update-inline-warning strong, .update-inline-warning small { display: block; } .update-inline-warning strong, .update-inline-warning small { display: block; }
.update-inline-warning strong { color: #74540b; font-size: 9px; } .update-inline-warning strong { color: #74540b; font-size: 9px; }
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; } .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: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s ease both; } .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 { width: min(520px,100%); padding: 30px; border: 1px solid rgba(255,255,255,.14); border-radius: 18px; background: #111c2e; color: #f8fafc; box-shadow: 0 30px 80px rgba(0,0,0,.32); text-align: center; } .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 .section-label { color: #7f94af; } .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 h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; } .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 p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; } .software-update-dialog .section-label { position: relative; color: #7f94af; }
.software-update-orbit { position: relative; display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 20px; border: 1px solid #344862; border-radius: 18px; background: #18283f; color: #b7d1ff; font-family: var(--mono); font-size: 11px; font-weight: 900; } .software-update-dialog h2 { position: relative; margin: 7px 0 8px; color: #fff; font-size: 25px; letter-spacing: -.025em; }
.software-update-orbit i { position: absolute; inset: -8px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.25); border-radius: 22px; animation: update-orbit 1.15s linear infinite; } .software-update-dialog p { position: relative; min-height: 36px; margin: 0 auto 17px; color: #a8b8cc; font-size: 11px; line-height: 1.55; }
.software-update-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; } .software-update-visual { position: relative; display: grid; width: 166px; height: 112px; place-items: center; margin: -2px auto 13px; }
.software-update-progress i { display: block; width: 8%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#60a5fa,#2dd4bf); box-shadow: 0 0 14px rgba(96,165,250,.45); transition: width .55s cubic-bezier(.2,.8,.3,1), background .2s; } .software-update-visual::before, .software-update-visual::after { position: absolute; border: 1px solid rgba(96,165,250,.18); border-radius: 50%; content: ""; }
.software-update-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; } .software-update-visual::before { width: 136px; height: 78px; transform: rotate(-9deg); animation: update-ring-left 4.8s linear infinite; }
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; } .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-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; } .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-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; } .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-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; } .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-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; } .software-update-orbit span::after { content: ""; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; } .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; }
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; } .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); }
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; } .signal-a { animation: update-signal-a 2.4s ease-in-out infinite; }
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; } .signal-b { animation: update-signal-b 3s ease-in-out -.7s infinite; }
.software-update-phases li span { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid #2c3e57; border-radius: 8px; font-family: var(--mono); font-size: 7px; } .signal-c { width: 4px; height: 4px; background: #5eead4; animation: update-signal-c 2.7s ease-in-out -1.3s infinite; }
.software-update-phases li.is-active { color: #b9d4ff; } .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-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; } .software-update-progress-meta strong { color: #a9c9ff; font-size: 9px; font-variant-numeric: tabular-nums; transition: color .25s; }
.software-update-phases li.is-complete { color: #6fc5b6; } .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-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; } .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; } .software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
body.software-update-open { overflow: hidden; } body.software-update-open { overflow: hidden; }
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } } @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-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-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); } }
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; } .error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; } .error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
@@ -624,6 +709,8 @@ body.software-update-open { overflow: hidden; }
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; } .software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
.software-version-action { grid-column: 1 / -1; } .software-version-action { grid-column: 1 / -1; }
.software-version-action form, .software-version-action .button { width: 100%; } .software-version-action form, .software-version-action .button { width: 100%; }
.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 { grid-template-columns: 42px minmax(0,1fr); }
.admin-full-access-card > .status-pill { grid-column: 2; } .admin-full-access-card > .status-pill { grid-column: 2; }
.auth-shell { align-items: start; padding: 12px; } .auth-shell { align-items: start; padding: 12px; }
@@ -645,7 +732,11 @@ body.software-update-open { overflow: hidden; }
.software-version-cell { min-width: 0; padding-right: 12px; } .software-version-cell { min-width: 0; padding-right: 12px; }
.software-version-cell + .software-version-cell { padding-left: 12px; } .software-version-cell + .software-version-cell { padding-left: 12px; }
.software-version-cell strong { font-size: 21px; } .software-version-cell strong { font-size: 21px; }
.software-update-dialog { padding: 24px 18px; } .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 { padding: 18px; }
.onboarding-card-meta { margin-bottom: 18px; } .onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 28px; } .onboarding-form-heading h2 { font-size: 28px; }
@@ -659,6 +750,11 @@ body.software-update-open { overflow: hidden; }
} }
@media (max-height: 700px) { @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; }
.onboarding-intro { padding-block: 30px; } .onboarding-intro { padding-block: 30px; }
.onboarding-copy { margin-top: 28px; } .onboarding-copy { margin-top: 28px; }
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; } .onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
+18 -9
View File
@@ -10,7 +10,7 @@
<div class="heading-actions"> <div class="heading-actions">
<form action="/run" method="post"> <form action="/run" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <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> </form>
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}} {{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
</div> </div>
@@ -19,8 +19,8 @@
<section class="metric-grid" aria-label="DynDNS-Status"> <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">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"><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">{{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 {{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> <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> </section>
@@ -28,21 +28,30 @@
{{if .IsAdmin}} {{if .IsAdmin}}
<article class="panel"> <article class="panel">
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div> <div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
<div data-recent-runs aria-live="polite" aria-busy="{{.Dashboard.SchedulerRunning}}">
{{if .Dashboard.RecentRuns}} {{if .Dashboard.RecentRuns}}
<div class="data-list"> <div class="data-list">
{{range .Dashboard.RecentRuns}} {{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}} {{end}}
</div> </div>
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}} {{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
</div>
</article> </article>
{{end}} {{end}}
<article class="panel"> <article class="panel dashboard-zones-panel">
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div> <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}} {{if .Dashboard.Zones}}
<div class="domain-list"> <div class="dashboard-zone-list">
{{range .Dashboard.Zones}} {{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}} {{end}}
</div> </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}} {{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}} {{if and .IsAdmin .Dashboard.RecentEvents}}
<section class="panel audit-panel"> <section class="panel audit-panel">
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div> <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> </section>
{{end}} {{end}}
{{end}} {{end}}
+3 -3
View File
@@ -7,7 +7,7 @@
<section class="zone-summary" aria-label="Zonenstatus"> <section class="zone-summary" aria-label="Zonenstatus">
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div> <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> <div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}} {{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
</section> </section>
@@ -27,7 +27,7 @@
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}} {{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
<article class="target-row {{if .Active}}is-active{{end}}"> <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-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"> {{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}}"> <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}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -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> <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"> <div class="archive-list">
{{range .ArchivedTargets}} {{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}} {{end}}
</div> </div>
</section> </section>
+1 -1
View File
@@ -4,7 +4,7 @@
<section class="form-panel compact-panel"> <section class="form-panel compact-panel">
<form action="/domains" method="post" class="stacked-form wide-form"> <form action="/domains" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <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">&nbsp;</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> <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="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> <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>
+2 -2
View File
@@ -8,9 +8,9 @@
{{if .Zones}} {{if .Zones}}
{{range .Zones}} {{range .Zones}}
<article class="zone-card"> <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> <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}} {{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a> <a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
</article> </article>
+4 -4
View File
@@ -23,7 +23,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="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"> <div class="error-list">
{{range .ErrorTargets}} {{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}} {{end}}
</div> </div>
</section> </section>
@@ -34,7 +34,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="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"> <div class="error-list">
{{range .ErrorPiHoles}} {{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}} {{end}}
</div> </div>
</section> </section>
@@ -45,7 +45,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="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"> <div class="error-list">
{{range .ErrorZones}} {{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}} {{end}}
</div> </div>
</section> </section>
@@ -56,7 +56,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="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"> <div class="error-list run-error-list">
{{range .ErrorRuns}} {{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}} {{end}}
</div> </div>
</section> </section>
+28 -14
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title> <title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-21"> <link rel="stylesheet" href="/static/styles.css?v=features-28">
<script src="/static/app.js?v=features-21" defer></script> <script src="/static/app.js?v=features-28" defer></script>
</head> </head>
<body class="page-{{.Page}}"> <body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a> <a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -44,12 +44,12 @@
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}} {{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
</header> </header>
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}} {{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<aside class="software-update-banner" aria-label="Software-Update verfügbar"> <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> <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><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
<div class="software-update-banner-actions"> <div class="software-update-banner-actions">
<a class="button button-quiet" href="/system">Details</a> <a class="button button-quiet" href="/system">Details</a>
{{if .SoftwareUpdate.AgentAvailable}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}} {{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
</div> </div>
</aside> </aside>
{{end}} {{end}}
@@ -77,17 +77,31 @@
</div> </div>
{{end}} {{end}}
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}} {{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title-global"> {{template "software_update_overlay" .}}
<div class="software-update-dialog">
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span><h2 id="software-update-title-global">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt"><li data-update-phase="pulling"><span>01</span>Image laden</li><li data-update-phase="restarting"><span>02</span>Dienst wechseln</li><li data-update-phase="verifying"><span>03</span>Healthcheck</li></ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{end}} {{end}}
</body> </body>
</html> </html>
{{end}} {{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}}
+2 -2
View File
@@ -12,7 +12,7 @@
{{$integration := $view.Integration}} {{$integration := $view.Integration}}
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status"> <section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
<div><span>Provider</span><strong>Pi-hole API v6</strong></div> <div><span>Provider</span><strong>Pi-hole API v6</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div> <div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div> <div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div> <div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
</section> </section>
@@ -57,7 +57,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> <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"> <div class="archive-list">
{{range $view.ArchivedRecords}} {{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}} {{end}}
</div> </div>
</section> </section>
+1 -1
View File
@@ -24,7 +24,7 @@
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required> <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> <small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
</label> </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>
<div class="form-grid endpoint-grid"> <div class="form-grid endpoint-grid">
<label>IPv4-Endpunkte <label>IPv4-Endpunkte
+24 -24
View File
@@ -10,49 +10,49 @@
<div class="software-version-copy"> <div class="software-version-copy">
<div class="software-version-values"> <div class="software-version-values">
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div> <div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div> <div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
</div> </div>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p> <p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{end}}</b></span></div> <div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}} {{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
</div> </div>
<div class="software-version-action"> <div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}} {{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
<form action="/system/update" method="post" data-software-update-form> <form action="/system/update" method="post" data-software-update-form>
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"> <input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button> <button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
</form> </form>
{{else if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.ComposeSyncRequired}}
<span class="status-pill status-warn">Compose aktualisieren</span>
{{else if .SoftwareUpdate.UpdateAvailable}} {{else if .SoftwareUpdate.UpdateAvailable}}
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span> <span class="status-pill status-warn">Agent nicht erreichbar</span>
{{else}} {{else}}
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured)}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span> <span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
{{end}} {{end}}
</div> </div>
</section> </section>
<section class="panel update-credential-panel" id="update-access"> <section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>{{if .SoftwareUpdate.CredentialConfigured}}Update-Zugang ersetzen{{else}}Update-Zugang einrichten{{end}}</h2></div>{{if .SoftwareUpdate.CredentialConfigured}}<span class="status-pill status-good">verschlüsselt</span>{{end}}</div> {{if .SoftwareUpdate.CredentialConfigured}}
<div class="update-access-state">
<span class="update-access-state-icon" aria-hidden="true">✓</span>
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
</div>
<details class="update-credential-editor">
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
{{else}}
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
{{end}}
<form action="/system/update-credential" method="post" class="stacked-form"> <form action="/system/update-credential" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div> <div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label> <label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
<div class="form-actions"><button class="button button-primary" type="submit">Zugang sicher speichern</button></div> <div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
</form> </form>
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
</section> </section>
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title"> {{template "software_update_overlay" .}}
<div class="software-update-dialog">
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span>
<h2 id="software-update-title">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen. DNS-Daten und Secrets bleiben erhalten.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt">
<li data-update-phase="pulling"><span>01</span>Image laden</li>
<li data-update-phase="restarting"><span>02</span>Dienst wechseln</li>
<li data-update-phase="verifying"><span>03</span>Healthcheck</li>
</ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{end}} {{end}}
+1 -1
View File
@@ -5,5 +5,5 @@
<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 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> <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>
<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> <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}} {{end}}