Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02ec9977c | ||
|
|
a000152423 | ||
|
|
2082b68794 | ||
|
|
42fa6efc84 |
No files matched your search
@@ -43,3 +43,5 @@ jobs:
|
|||||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
org.opencontainers.image.version=${{ gitea.ref_name }}
|
org.opencontainers.image.version=${{ gitea.ref_name }}
|
||||||
provenance: false
|
provenance: false
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ gitea.ref_name }}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Arbeitsanweisung für Agenten
|
||||||
|
|
||||||
|
Diese Datei gilt für das gesamte Repository. Lies sie vor Änderungen am Projekt vollständig.
|
||||||
|
|
||||||
|
## Release-Grundsätze
|
||||||
|
|
||||||
|
- Veröffentliche nur, wenn der Benutzer das Release ausdrücklich freigegeben hat.
|
||||||
|
- Verwende Semantic Versioning mit führendem `v`, beispielsweise `v1.2.2`.
|
||||||
|
- Bestehende Tags und veröffentlichte Versions-Images werden niemals verschoben, überschrieben oder erneut verwendet.
|
||||||
|
- `compose.yml` bleibt auf eine konkrete Version gepinnt. Verwende dort nicht `stable`.
|
||||||
|
- Veröffentliche genau zwei Registry-Tags: die konkrete Version und `stable`.
|
||||||
|
- Ein Release muss weiterhin `linux/amd64` und `linux/arm64` enthalten.
|
||||||
|
- Verändere oder lösche beim Release keine Datenbank-, Daten- oder Secret-Volumes.
|
||||||
|
- Gib niemals Git-, Registry-, Hetzner- oder Pi-hole-Zugangsdaten in Befehlsausgaben, URLs, Commits oder Release Notes aus.
|
||||||
|
- Verwende kein Force-Push und keine destruktiven Git-Befehle.
|
||||||
|
|
||||||
|
## Ziele
|
||||||
|
|
||||||
|
- Quell-Repository: `https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface.git`
|
||||||
|
- Container-Image: `git.j-mahler.de/admin/domain-control-webinterface-prod`
|
||||||
|
- Release-Workflow: `.gitea/workflows/release.yml`
|
||||||
|
- Deployment: `compose.yml`, standardmäßig Port `9090`
|
||||||
|
- Healthcheck: `http://127.0.0.1:9090/healthz`
|
||||||
|
|
||||||
|
## 1. Release-Kandidaten prüfen
|
||||||
|
|
||||||
|
Arbeite nur auf `main`. Prüfe zuerst den vollständigen Zustand und erhalte alle vorhandenen Benutzeränderungen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short --branch
|
||||||
|
git diff --stat
|
||||||
|
git diff --check
|
||||||
|
git remote -v
|
||||||
|
git tag --sort=-v:refname | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontrolliere den tatsächlichen Diff. Stage oder committe keine fremden oder sachfremden Änderungen. Falls Frontend-Dateien geändert wurden, erhöhe in `internal/web/templates/layout.html` die Cache-Version beider Assets gemeinsam, beispielsweise von `features-14` auf `features-15`.
|
||||||
|
|
||||||
|
## 2. Version anheben
|
||||||
|
|
||||||
|
Lege eine noch nicht vorhandene Version fest. Für reine Fehler- und UI-Korrekturen wird normalerweise die Patch-Version erhöht.
|
||||||
|
|
||||||
|
Ersetze die bisherige Version mindestens an diesen Stellen:
|
||||||
|
|
||||||
|
- `compose.yml`
|
||||||
|
- Image-Beispiel in `README.md`
|
||||||
|
|
||||||
|
Danach dürfen diese Dateien die vorherige Version nicht mehr enthalten. Setze `PREVIOUS` auf die tatsächlich vorherige Version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PREVIOUS=v1.2.1
|
||||||
|
if grep -n "$PREVIOUS" README.md compose.yml; then
|
||||||
|
echo "Alte Version $PREVIOUS ist noch eingetragen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker compose -f compose.yml config --quiet
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Pflichtprüfungen vor dem Tag
|
||||||
|
|
||||||
|
JavaScript und Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check internal/web/static/app.js
|
||||||
|
docker compose -f compose.yml config --quiet
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewusst geänderte Dateien, bei Bedarf ebenfalls im Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD":/src \
|
||||||
|
-w /src golang:1.25.13-alpine \
|
||||||
|
gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Projekt wird in einem Go-Container geprüft, weil auf dem Host nicht zwingend Go installiert ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD":/src \
|
||||||
|
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||||
|
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
||||||
|
-w /src golang:1.25.13-alpine \
|
||||||
|
sh -c 'apk add --no-cache build-base >/dev/null && go vet ./... && go test -race -count=1 ./...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei Abhängigkeits-, Kryptografie-, Authentifizierungs- oder sonstigen Sicherheitsänderungen zusätzlich:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD":/src \
|
||||||
|
-v /tmp/dyndns-go-mod:/go/pkg/mod \
|
||||||
|
-v /tmp/dyndns-go-build:/root/.cache/go-build \
|
||||||
|
-w /src golang:1.25.13-alpine \
|
||||||
|
sh -c 'apk add --no-cache build-base >/dev/null && go run golang.org/x/vuln/cmd/govulncheck@latest ./...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei einem Fehler wird nicht getaggt oder veröffentlicht. Behebe den Fehler und beginne die finalen Prüfungen erneut.
|
||||||
|
|
||||||
|
## 4. Commit, Tag und Git-Push
|
||||||
|
|
||||||
|
Stage nur die geprüften Dateien und kontrolliere den Index:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add DATEI1 DATEI2
|
||||||
|
git diff --cached --check
|
||||||
|
git diff --cached --stat
|
||||||
|
git diff --cached
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstelle einen sachlichen Commit und einen annotierten Tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "fix: kurze Beschreibung"
|
||||||
|
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
||||||
|
git show --no-patch --format=fuller vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushe Branch und Tag atomar, damit nicht nur einer von beiden veröffentlicht wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --atomic origin main refs/tags/vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfe anschließend, dass Remote-Branch und aufgelöster Tag auf denselben Release-Commit zeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git ls-remote origin refs/heads/main refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Container-Image veröffentlichen
|
||||||
|
|
||||||
|
Ein `v*`-Tag startet den Gitea-Workflow. Prüfe zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Manueller Multi-Arch-Fallback
|
||||||
|
|
||||||
|
Melde Docker vorab interaktiv an, falls noch keine Registry-Anmeldung besteht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.j-mahler.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Gib den Token nur über die verdeckte Passwortabfrage ein. Der Token benötigt mindestens Paket-Schreibzugriff.
|
||||||
|
|
||||||
|
Prüfe den vorhandenen Builder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx inspect domain-control-release-builder --bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls er nicht existiert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx create --name domain-control-release-builder --driver docker-container --use
|
||||||
|
docker buildx inspect domain-control-release-builder --bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls `linux/arm64` nicht unter `Platforms` erscheint, registriere einmalig die ARM64-Emulation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.0.4-56 --install arm64
|
||||||
|
docker buildx inspect domain-control-release-builder --bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
Setze Version und Commit bewusst und prüfe beide Werte vor dem Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=vX.Y.Z
|
||||||
|
REVISION="$(git rev-parse HEAD)"
|
||||||
|
printf 'Version: %s\nCommit: %s\n' "$VERSION" "$REVISION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Baue und pushe anschließend beide Plattformen in einem gemeinsamen Manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build \
|
||||||
|
--builder domain-control-release-builder \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION" \
|
||||||
|
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:stable" \
|
||||||
|
--label org.opencontainers.image.source=https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface \
|
||||||
|
--label "org.opencontainers.image.revision=$REVISION" \
|
||||||
|
--label "org.opencontainers.image.version=$VERSION" \
|
||||||
|
--provenance=false \
|
||||||
|
--push .
|
||||||
|
```
|
||||||
|
|
||||||
|
Der emulierte ARM64-Build mit CGO und SQLite kann etwa acht Minuten lang ohne neue Ausgabe rechnen. Nicht allein deshalb abbrechen. Prüfe bei Zweifeln, ob der BuildKit-Container CPU verwendet.
|
||||||
|
|
||||||
|
## 6. Registry verifizieren
|
||||||
|
|
||||||
|
Prüfe sowohl die konkrete Version als auch `stable`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx imagetools inspect "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION"
|
||||||
|
docker buildx imagetools inspect git.j-mahler.de/admin/domain-control-webinterface-prod:stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide Ausgaben müssen denselben Manifest-Digest und genau diese Plattformen zeigen:
|
||||||
|
|
||||||
|
- `linux/amd64`
|
||||||
|
- `linux/arm64`
|
||||||
|
|
||||||
|
Ein erfolgreicher Build ohne diese Manifestprüfung gilt nicht als abgeschlossenes Release.
|
||||||
|
|
||||||
|
## 7. Gitea-Releaseeintrag
|
||||||
|
|
||||||
|
Lege für den neuen Tag einen veröffentlichten, nicht als Entwurf und nicht als Pre-Release markierten Gitea-Releaseeintrag an.
|
||||||
|
|
||||||
|
Die Release Notes enthalten mindestens:
|
||||||
|
|
||||||
|
- kurze, benutzerverständliche Änderungen seit der vorherigen Version
|
||||||
|
- vollständigen Image-Namen mit Version
|
||||||
|
- `linux/amd64` und `linux/arm64`
|
||||||
|
|
||||||
|
Nutze vorhandene Anmeldedaten sicher über Gitea UI oder API. Schreibe Tokens niemals in die URL oder die Release Notes. Lies den erzeugten Eintrag anschließend zurück und kontrolliere Tag, Titel und Beschreibung.
|
||||||
|
|
||||||
|
## 8. Laufenden Container auf das Release umstellen
|
||||||
|
|
||||||
|
Erst nach erfolgreicher Registry-Prüfung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Compose verwendet dieselben benannten Daten- und Secret-Volumes. Erstelle sie nicht neu und lösche sie nicht.
|
||||||
|
|
||||||
|
Prüfe abschließend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker inspect --format 'image={{.Config.Image}} digest={{.Image}} health={{if .State.Health}}{{.State.Health.Status}}{{end}} readonly={{.HostConfig.ReadonlyRootfs}} user={{.Config.User}}' hetzner-api-dyndns-records-webinterface-dyndns-1
|
||||||
|
docker image inspect --format '{{json .RepoDigests}}' "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION"
|
||||||
|
curl --silent --show-error --fail http://127.0.0.1:9090/healthz
|
||||||
|
docker logs --tail 80 hetzner-api-dyndns-records-webinterface-dyndns-1
|
||||||
|
git status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet werden:
|
||||||
|
|
||||||
|
- Containerstatus `healthy`
|
||||||
|
- Image-Name mit der konkreten Version, nicht mit einem lokalen Testtag
|
||||||
|
- schreibgeschütztes Root-Dateisystem
|
||||||
|
- Benutzer `dyndns:dyndns`
|
||||||
|
- Registry-Digest identisch mit dem zuvor geprüften Versionsmanifest
|
||||||
|
- HTTP-Antwort `ok`
|
||||||
|
- sauberer und mit `origin/main` synchroner Git-Arbeitsbaum
|
||||||
|
|
||||||
|
## Abschlussmeldung
|
||||||
|
|
||||||
|
Melde dem Benutzer kompakt:
|
||||||
|
|
||||||
|
- Version und Commit
|
||||||
|
- Link zum Gitea-Release
|
||||||
|
- veröffentlichte Tags
|
||||||
|
- Manifest-Digest und Plattformen
|
||||||
|
- bestandene Prüfungen
|
||||||
|
- Healthstatus des laufenden Containers
|
||||||
|
- einen weiterhin bestehenden Runner- oder Infrastrukturhinweis
|
||||||
|
|
||||||
|
Ein Git-Tag allein, ein nur lokal gebautes Image oder ein lediglich gestarteter Workflow ist kein abgeschlossenes Release.
|
||||||
+6
-2
@@ -1,5 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM golang:1.25.12-alpine AS build
|
FROM golang:1.25.13-alpine AS build
|
||||||
|
|
||||||
|
ARG VERSION=v1.3.0
|
||||||
|
|
||||||
RUN apk add --no-cache build-base
|
RUN apk add --no-cache build-base
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -8,7 +10,8 @@ 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" -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/dyndns-web ./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
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ RUN apk add --no-cache ca-certificates && \
|
|||||||
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/dyndns-web /usr/local/bin/dyndns-web
|
||||||
|
COPY --from=build /out/update-agent /usr/local/bin/update-agent
|
||||||
|
|
||||||
USER dyndns:dyndns
|
USER dyndns:dyndns
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|||||||
@@ -23,36 +23,76 @@ 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:
|
||||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.1
|
<<: *domain-control-image
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9090:8080"
|
- "9090:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- dyndns-data:/data
|
- dyndns-data:/data
|
||||||
- dyndns-secrets:/secrets
|
- dyndns-secrets:/secrets
|
||||||
|
- dyndns-update-control:/run/domain-control-update
|
||||||
|
labels:
|
||||||
|
de.j-mahler.domain-control.role: app
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
|
||||||
|
update-agent:
|
||||||
|
<<: *domain-control-image
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:10001"
|
||||||
|
entrypoint: [/usr/local/bin/update-agent]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
network_mode: none
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- dyndns-update-control:/run/domain-control-update
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
pids_limit: 32
|
||||||
|
mem_limit: 64m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dyndns-data:
|
dyndns-data:
|
||||||
dyndns-secrets:
|
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.
|
||||||
|
|
||||||
## Aktualisieren
|
## Aktualisieren
|
||||||
|
|
||||||
In `compose.yml` die Versionsnummer beim Image ändern, anschließend das Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
|
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
||||||
|
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
||||||
|
|
||||||
|
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. 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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
@@ -63,6 +103,8 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
|
|||||||
- lokales Archiv statt Löschen von Hetzner-Records
|
- lokales Archiv statt Löschen von Hetzner-Records
|
||||||
- frei einstellbares Update-Intervall mit Countdown
|
- frei einstellbares Update-Intervall mit Countdown
|
||||||
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||||
|
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
||||||
|
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
|
Die aktuelle Compose-Version ist `v1.3.0`. Datenbank, Einstellungen und
|
||||||
|
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||||
|
den folgenden Verfahren erhalten.
|
||||||
|
|
||||||
|
## Direkt in der Weboberfläche
|
||||||
|
|
||||||
|
1. Als Administrator anmelden und **Update** öffnen.
|
||||||
|
2. Falls noch nicht geschehen, Gitea-Benutzername und einen Token mit
|
||||||
|
Lesezugriff auf Repository und Container-Paket hinterlegen.
|
||||||
|
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
|
||||||
|
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
|
||||||
|
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
|
||||||
|
Container wiederherzustellen.
|
||||||
|
|
||||||
|
Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden
|
||||||
|
DynDNS-Dienst nicht.
|
||||||
|
|
||||||
|
## Mit Docker Compose
|
||||||
|
|
||||||
|
In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
x-domain-control-image: &domain-control-image
|
||||||
|
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach im Ordner der Compose-Datei ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Web-App und der Update-Agent verwenden durch den YAML-Anker dieselbe
|
||||||
|
Version. Für das Update ist kein `docker compose down` nötig. Insbesondere
|
||||||
|
niemals `docker compose down -v` verwenden, da `-v` die Daten- und
|
||||||
|
Secret-Volumes löschen würde.
|
||||||
|
|
||||||
|
Der Healthcheck lässt sich anschließend direkt prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail http://127.0.0.1:9090/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Die erwartete Antwort lautet `ok`.
|
||||||
|
|
||||||
|
## Portainer oder Dockge
|
||||||
|
|
||||||
|
1. Den bestehenden Stack bearbeiten.
|
||||||
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.3.0` setzen.
|
||||||
|
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
||||||
|
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
||||||
|
|
||||||
|
Den Stack nicht mitsamt seinen Volumes löschen oder neu anlegen.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Falls ein manuelles Rollback nötig ist, die zentrale Image-Zeile wieder auf die
|
||||||
|
zuvor eingesetzte konkrete Version setzen und erneut ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und
|
||||||
|
`dyndns-secrets` gemeinsam gesichert werden. Nur zusammen enthalten sie sowohl
|
||||||
|
die Datenbank als auch den zur Entschlüsselung benötigten Master-Key.
|
||||||
+27
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
|
||||||
@@ -53,6 +54,10 @@ func run(logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
provider := hetzner.New(cfg.HCloudBaseURL)
|
provider := hetzner.New(cfg.HCloudBaseURL)
|
||||||
updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger)
|
updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger)
|
||||||
|
softwareUpdates, err := appupdate.New(database, cipher, cfg.UpdateAPIURL, cfg.UpdateAgentSock, cfg.UpdateInterval, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
settings, err := database.GetSettings(ctx)
|
settings, err := database.GetSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -65,7 +70,7 @@ func run(logger *slog.Logger) error {
|
|||||||
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverUI, err := web.New(cfg, database, cipher, updater, logger)
|
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,8 @@ func run(logger *slog.Logger) error {
|
|||||||
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
updater.Start(appCtx)
|
updater.Start(appCtx)
|
||||||
|
softwareUpdates.Start(appCtx)
|
||||||
|
go pruneStore(appCtx, database, logger)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Address,
|
Addr: cfg.Address,
|
||||||
Handler: serverUI.Handler(),
|
Handler: serverUI.Handler(),
|
||||||
@@ -80,6 +87,7 @@ func run(logger *slog.Logger) error {
|
|||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 16 << 10,
|
||||||
}
|
}
|
||||||
serverErrors := make(chan error, 1)
|
serverErrors := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -100,6 +108,24 @@ func run(logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pruneStore(ctx context.Context, database *store.Store, logger *slog.Logger) {
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
pruneCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
err := database.Prune(pruneCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
logger.Warn("prune old local data", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func healthcheck() int {
|
func healthcheck() int {
|
||||||
url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL"))
|
url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL"))
|
||||||
if url == "" {
|
if url == "" {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+30
-1
@@ -1,12 +1,18 @@
|
|||||||
|
x-domain-control-image: &domain-control-image
|
||||||
|
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
|
||||||
|
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.1
|
<<: *domain-control-image
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9090:8080"
|
- "9090:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- dyndns-data:/data
|
- dyndns-data:/data
|
||||||
- dyndns-secrets:/secrets
|
- 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
|
||||||
@@ -15,6 +21,29 @@ services:
|
|||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
|
||||||
|
update-agent:
|
||||||
|
<<: *domain-control-image
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:10001"
|
||||||
|
entrypoint:
|
||||||
|
- /usr/local/bin/update-agent
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
network_mode: none
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- dyndns-update-control:/run/domain-control-update
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
pids_limit: 32
|
||||||
|
mem_limit: 64m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dyndns-data:
|
dyndns-data:
|
||||||
dyndns-secrets:
|
dyndns-secrets:
|
||||||
|
dyndns-update-control:
|
||||||
@@ -2,6 +2,8 @@ module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
toolchain go1.25.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.47
|
github.com/mattn/go-sqlite3 v1.14.47
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentClient struct {
|
||||||
|
socket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentClient(socket string) *AgentClient {
|
||||||
|
return &AgentClient{socket: socket}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
|
||||||
|
encoded, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusAccepted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusConflict {
|
||||||
|
return errors.New("ein Software-Update läuft bereits")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
|
||||||
|
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
|
||||||
|
if err != nil {
|
||||||
|
return updateprotocol.Status{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
var status updateprotocol.Status
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
|
||||||
|
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||||
|
if c == nil || c.socket == "" {
|
||||||
|
return nil, errors.New("Update-Agent ist nicht konfiguriert")
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
dialer := net.Dialer{Timeout: time.Second}
|
||||||
|
return dialer.DialContext(ctx, "unix", c.socket)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Update-Agent ist nicht erreichbar")
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
CurrentVersion string
|
||||||
|
LatestVersion string
|
||||||
|
ReleaseURL string
|
||||||
|
DesiredVersion string
|
||||||
|
LastCheckedAt time.Time
|
||||||
|
LastError string
|
||||||
|
CredentialConfigured bool
|
||||||
|
UpdateAvailable bool
|
||||||
|
AgentAvailable bool
|
||||||
|
AgentStatus updateprotocol.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
store *store.Store
|
||||||
|
cipher *secure.Cipher
|
||||||
|
logger *slog.Logger
|
||||||
|
apiURL string
|
||||||
|
interval time.Duration
|
||||||
|
agent *AgentClient
|
||||||
|
httpClient *http.Client
|
||||||
|
checkMu sync.Mutex
|
||||||
|
stateMu sync.RWMutex
|
||||||
|
state State
|
||||||
|
checkSignal chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseResponse struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) {
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
manager := &Manager{
|
||||||
|
store: database,
|
||||||
|
cipher: cipher,
|
||||||
|
logger: logger,
|
||||||
|
apiURL: apiURL,
|
||||||
|
interval: interval,
|
||||||
|
agent: NewAgentClient(agentSocket),
|
||||||
|
checkSignal: make(chan struct{}, 1),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 12 * time.Second,
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manager.setStoredState(settings)
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
m.refreshAgentStatus(ctx)
|
||||||
|
initial := time.NewTimer(15 * time.Second)
|
||||||
|
defer initial.Stop()
|
||||||
|
var ticker *time.Ticker
|
||||||
|
var tick <-chan time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if ticker != nil {
|
||||||
|
ticker.Stop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-initial.C:
|
||||||
|
if ticker == nil {
|
||||||
|
ticker = time.NewTicker(m.interval)
|
||||||
|
tick = ticker.C
|
||||||
|
}
|
||||||
|
m.backgroundCheck(ctx)
|
||||||
|
case <-tick:
|
||||||
|
m.backgroundCheck(ctx)
|
||||||
|
case <-m.checkSignal:
|
||||||
|
m.backgroundCheck(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) backgroundCheck(ctx context.Context) {
|
||||||
|
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
||||||
|
m.logger.Warn("software update check failed", "error", err)
|
||||||
|
}
|
||||||
|
m.refreshAgentStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) TriggerCheck() {
|
||||||
|
select {
|
||||||
|
case m.checkSignal <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) State() State {
|
||||||
|
m.stateMu.RLock()
|
||||||
|
defer m.stateMu.RUnlock()
|
||||||
|
return m.state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
|
||||||
|
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
|
||||||
|
if username == "" || len(username) > 128 {
|
||||||
|
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
|
||||||
|
}
|
||||||
|
if len(token) < 8 || len(token) > 1024 {
|
||||||
|
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge")
|
||||||
|
}
|
||||||
|
ciphertext, nonce, err := m.cipher.Encrypt(token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) CheckNow(ctx context.Context) error {
|
||||||
|
m.checkMu.Lock()
|
||||||
|
defer m.checkMu.Unlock()
|
||||||
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
return ErrCredentialMissing
|
||||||
|
}
|
||||||
|
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||||
|
if err != nil {
|
||||||
|
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||||
|
}
|
||||||
|
release, err := m.fetchLatest(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return m.recordFailure(ctx, err)
|
||||||
|
}
|
||||||
|
checkedAt := time.Now().UTC()
|
||||||
|
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||||
|
version = strings.TrimSpace(version)
|
||||||
|
state := m.State()
|
||||||
|
if !validReleaseVersion(version) || version != state.LatestVersion {
|
||||||
|
return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release")
|
||||||
|
}
|
||||||
|
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
||||||
|
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
||||||
|
}
|
||||||
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||||
|
if err != nil || settings.RegistryUsername == "" {
|
||||||
|
return ErrCredentialMissing
|
||||||
|
}
|
||||||
|
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
||||||
|
if err := m.agent.Start(ctx, request); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
|
||||||
|
status, err := m.agent.Status(ctx)
|
||||||
|
m.stateMu.Lock()
|
||||||
|
m.state.AgentAvailable = err == nil
|
||||||
|
if err == nil {
|
||||||
|
m.state.AgentStatus = status
|
||||||
|
}
|
||||||
|
m.stateMu.Unlock()
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||||
|
response, err := m.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||||
|
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
var release releaseResponse
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
|
||||||
|
return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig")
|
||||||
|
}
|
||||||
|
if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) {
|
||||||
|
return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release")
|
||||||
|
}
|
||||||
|
release.HTMLURL = m.safeReleaseURL(release.HTMLURL)
|
||||||
|
return release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) safeReleaseURL(raw string) string {
|
||||||
|
apiURL, apiErr := url.Parse(m.apiURL)
|
||||||
|
releaseURL, releaseErr := url.Parse(raw)
|
||||||
|
if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return releaseURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
|
||||||
|
checkedAt := time.Now().UTC()
|
||||||
|
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
return checkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
||||||
|
state := State{
|
||||||
|
CurrentVersion: buildinfo.Version,
|
||||||
|
LatestVersion: settings.LatestVersion,
|
||||||
|
ReleaseURL: settings.ReleaseURL,
|
||||||
|
DesiredVersion: settings.DesiredVersion,
|
||||||
|
LastCheckedAt: settings.LastCheckedAt,
|
||||||
|
LastError: settings.LastError,
|
||||||
|
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
|
||||||
|
}
|
||||||
|
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
|
||||||
|
state.UpdateAvailable = comparison < 0
|
||||||
|
}
|
||||||
|
m.stateMu.Lock()
|
||||||
|
state.AgentAvailable = m.state.AgentAvailable
|
||||||
|
state.AgentStatus = m.state.AgentStatus
|
||||||
|
m.state = state
|
||||||
|
m.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) refreshAgentStatus(ctx context.Context) {
|
||||||
|
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = m.UpdateStatus(statusCtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const token = "test-token-that-must-stay-secret"
|
||||||
|
var unavailable atomic.Bool
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "token "+token {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if unavailable.Load() {
|
||||||
|
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cipher, err := secure.LoadCipher(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := New(database, cipher, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := manager.CheckNow(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := manager.State()
|
||||||
|
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
|
||||||
|
t.Fatalf("new release was not detected: %#v", state)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(settings.TokenCiphertext), token) {
|
||||||
|
t.Fatal("registry token was stored as plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
unavailable.Store(true)
|
||||||
|
if err := manager.CheckNow(ctx); err == nil {
|
||||||
|
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||||
|
}
|
||||||
|
state = manager.State()
|
||||||
|
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
|
||||||
|
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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) {
|
||||||
|
a, err := parseVersion(left)
|
||||||
|
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 {
|
||||||
|
if !updateprotocol.ValidVersion(version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := parseVersion(version)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{left: "v1.2.2", right: "v1.2.3", want: -1},
|
||||||
|
{left: "v2.0.0", right: "v1.99.99", want: 1},
|
||||||
|
{left: "v1.2.2", right: "v1.2.2", want: 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
got, err := compareVersions(test.left, test.right)
|
||||||
|
if err != nil || got != test.want {
|
||||||
|
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
|
||||||
|
if validReleaseVersion(invalid) {
|
||||||
|
t.Errorf("invalid release version accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package buildinfo
|
||||||
|
|
||||||
|
// Version is replaced through -ldflags for release images. The source fallback
|
||||||
|
// keeps local builds and tests honest about the currently checked-out release.
|
||||||
|
var Version = "v1.3.0"
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -27,6 +29,9 @@ type Config struct {
|
|||||||
HCloudBaseURL string
|
HCloudBaseURL string
|
||||||
Mode string
|
Mode string
|
||||||
ShutdownTimeout time.Duration
|
ShutdownTimeout time.Duration
|
||||||
|
UpdateAPIURL string
|
||||||
|
UpdateAgentSock string
|
||||||
|
UpdateInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -51,6 +56,14 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
|
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
|
||||||
|
updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour)
|
||||||
|
if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour {
|
||||||
|
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h")
|
||||||
|
}
|
||||||
|
updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest")
|
||||||
|
if err := validateUpdateURL(updateAPIURL); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cookieSecure := mode == "https"
|
cookieSecure := mode == "https"
|
||||||
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
||||||
@@ -71,9 +84,28 @@ func Load() (Config, error) {
|
|||||||
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
|
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
ShutdownTimeout: shutdownTimeout,
|
ShutdownTimeout: shutdownTimeout,
|
||||||
|
UpdateAPIURL: updateAPIURL,
|
||||||
|
UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
|
||||||
|
UpdateInterval: updateInterval,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateUpdateURL(raw string) error {
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||||
|
return errors.New("must be an absolute HTTPS URL without embedded credentials")
|
||||||
|
}
|
||||||
|
if parsed.Scheme == "https" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
address := net.ParseIP(host)
|
||||||
|
if host == "localhost" || (address != nil && address.IsLoopback()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("HTTP is allowed only for loopback test endpoints")
|
||||||
|
}
|
||||||
|
|
||||||
func value(name, fallback string) string {
|
func value(name, fallback string) string {
|
||||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ type PiHoleCredential struct {
|
|||||||
Nonce []byte
|
Nonce []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SoftwareUpdateSettings contains the locally persisted update state. The
|
||||||
|
// encrypted registry token is returned only to trusted backend components and
|
||||||
|
// is never passed to templates.
|
||||||
|
type SoftwareUpdateSettings struct {
|
||||||
|
RegistryUsername string
|
||||||
|
TokenCiphertext []byte
|
||||||
|
TokenNonce []byte
|
||||||
|
LatestVersion string
|
||||||
|
ReleaseURL string
|
||||||
|
DesiredVersion string
|
||||||
|
LastCheckedAt time.Time
|
||||||
|
LastError string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
|
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
|
||||||
// merely retained at Pi-hole after being removed from the active WebUI list.
|
// merely retained at Pi-hole after being removed from the active WebUI list.
|
||||||
type PiHoleRecordLink struct {
|
type PiHoleRecordLink struct {
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ func New(rawURL string, insecureTLS bool) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBaseURL(raw string) (string, error) {
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
raw = strings.TrimSpace(raw)
|
||||||
|
if address := net.ParseIP(raw); address != nil && address.To4() != nil {
|
||||||
|
raw = "http://" + raw
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
|
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ func TestNewValidatesAndNormalizesPiHoleURL(t *testing.T) {
|
|||||||
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
|
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
|
||||||
t.Fatalf("custom path not normalized: %q err=%v", client.BaseURL(), err)
|
t.Fatalf("custom path not normalized: %q err=%v", client.BaseURL(), err)
|
||||||
}
|
}
|
||||||
|
client, err = New("10.0.20.100", false)
|
||||||
|
if err != nil || client.BaseURL() != "http://10.0.20.100/api" {
|
||||||
|
t.Fatalf("bare IPv4 address not normalized: %q err=%v", client.BaseURL(), err)
|
||||||
|
}
|
||||||
for _, invalid := range []string{"pi.hole", "ftp://pi.hole", "https://user:pass@pi.hole", "https://pi.hole?sid=secret"} {
|
for _, invalid := range []string{"pi.hole", "ftp://pi.hole", "https://user:pass@pi.hole", "https://pi.hole?sid=secret"} {
|
||||||
if _, err := New(invalid, false); err == nil {
|
if _, err := New(invalid, false); err == nil {
|
||||||
t.Errorf("invalid URL accepted: %s", invalid)
|
t.Errorf("invalid URL accepted: %s", invalid)
|
||||||
|
|||||||
@@ -21,24 +21,42 @@ var (
|
|||||||
ErrDisabled = errors.New("the updater is disabled")
|
ErrDisabled = errors.New("the updater is disabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultRetryInitial = 5 * time.Second
|
||||||
|
|
||||||
|
type ipResolver interface {
|
||||||
|
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
|
||||||
|
ValidateEndpoint(ctx context.Context, raw string) error
|
||||||
|
}
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
provider *hetzner.Client
|
provider *hetzner.Client
|
||||||
resolver *ipresolve.Resolver
|
resolver ipResolver
|
||||||
cipher *secure.Cipher
|
cipher *secure.Cipher
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
nextRun atomic.Int64
|
nextRun atomic.Int64
|
||||||
wake chan struct{}
|
wake chan struct{}
|
||||||
|
retryMin time.Duration
|
||||||
|
retryMax time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
|
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
|
||||||
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
|
return &Scheduler{
|
||||||
|
store: database,
|
||||||
|
provider: provider,
|
||||||
|
resolver: resolver,
|
||||||
|
cipher: cipher,
|
||||||
|
logger: logger,
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
retryMin: defaultRetryInitial,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) Start(ctx context.Context) {
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
go func() {
|
go func() {
|
||||||
defer s.nextRun.Store(0)
|
defer s.nextRun.Store(0)
|
||||||
|
consecutiveFailures := 0
|
||||||
for {
|
for {
|
||||||
settings, err := s.store.GetSettings(ctx)
|
settings, err := s.store.GetSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||||
|
if wait < time.Minute {
|
||||||
|
wait = time.Minute
|
||||||
|
}
|
||||||
|
retryAfter := time.Duration(0)
|
||||||
if settings.UpdaterEnabled {
|
if settings.UpdaterEnabled {
|
||||||
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
_, runErr := s.Run(ctx)
|
||||||
s.logger.Warn("scheduled update run failed", "error", runErr)
|
if runErr == nil {
|
||||||
|
consecutiveFailures = 0
|
||||||
|
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
||||||
|
consecutiveFailures++
|
||||||
|
retryAfter = s.retryDelay(consecutiveFailures, wait)
|
||||||
|
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
consecutiveFailures = 0
|
||||||
s.nextRun.Store(0)
|
s.nextRun.Store(0)
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
if retryAfter > 0 {
|
||||||
if wait < time.Minute {
|
wait = retryAfter
|
||||||
wait = time.Minute
|
|
||||||
}
|
}
|
||||||
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
||||||
timer := time.NewTimer(wait)
|
timer := time.NewTimer(wait)
|
||||||
@@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
timer.Stop()
|
timer.Stop()
|
||||||
return
|
return
|
||||||
case <-s.wake:
|
case <-s.wake:
|
||||||
|
consecutiveFailures = 0
|
||||||
if !timer.Stop() {
|
if !timer.Stop() {
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
@@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
|
||||||
|
minimum, maximum := s.retryMin, scheduledWait
|
||||||
|
if minimum <= 0 {
|
||||||
|
minimum = defaultRetryInitial
|
||||||
|
}
|
||||||
|
if s.retryMax > 0 && s.retryMax < maximum {
|
||||||
|
maximum = s.retryMax
|
||||||
|
}
|
||||||
|
if maximum < minimum {
|
||||||
|
maximum = minimum
|
||||||
|
}
|
||||||
|
wait := minimum
|
||||||
|
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
|
||||||
|
if wait > maximum/2 {
|
||||||
|
return maximum
|
||||||
|
}
|
||||||
|
wait *= 2
|
||||||
|
}
|
||||||
|
if wait > maximum {
|
||||||
|
return maximum
|
||||||
|
}
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scheduler) Trigger() {
|
func (s *Scheduler) Trigger() {
|
||||||
s.nextRun.Store(0)
|
s.nextRun.Store(0)
|
||||||
select {
|
select {
|
||||||
@@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
|
|
||||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
|
||||||
message := "Keine öffentliche IP ermittelt"
|
|
||||||
if ipResult.IPv4Err != nil {
|
|
||||||
message += ": " + ipResult.IPv4Err.Error()
|
|
||||||
}
|
|
||||||
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
targets, err := s.store.ListManagedTargets(ctx)
|
targets, err := s.store.ListManagedTargets(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
|
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
|
||||||
}
|
}
|
||||||
|
needsIPv6 := false
|
||||||
|
for _, target := range targets {
|
||||||
|
if target.Target.UpdateIPv6 {
|
||||||
|
needsIPv6 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
|
||||||
|
var resolutionErr error
|
||||||
|
if ipResult.IPv4Err != nil {
|
||||||
|
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
|
||||||
|
}
|
||||||
|
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
|
||||||
|
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
|
||||||
|
}
|
||||||
|
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||||
|
message := "Keine öffentliche IP ermittelt"
|
||||||
|
if resolutionErr == nil {
|
||||||
|
resolutionErr = errors.New(message)
|
||||||
|
} else {
|
||||||
|
message += ": " + resolutionErr.Error()
|
||||||
|
}
|
||||||
|
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
|
||||||
|
}
|
||||||
|
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
summary := "Keine aktiven DynDNS-Ziele"
|
summary := "Keine aktiven DynDNS-Ziele"
|
||||||
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
|
status := "ok"
|
||||||
|
if resolutionErr != nil {
|
||||||
|
status = "partial"
|
||||||
|
summary += "; eine IP-Familie war nicht verfügbar"
|
||||||
|
}
|
||||||
|
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped := make(map[int64][]store.ManagedTarget)
|
grouped := make(map[int64][]store.ManagedTarget)
|
||||||
@@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
|||||||
status = "partial"
|
status = "partial"
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
||||||
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
|
if resolutionErr != nil {
|
||||||
summary += "; eine IP-Familie war nicht verfügbar"
|
summary += "; eine IP-Familie war nicht verfügbar"
|
||||||
if status == "ok" {
|
if status == "ok" {
|
||||||
status = "partial"
|
status = "partial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
|
var runErr error
|
||||||
|
if failed > 0 {
|
||||||
|
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
|
||||||
|
}
|
||||||
|
runErr = errors.Join(runErr, resolutionErr)
|
||||||
|
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
|
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +22,24 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type retryResolver struct {
|
||||||
|
calls atomic.Int32
|
||||||
|
recovered chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
|
||||||
|
call := r.calls.Add(1)
|
||||||
|
if call == 1 {
|
||||||
|
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
|
||||||
|
}
|
||||||
|
if call == 2 {
|
||||||
|
close(r.recovered)
|
||||||
|
}
|
||||||
|
return ipresolve.Result{IPv4: "198.51.100.42"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
settings, err := database.GetSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := &retryResolver{recovered: make(chan struct{})}
|
||||||
|
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
schedule.resolver = resolver
|
||||||
|
schedule.retryMin = 5 * time.Millisecond
|
||||||
|
schedule.retryMax = 20 * time.Millisecond
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
schedule.Start(ctx)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-resolver.recovered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("scheduler did not retry the failed startup resolution")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
settings, err = database.GetSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.LastIPv4 == "198.51.100.42" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, err := database.RecentRuns(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
|
||||||
|
t.Fatalf("unexpected startup retry history: %#v", runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
schedule := &Scheduler{retryMin: 5 * time.Second}
|
||||||
|
tests := []struct {
|
||||||
|
failures int
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{failures: 1, want: 5 * time.Second},
|
||||||
|
{failures: 2, want: 10 * time.Second},
|
||||||
|
{failures: 3, want: 20 * time.Second},
|
||||||
|
{failures: 4, want: 40 * time.Second},
|
||||||
|
{failures: 5, want: time.Minute},
|
||||||
|
{failures: 20, want: time.Minute},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
|
||||||
|
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
schedule := &Scheduler{}
|
schedule := &Scheduler{}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/chacha20poly1305"
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
@@ -21,6 +22,14 @@ const (
|
|||||||
argonTime = 3
|
argonTime = 3
|
||||||
argonThreads = 2
|
argonThreads = 2
|
||||||
argonKeyLen = 32
|
argonKeyLen = 32
|
||||||
|
|
||||||
|
PasswordMinLength = 15
|
||||||
|
PasswordMaxLength = 128
|
||||||
|
|
||||||
|
// PasswordTimingHash is a non-secret, precomputed Argon2id hash used only
|
||||||
|
// to equalize failed-login work when a username does not exist. Keeping it
|
||||||
|
// static avoids allocating the full Argon2 workspace during every startup.
|
||||||
|
PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cipher struct {
|
type Cipher struct {
|
||||||
@@ -129,8 +138,30 @@ func HashToken(token string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
if len(password) < 12 {
|
return hashPassword(password, PasswordMinLength)
|
||||||
return "", errors.New("password must contain at least 12 characters")
|
}
|
||||||
|
|
||||||
|
// HashProvisionedPassword hashes the initial password chosen by an
|
||||||
|
// administrator when creating another account. Product policy deliberately
|
||||||
|
// leaves its length to that administrator while still rejecting empty,
|
||||||
|
// overlong, or invalid UTF-8 input.
|
||||||
|
func HashProvisionedPassword(password string) (string, error) {
|
||||||
|
return hashPassword(password, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string, minimumLength int) (string, error) {
|
||||||
|
if !utf8.ValidString(password) {
|
||||||
|
return "", errors.New("Passwort muss gültiger UTF-8-Text sein")
|
||||||
|
}
|
||||||
|
length := utf8.RuneCountInString(password)
|
||||||
|
if length < minimumLength {
|
||||||
|
if minimumLength == 1 {
|
||||||
|
return "", errors.New("Passwort darf nicht leer sein")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength)
|
||||||
|
}
|
||||||
|
if length > PasswordMaxLength {
|
||||||
|
return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength)
|
||||||
}
|
}
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
@@ -142,6 +173,9 @@ func HashPassword(password string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func VerifyPassword(encoded, password string) bool {
|
func VerifyPassword(encoded, password string) bool {
|
||||||
|
if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
parts := strings.Split(encoded, "$")
|
parts := strings.Split(encoded, "$")
|
||||||
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
|
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
|
||||||
return false
|
return false
|
||||||
@@ -152,12 +186,18 @@ func VerifyPassword(encoded, password string) bool {
|
|||||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
if err != nil {
|
if err != nil || len(salt) != 16 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
want, err := base64.RawStdEncoding.DecodeString(parts[4])
|
want, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
if err != nil {
|
if err != nil || len(want) != argonKeyLen {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
|
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,7 +82,34 @@ func TestPasswordHash(t *testing.T) {
|
|||||||
if VerifyPassword(hash, "wrong password") {
|
if VerifyPassword(hash, "wrong password") {
|
||||||
t.Fatal("wrong password verified")
|
t.Fatal("wrong password verified")
|
||||||
}
|
}
|
||||||
if _, err := HashPassword("short"); err == nil {
|
if VerifyPassword(strings.Replace(hash, "m=65536", "m=4294967295", 1), "a secure password") {
|
||||||
|
t.Fatal("password hash with unsafe Argon2 parameters was accepted")
|
||||||
|
}
|
||||||
|
if _, err := HashPassword(strings.Repeat("a", PasswordMinLength-1)); err == nil {
|
||||||
t.Fatal("short password was accepted")
|
t.Fatal("short password was accepted")
|
||||||
}
|
}
|
||||||
|
if _, err := HashPassword(strings.Repeat("a", PasswordMaxLength+1)); err == nil {
|
||||||
|
t.Fatal("overlong password was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordTimingHashMatchesCurrentArgonParameters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if !VerifyPassword(PasswordTimingHash, "login timing normalization only") {
|
||||||
|
t.Fatal("precomputed login timing hash no longer matches the configured Argon2 parameters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionedPasswordHasNoMinimumLengthBeyondNonEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hash, err := HashProvisionedPassword("x")
|
||||||
|
if err != nil || !VerifyPassword(hash, "x") {
|
||||||
|
t.Fatalf("short provisioned password was not hashed correctly: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := HashProvisionedPassword(""); err == nil {
|
||||||
|
t.Fatal("empty provisioned password was accepted")
|
||||||
|
}
|
||||||
|
if _, err := HashProvisionedPassword(strings.Repeat("x", PasswordMaxLength+1)); err == nil {
|
||||||
|
t.Fatal("overlong provisioned password was accepted")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+179
-7
@@ -21,7 +21,12 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
var (
|
||||||
|
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
|
||||||
|
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
|
||||||
|
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
|
||||||
|
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
|
||||||
|
)
|
||||||
|
|
||||||
type ManagedTarget struct {
|
type ManagedTarget struct {
|
||||||
Target model.Target
|
Target model.Target
|
||||||
@@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error {
|
|||||||
if err := s.applyMigrationV10(ctx, tx); err != nil {
|
if err := s.applyMigrationV10(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.applyMigrationV11(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
|
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
|
||||||
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
|
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v11: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
|
||||||
var count int
|
var count int
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
|
||||||
@@ -453,6 +478,22 @@ const schemaV10 = `
|
|||||||
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const schemaV11 = `
|
||||||
|
CREATE TABLE software_update_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
registry_username TEXT NOT NULL DEFAULT '',
|
||||||
|
token_ciphertext BLOB NOT NULL DEFAULT X'',
|
||||||
|
token_nonce BLOB NOT NULL DEFAULT X'',
|
||||||
|
latest_version TEXT NOT NULL DEFAULT '',
|
||||||
|
release_url TEXT NOT NULL DEFAULT '',
|
||||||
|
desired_version TEXT NOT NULL DEFAULT '',
|
||||||
|
last_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
|
||||||
|
`
|
||||||
|
|
||||||
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
||||||
var count int
|
var count int
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
|
||||||
@@ -460,8 +501,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
|
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
|
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
|
||||||
|
SELECT ?, ?, ?, 'admin', 1, ?, ?
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
created, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if created != 1 {
|
||||||
|
return ErrAdminAlreadyConfigured
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
|
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
|
||||||
@@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
|
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
|
||||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
|
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
|
||||||
}
|
}
|
||||||
@@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
|
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
|
||||||
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var currentRole string
|
||||||
|
var currentActive int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(¤tRole, ¤tActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
|
||||||
|
var activeAdmins int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if activeAdmins <= 1 {
|
||||||
|
return ErrLastActiveAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count, _ := result.RowsAffected(); count != 1 {
|
if count, _ := result.RowsAffected(); count != 1 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
return nil
|
// Administrators receive full access from their role. Removing stale member
|
||||||
|
// grants prevents hidden permissions from resurfacing after a later demotion.
|
||||||
|
if role == "admin" {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if currentRole != role || intBool(currentActive) != active {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
|
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
|
||||||
return s.UpdatePassword(ctx, userID, passwordHash)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
|
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
|
||||||
@@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var role string
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if role == "admin" {
|
||||||
|
return ErrAdminAccessImmutable
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
|
||||||
|
var settings model.SoftwareUpdateSettings
|
||||||
|
var checkedAt, updatedAt int64
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
|
||||||
|
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
|
||||||
|
)
|
||||||
|
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSoftwareUpdateFailure intentionally retains the last known release so
|
||||||
|
// a temporary Gitea outage never removes an already visible update reminder.
|
||||||
|
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) 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 last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
|
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
|
||||||
|
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
|
||||||
|
last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6),
|
||||||
|
last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,8 +1257,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
|
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
|
||||||
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
|
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) {
|
||||||
|
t.Fatalf("second first-run administrator was not rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil {
|
||||||
|
t.Fatal("rejected second administrator was persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -168,6 +188,100 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hash, err := secure.HashPassword("a secure administrator password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
|
||||||
|
t.Fatalf("last active administrator could be removed: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||||
|
t.Fatalf("administrator accepted editable individual grants: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
|
||||||
|
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
|
||||||
|
}
|
||||||
|
grants, err := database.ListAccessGrants(ctx, member.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(grants) != 0 {
|
||||||
|
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||||
|
t.Fatalf("promoted administrator accepted individual grants: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkedAt := time.Now().UTC().Truncate(time.Second)
|
||||||
|
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
|
||||||
|
t.Fatalf("software update state was not persisted safely: %#v", settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -268,6 +382,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
|||||||
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
|
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
|
||||||
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
|
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
|
||||||
}
|
}
|
||||||
|
if err := database.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := database.DisableIPv6ForTargets(ctx); err != nil {
|
if err := database.DisableIPv6ForTargets(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -275,6 +392,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
|||||||
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
|
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
|
||||||
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
|
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
|
||||||
}
|
}
|
||||||
|
settings, err := database.GetSettings(ctx)
|
||||||
|
if err != nil || settings.LastIPv6 != "" {
|
||||||
|
t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err)
|
||||||
|
}
|
||||||
if err := database.DeleteTarget(ctx, target.ID); err != nil {
|
if err := database.DeleteTarget(ctx, target.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -335,6 +456,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
|
|||||||
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
|
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := database.UpdateRunState(ctx, "", "", "error"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err = database.GetSettings(ctx)
|
||||||
|
if err != nil || settings.LastIPv4 != "198.51.100.1" {
|
||||||
|
t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err)
|
||||||
|
}
|
||||||
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
|
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
|
||||||
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
|
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner interface {
|
||||||
|
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Controller struct {
|
||||||
|
runner Runner
|
||||||
|
logger *slog.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
status updateprotocol.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewController(runner Runner, logger *slog.Logger) *Controller {
|
||||||
|
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /v1/status", c.statusHandler)
|
||||||
|
mux.HandleFunc("POST /v1/update", c.updateHandler)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(c.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||||
|
defer r.Body.Close()
|
||||||
|
var request updateprotocol.Request
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&request); err != nil {
|
||||||
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Version = strings.TrimSpace(request.Version)
|
||||||
|
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
||||||
|
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !c.begin(request.Version) {
|
||||||
|
http.Error(w, "update already running", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
|
||||||
|
go c.run(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) begin(version string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.status.Busy() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) run(request updateprotocol.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
err := c.runner.Update(ctx, request, c.setPhase)
|
||||||
|
registryToken := request.RegistryToken
|
||||||
|
request.RegistryToken = ""
|
||||||
|
if err != nil {
|
||||||
|
message := publicAgentError(err)
|
||||||
|
c.setPhase(updateprotocol.PhaseFailed, message)
|
||||||
|
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
|
||||||
|
c.logger.Info("software update completed", "version", request.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactSecret(err error, secret string) error {
|
||||||
|
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) setPhase(phase, message string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.status.Phase = phase
|
||||||
|
c.status.Message = message
|
||||||
|
c.status.UpdatedAt = time.Now().UTC()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Status() updateprotocol.Status {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicAgentError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "Update fehlgeschlagen"
|
||||||
|
}
|
||||||
|
var public interface{ PublicMessage() string }
|
||||||
|
if errors.As(err, &public) {
|
||||||
|
return public.PublicMessage()
|
||||||
|
}
|
||||||
|
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const secret = "sensitive-registry-token"
|
||||||
|
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
|
||||||
|
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
|
||||||
|
t.Fatalf("credential was not redacted: %q", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingRunner struct {
|
||||||
|
started chan updateprotocol.Request
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
|
||||||
|
progress(updateprotocol.PhasePulling, "image is being pulled")
|
||||||
|
r.started <- request
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-r.release:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
|
||||||
|
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
handler := controller.Handler()
|
||||||
|
|
||||||
|
invalid := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
|
||||||
|
if invalid.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
|
||||||
|
}
|
||||||
|
trailing := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
|
||||||
|
if trailing.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = "registry-token-must-not-leak"
|
||||||
|
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||||
|
accepted := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||||
|
if accepted.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case request := <-runner.started:
|
||||||
|
if request.Version != "v1.2.3" || request.RegistryToken != secret {
|
||||||
|
t.Fatalf("runner received an altered request: %#v", request)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("update runner did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
busy := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||||
|
if busy.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
|
||||||
|
}
|
||||||
|
status := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
||||||
|
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
|
||||||
|
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
close(runner.release)
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
if controller.Status().Phase != updateprotocol.PhaseComplete {
|
||||||
|
t.Fatalf("update did not complete: %#v", controller.Status())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
|
||||||
|
|
||||||
|
type DockerRunner struct {
|
||||||
|
docker *dockerClient
|
||||||
|
imageRepository string
|
||||||
|
targetLabel string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateError struct {
|
||||||
|
message string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *updateError) Error() string { return e.err.Error() }
|
||||||
|
func (e *updateError) Unwrap() error { return e.err }
|
||||||
|
func (e *updateError) PublicMessage() string { return e.message }
|
||||||
|
|
||||||
|
func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner {
|
||||||
|
if imageRepository == "" {
|
||||||
|
imageRepository = defaultImageRepository
|
||||||
|
}
|
||||||
|
return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) {
|
||||||
|
if !updateprotocol.ValidVersion(request.Version) {
|
||||||
|
return errors.New("invalid target version")
|
||||||
|
}
|
||||||
|
if err := r.docker.negotiate(ctx); err != nil {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
|
||||||
|
}
|
||||||
|
inspected, err := r.docker.inspectContainer(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
originalName := strings.TrimPrefix(inspected.Name, "/")
|
||||||
|
if originalName == "" {
|
||||||
|
return errors.New("target container has no name")
|
||||||
|
}
|
||||||
|
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix())
|
||||||
|
var oldStopped, oldRenamed bool
|
||||||
|
var newID string
|
||||||
|
defer func() {
|
||||||
|
if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil {
|
||||||
|
r.logger.Error("software update rollback failed", "error", rollbackErr)
|
||||||
|
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet")
|
||||||
|
if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldStopped = true
|
||||||
|
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldRenamed = true
|
||||||
|
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.docker.startContainer(ctx, newID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
|
||||||
|
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
|
||||||
|
r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err)
|
||||||
|
}
|
||||||
|
oldStopped, oldRenamed, newID = false, false, ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error {
|
||||||
|
var failures []string
|
||||||
|
if newID != "" {
|
||||||
|
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
|
||||||
|
failures = append(failures, "stop replacement: "+err.Error())
|
||||||
|
}
|
||||||
|
if err := r.docker.removeContainer(ctx, newID, true); err != nil {
|
||||||
|
failures = append(failures, "remove replacement: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldRenamed {
|
||||||
|
if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil {
|
||||||
|
failures = append(failures, "restore name: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldStopped {
|
||||||
|
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
|
||||||
|
failures = append(failures, "restart previous container: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
return errors.New(strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dockerClient struct {
|
||||||
|
socket string
|
||||||
|
client *http.Client
|
||||||
|
apiPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerSummary struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Names []string `json:"Names"`
|
||||||
|
State string `json:"State"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerInspect struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Config json.RawMessage `json:"Config"`
|
||||||
|
HostConfig json.RawMessage `json:"HostConfig"`
|
||||||
|
NetworkSettings struct {
|
||||||
|
Networks map[string]endpointSettings `json:"Networks"`
|
||||||
|
} `json:"NetworkSettings"`
|
||||||
|
State containerState `json:"State"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointSettings struct {
|
||||||
|
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
|
||||||
|
Links []string `json:"Links,omitempty"`
|
||||||
|
Aliases []string `json:"Aliases,omitempty"`
|
||||||
|
MacAddress string `json:"MacAddress,omitempty"`
|
||||||
|
DriverOpts map[string]string `json:"DriverOpts,omitempty"`
|
||||||
|
GwPriority int `json:"GwPriority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerState struct {
|
||||||
|
Running bool `json:"Running"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
Health *struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
} `json:"Health"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDockerClient(socket string) *dockerClient {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket)
|
||||||
|
},
|
||||||
|
MaxIdleConns: 2,
|
||||||
|
MaxIdleConnsPerHost: 2,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
return &dockerClient{socket: socket, client: &http.Client{Transport: transport}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) negotiate(ctx context.Context) error {
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var version struct {
|
||||||
|
APIVersion string `json:"ApiVersion"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil {
|
||||||
|
return fmt.Errorf("decode Docker API version: %w", err)
|
||||||
|
}
|
||||||
|
for _, character := range version.APIVersion {
|
||||||
|
if (character < '0' || character > '9') && character != '.' {
|
||||||
|
return errors.New("Docker returned an invalid API version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version.APIVersion == "" {
|
||||||
|
return errors.New("Docker returned an empty API version")
|
||||||
|
}
|
||||||
|
c.apiPrefix = "/v" + version.APIVersion
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) {
|
||||||
|
filters, _ := json.Marshal(map[string][]string{"label": {label}})
|
||||||
|
query := url.Values{"all": {"1"}, "filters": {string(filters)}}
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return containerSummary{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return containerSummary{}, dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var containers []containerSummary
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil {
|
||||||
|
return containerSummary{}, err
|
||||||
|
}
|
||||||
|
var running []containerSummary
|
||||||
|
for _, container := range containers {
|
||||||
|
if container.State == "running" {
|
||||||
|
running = append(running, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(running) != 1 {
|
||||||
|
return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running))
|
||||||
|
}
|
||||||
|
return running[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error {
|
||||||
|
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]})
|
||||||
|
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
|
||||||
|
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(response.Body)
|
||||||
|
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var event struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Error != "" {
|
||||||
|
return errors.New(event.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) {
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return containerInspect{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return containerInspect{}, dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var inspected containerInspect
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
|
||||||
|
return containerInspect{}, err
|
||||||
|
}
|
||||||
|
return inspected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
|
||||||
|
query := url.Values{"t": {fmt.Sprint(seconds)}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error {
|
||||||
|
query := url.Values{"name": {name}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) {
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(inspected.Config, &body); err != nil {
|
||||||
|
return "", fmt.Errorf("decode container config: %w", err)
|
||||||
|
}
|
||||||
|
imageJSON, _ := json.Marshal(image)
|
||||||
|
body["Image"] = imageJSON
|
||||||
|
body["HostConfig"] = inspected.HostConfig
|
||||||
|
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
|
||||||
|
oldIDPrefix := inspected.ID
|
||||||
|
if len(oldIDPrefix) > 12 {
|
||||||
|
oldIDPrefix = oldIDPrefix[:12]
|
||||||
|
}
|
||||||
|
for network, endpoint := range inspected.NetworkSettings.Networks {
|
||||||
|
aliases := endpoint.Aliases[:0]
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if alias != inspected.ID && alias != oldIDPrefix {
|
||||||
|
aliases = append(aliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint.Aliases = aliases
|
||||||
|
endpoints[network] = endpoint
|
||||||
|
}
|
||||||
|
networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints})
|
||||||
|
body["NetworkingConfig"] = networkingJSON
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
query := url.Values{"name": {name}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
return "", dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if created.ID == "" {
|
||||||
|
return "", errors.New("Docker created a replacement without an ID")
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) startContainer(ctx context.Context, id string) error {
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error {
|
||||||
|
query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}}
|
||||||
|
response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error {
|
||||||
|
deadline := time.NewTimer(timeout)
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
defer ticker.Stop()
|
||||||
|
consecutiveHealthy := 0
|
||||||
|
startedAt := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-deadline.C:
|
||||||
|
return errors.New("replacement container did not become healthy in time")
|
||||||
|
case <-ticker.C:
|
||||||
|
inspected, err := c.inspectContainer(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !inspected.State.Running {
|
||||||
|
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
|
||||||
|
}
|
||||||
|
if inspected.State.Health == nil {
|
||||||
|
if time.Since(startedAt) >= 8*time.Second {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inspected.State.Health.Status == "healthy" {
|
||||||
|
consecutiveHealthy++
|
||||||
|
if consecutiveHealthy >= 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveHealthy = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
|
||||||
|
if versioned {
|
||||||
|
path = c.apiPrefix + path
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for name, values := range headers {
|
||||||
|
for _, value := range values {
|
||||||
|
request.Header.Add(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.client.Do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerStatusError(response *http.Response) error {
|
||||||
|
message, _ := io.ReadAll(io.LimitReader(response.Body, 8<<10))
|
||||||
|
trimmed := strings.TrimSpace(string(message))
|
||||||
|
if trimmed == "" {
|
||||||
|
trimmed = http.StatusText(response.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Docker API HTTP %d: %s", response.StatusCode, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDockerNotModified(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), "HTTP 304")
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package updateprotocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseIdle = "idle"
|
||||||
|
PhaseQueued = "queued"
|
||||||
|
PhasePulling = "pulling"
|
||||||
|
PhaseRestarting = "restarting"
|
||||||
|
PhaseVerifying = "verifying"
|
||||||
|
PhaseComplete = "complete"
|
||||||
|
PhaseFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
RegistryUsername string `json:"registry_username"`
|
||||||
|
RegistryToken string `json:"registry_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
TargetVersion string `json:"target_version,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
StartedAt time.Time `json:"started_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) Busy() bool {
|
||||||
|
return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidVersion(version string) bool {
|
||||||
|
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
"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/secure"
|
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +212,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||||
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-38
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/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/scheduler"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||||
@@ -36,9 +37,12 @@ type Server struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
cipher *secure.Cipher
|
cipher *secure.Cipher
|
||||||
scheduler *scheduler.Scheduler
|
scheduler *scheduler.Scheduler
|
||||||
|
updates *appupdate.Manager
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
limiter *loginLimiter
|
limiter *loginLimiter
|
||||||
|
dummyHash string
|
||||||
|
authSlots chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewData struct {
|
type viewData struct {
|
||||||
@@ -74,6 +78,7 @@ type viewData struct {
|
|||||||
ErrorPiHoles []model.PiHoleIntegration
|
ErrorPiHoles []model.PiHoleIntegration
|
||||||
ErrorCount int
|
ErrorCount int
|
||||||
PiHoleView *piHoleView
|
PiHoleView *piHoleView
|
||||||
|
SoftwareUpdate appupdate.State
|
||||||
}
|
}
|
||||||
|
|
||||||
type flashMessage struct {
|
type flashMessage struct {
|
||||||
@@ -91,12 +96,12 @@ type accessZone struct {
|
|||||||
ManagedTargetIDs map[int64]bool
|
ManagedTargetIDs map[int64]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, 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 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
|
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
||||||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||||||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||||||
|
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
|
||||||
|
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||||
|
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||||
|
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||||
mux.Handle("GET /security", s.authenticated(s.securityPage))
|
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||||
|
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
|
||||||
|
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
|
||||||
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
||||||
mux.Handle("GET /users", s.adminOnly(s.usersPage))
|
mux.Handle("GET /access", s.adminOnly(s.usersPage))
|
||||||
|
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
|
||||||
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
|
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
|
||||||
mux.Handle("POST /users", s.adminOnly(s.userCreate))
|
mux.Handle("POST /users", s.adminOnly(s.userCreate))
|
||||||
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
|
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
|
||||||
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
|
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
|
||||||
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
|
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
|
||||||
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
|
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
|
||||||
|
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
|
||||||
return s.securityHeaders(mux)
|
return s.securityHeaders(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
|
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
|
||||||
|
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
|
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
username, usernameErr := normalizeUsername(r.FormValue("username"))
|
username, usernameErr := normalizeUsername(r.FormValue("username"))
|
||||||
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
|
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
|
||||||
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
lookupOK := usernameErr == nil && err == nil
|
||||||
|
if !lookupOK {
|
||||||
|
hash = s.dummyHash
|
||||||
|
}
|
||||||
|
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
|
||||||
|
if !lookupOK || !user.Active || !passwordOK {
|
||||||
s.limiter.Fail(clientIP)
|
s.limiter.Fail(clientIP)
|
||||||
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
||||||
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
|
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.limiter.Reset(clientIP)
|
s.limiter.Reset(clientIP)
|
||||||
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.redirectNotice(w, r, "/", "Angemeldet.")
|
s.redirectNotice(w, r, "/", "Angemeldet.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
|
||||||
|
if s.authSlots == nil {
|
||||||
|
return secure.VerifyPassword(hash, password)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(5 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case s.authSlots <- struct{}{}:
|
||||||
|
defer func() { <-s.authSlots }()
|
||||||
|
return secure.VerifyPassword(hash, password)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
if !s.validSessionCSRF(r, session) {
|
if !s.validSessionCSRF(r, session) {
|
||||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||||
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
|
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
|
|||||||
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
|
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if s.updates == nil {
|
||||||
|
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||||
|
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||||
|
cancel()
|
||||||
|
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if s.updates == nil {
|
||||||
|
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
|
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||||
|
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
|
||||||
|
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
|
||||||
|
s.updates.TriggerCheck()
|
||||||
|
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if s.updates == nil {
|
||||||
|
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
err := s.updates.CheckNow(checkCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, appupdate.ErrCredentialMissing) {
|
||||||
|
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if s.updates == nil {
|
||||||
|
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(r.FormValue("version"))
|
||||||
|
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
err := s.updates.RequestUpdate(startCtx, version)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||||
|
if s.updates == nil {
|
||||||
|
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
status, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||||
|
cancel()
|
||||||
|
state := s.updates.State()
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"agent_available": statusErr == nil,
|
||||||
|
"phase": status.Phase,
|
||||||
|
"message": status.Message,
|
||||||
|
"target_version": status.TargetVersion,
|
||||||
|
"current_version": state.CurrentVersion,
|
||||||
|
"latest_version": state.LatestVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
|
|||||||
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
|
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
|
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
|
||||||
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
|
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
||||||
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
|
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.redirectNotice(w, r, "/security", err.Error(), "error")
|
s.redirectNotice(w, r, "/account", err.Error(), "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
|
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
|
||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
|
|
||||||
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
||||||
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||||
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
|
|||||||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
||||||
"running": s.scheduler.IsRunning(),
|
"running": s.scheduler.IsRunning(),
|
||||||
"enabled": settings.UpdaterEnabled,
|
"enabled": settings.UpdaterEnabled,
|
||||||
|
"last_ipv4": settings.LastIPv4,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
|
|||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
|
||||||
|
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
|
|||||||
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
|
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := secure.HashPassword(r.FormValue("password"))
|
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderUserNewError(w, session, err.Error(), form)
|
s.renderUserNewError(w, session, err.Error(), form)
|
||||||
return
|
return
|
||||||
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
|
|||||||
}
|
}
|
||||||
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
|
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
|
||||||
}
|
}
|
||||||
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
|
|||||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
|
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if managedUser.IsAdmin() && (role != "admin" || !active) {
|
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||||||
count, countErr := s.store.CountActiveAdmins(r.Context())
|
if errors.Is(err, store.ErrLastActiveAdmin) {
|
||||||
if countErr != nil {
|
|
||||||
s.serverError(w, countErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if count <= 1 {
|
|
||||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
|
||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if role != managedUser.Role || active != managedUser.Active {
|
|
||||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
|
||||||
}
|
|
||||||
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
|
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
|
||||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
|
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
|
||||||
}
|
}
|
||||||
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
|
|||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
|
||||||
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
|
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
|
||||||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
|
||||||
}
|
}
|
||||||
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
|
|||||||
if settings, err := s.store.GetSettings(context.Background()); err == nil {
|
if settings, err := s.store.GetSettings(context.Background()); err == nil {
|
||||||
data.UpdaterEnabled = settings.UpdaterEnabled
|
data.UpdaterEnabled = settings.UpdaterEnabled
|
||||||
}
|
}
|
||||||
|
if data.IsAdmin && s.updates != nil {
|
||||||
|
data.SoftwareUpdate = s.updates.State()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
|
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
|
||||||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serverError(w http.ResponseWriter, err error) {
|
func (s *Server) serverError(w http.ResponseWriter, err error) {
|
||||||
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadTemplates() (map[string]*template.Template, error) {
|
func loadTemplates() (map[string]*template.Template, error) {
|
||||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
|
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
|
||||||
functions := template.FuncMap{
|
functions := template.FuncMap{
|
||||||
"formatTime": func(value time.Time) string {
|
"formatTime": func(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
@@ -2045,13 +2204,28 @@ func remoteIP(r *http.Request) string {
|
|||||||
type loginLimiter struct {
|
type loginLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
attempts map[string][]time.Time
|
attempts map[string][]time.Time
|
||||||
|
lastCleanup time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginAttemptLimit = 5
|
||||||
|
loginAttemptWindow = 15 * time.Minute
|
||||||
|
loginLimiterCleanupEvery = time.Minute
|
||||||
|
loginLimiterMaxTrackedIPs = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLoginLimiter() *loginLimiter {
|
||||||
|
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
|
|
||||||
func (l *loginLimiter) Allow(ip string) bool {
|
func (l *loginLimiter) Allow(ip string) bool {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
cutoff := time.Now().Add(-15 * time.Minute)
|
now := time.Now()
|
||||||
|
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
|
||||||
|
l.cleanupLocked(now)
|
||||||
|
}
|
||||||
|
cutoff := now.Add(-loginAttemptWindow)
|
||||||
values := l.attempts[ip]
|
values := l.attempts[ip]
|
||||||
kept := values[:0]
|
kept := values[:0]
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
|
|||||||
kept = append(kept, value)
|
kept = append(kept, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(l.attempts, ip)
|
||||||
|
} else {
|
||||||
l.attempts[ip] = kept
|
l.attempts[ip] = kept
|
||||||
return len(kept) < 5
|
}
|
||||||
|
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(kept) < loginAttemptLimit
|
||||||
}
|
}
|
||||||
func (l *loginLimiter) Fail(ip string) {
|
func (l *loginLimiter) Fail(ip string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
|
||||||
|
return
|
||||||
|
}
|
||||||
l.attempts[ip] = append(l.attempts[ip], time.Now())
|
l.attempts[ip] = append(l.attempts[ip], time.Now())
|
||||||
}
|
}
|
||||||
func (l *loginLimiter) Reset(ip string) {
|
func (l *loginLimiter) Reset(ip string) {
|
||||||
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
|
|||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
delete(l.attempts, ip)
|
delete(l.attempts, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) cleanupLocked(now time.Time) {
|
||||||
|
cutoff := now.Add(-loginAttemptWindow)
|
||||||
|
for ip, values := range l.attempts {
|
||||||
|
kept := values[:0]
|
||||||
|
for _, value := range values {
|
||||||
|
if value.After(cutoff) {
|
||||||
|
kept = append(kept, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(l.attempts, ip)
|
||||||
|
} else {
|
||||||
|
l.attempts[ip] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.lastCleanup = now
|
||||||
|
}
|
||||||
+267
-4
@@ -9,10 +9,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/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"
|
||||||
@@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
|
|||||||
if strings.Contains(body, `class="flash"`) {
|
if strings.Contains(body, `class="flash"`) {
|
||||||
t.Error("legacy in-flow flash message is still rendered")
|
t.Error("legacy in-flow flash message is still rendered")
|
||||||
}
|
}
|
||||||
|
for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} {
|
||||||
|
if strings.Contains(body, removed) {
|
||||||
|
t.Errorf("login still renders removed security marketing copy %q", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
limiter := newLoginLimiter()
|
||||||
|
limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)}
|
||||||
|
limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second)
|
||||||
|
if !limiter.Allow("fresh") {
|
||||||
|
t.Fatal("fresh client was rejected after expired entries should have been pruned")
|
||||||
|
}
|
||||||
|
if _, exists := limiter.attempts["expired"]; exists {
|
||||||
|
t.Fatal("expired login limiter entry was not pruned")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for index := 0; index < loginLimiterMaxTrackedIPs; index++ {
|
||||||
|
limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now}
|
||||||
|
}
|
||||||
|
limiter.lastCleanup = now
|
||||||
|
if limiter.Allow("overflow") {
|
||||||
|
t.Fatal("untracked client was accepted after the bounded limiter reached capacity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hash, err := secure.HashPassword("a sufficiently secure test password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const rawToken = "member-session-token"
|
||||||
|
if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{config: config.Config{}, store: database}
|
||||||
|
handler := server.Handler()
|
||||||
|
requests := []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{method: http.MethodGet, path: "/access"},
|
||||||
|
{method: http.MethodGet, path: "/users"},
|
||||||
|
{method: http.MethodGet, path: "/users/new"},
|
||||||
|
{method: http.MethodGet, path: "/system"},
|
||||||
|
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||||
|
}
|
||||||
|
for _, test := range requests {
|
||||||
|
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||||
|
request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken})
|
||||||
|
if test.body != "" {
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false}
|
||||||
|
if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
if !strings.Contains(body, `href="/account"`) {
|
||||||
|
t.Error("member navigation does not expose the personal account")
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} {
|
||||||
|
if strings.Contains(body, forbidden) {
|
||||||
|
t.Errorf("member UI exposes administrator control %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}}
|
||||||
|
if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} {
|
||||||
|
if !strings.Contains(body, expected) {
|
||||||
|
t.Errorf("immutable administrator access UI is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} {
|
||||||
|
if strings.Contains(body, forbidden) {
|
||||||
|
t.Errorf("administrator detail renders editable permission input %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}}
|
||||||
|
if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
navStart, navEnd := strings.Index(body, "<nav "), strings.Index(body, "</nav>")
|
||||||
|
if navStart < 0 || navEnd <= navStart {
|
||||||
|
t.Fatal("administrator navigation was not rendered")
|
||||||
|
}
|
||||||
|
navigation := body[navStart:navEnd]
|
||||||
|
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff & Sicherheit") {
|
||||||
|
t.Fatal("combined access administration entry is missing")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
|
||||||
|
t.Fatal("removed credential policy card is still rendered")
|
||||||
|
}
|
||||||
|
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
|
||||||
|
if strings.Contains(navigation, legacy) {
|
||||||
|
t.Errorf("legacy separate navigation entry remains: %q", legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||||
|
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
|
||||||
|
var notification bytes.Buffer
|
||||||
|
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||||
|
if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"Neue Version verfügbar", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
|
||||||
|
if !strings.Contains(notification.String(), expected) {
|
||||||
|
t.Errorf("global update notification is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var page bytes.Buffer
|
||||||
|
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
||||||
|
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} {
|
||||||
|
if !strings.Contains(page.String(), expected) {
|
||||||
|
t.Errorf("software update page is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var freshPage bytes.Buffer
|
||||||
|
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
|
||||||
|
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
|
||||||
|
if !strings.Contains(freshPage.String(), expected) {
|
||||||
|
t.Errorf("fresh software update page is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(freshPage.String(), "Update-Kanal") {
|
||||||
|
t.Error("confusing legacy update channel box is still rendered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{Title: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
|
||||||
|
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
|
||||||
|
if !strings.Contains(body, expected) {
|
||||||
|
t.Errorf("user creation form is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
|
||||||
|
start := strings.Index(body, field)
|
||||||
|
if start < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := strings.Index(body[start:], ">")
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatalf("password input %q has no closing bracket", field)
|
||||||
|
}
|
||||||
|
if strings.Contains(body[start:start+end], "minlength=") {
|
||||||
|
t.Errorf("password input %q still has a minimum length", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load templates: %v", err)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{
|
||||||
|
Title: "Ersteinrichtung",
|
||||||
|
Page: "setup",
|
||||||
|
Mode: "lan",
|
||||||
|
CSRF: "csrf",
|
||||||
|
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
|
||||||
|
}
|
||||||
|
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
|
||||||
|
t.Fatalf("render setup: %v", err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
|
||||||
|
if !strings.Contains(body, expected) {
|
||||||
|
t.Errorf("first-run onboarding is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Kein E-Mail-Reset") {
|
||||||
|
t.Error("first-run onboarding still renders the removed password reset footnote")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
||||||
@@ -238,7 +501,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
|||||||
CSRF: "csrf",
|
CSRF: "csrf",
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||||
Settings: model.Settings{IPv6Enabled: true},
|
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
|
||||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
||||||
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
||||||
}
|
}
|
||||||
@@ -251,7 +514,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
|
|||||||
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)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||||
if !strings.Contains(body, expected) {
|
if !strings.Contains(body, expected) {
|
||||||
t.Errorf("domain detail is missing %q", expected)
|
t.Errorf("domain detail is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -317,7 +580,7 @@ func TestPiHolePageRendersConnectionAndLocalDNSControls(t *testing.T) {
|
|||||||
t.Fatalf("render Pi-hole page: %v", err)
|
t.Fatalf("render Pi-hole page: %v", err)
|
||||||
}
|
}
|
||||||
body := rendered.String()
|
body := rendered.String()
|
||||||
for _, expected := range []string{`href="/pihole"`, `aria-current="page"`, `action="/pihole/7/records"`, `action="/pihole/7/records/update"`, `action="/pihole/7/records/archive"`, `action="/pihole/7/records/restore"`, `action="/pihole/7/records/delete"`, `action="/pihole/7/remove"`, `action="/pihole/7/delete"`, `action="/pihole/7/save"`, `action="/pihole/7/password"`, `name="active"`, `name="link_id" value="11"`, `name="insecure_tls"`, `data-dirty-form`, `data-dirty-submit disabled aria-disabled="true"`, `data-scroll-page-end`, `Anwendungspasswort anzeigen`, `Passwort entschlüsseln`, `Nur aus der WebUI entfernen`, `Anwendungspasswort`, `nas.lan`, `old.lan`, `192.168.1.20`} {
|
for _, expected := range []string{`href="/pihole"`, `aria-current="page"`, `action="/pihole/7/records"`, `action="/pihole/7/records/update"`, `action="/pihole/7/records/archive"`, `action="/pihole/7/records/restore"`, `action="/pihole/7/records/delete"`, `action="/pihole/7/remove"`, `action="/pihole/7/delete"`, `action="/pihole/7/save"`, `action="/pihole/7/password"`, `name="active"`, `name="link_id" value="11"`, `name="insecure_tls"`, `name="base_url" type="text"`, `data-dirty-form`, `data-dirty-submit disabled aria-disabled="true"`, `data-scroll-page-end`, `Anwendungspasswort anzeigen`, `Passwort entschlüsseln`, `Nur aus der WebUI entfernen`, `Anwendungspasswort`, `nas.lan`, `old.lan`, `192.168.1.20`} {
|
||||||
if !strings.Contains(body, expected) {
|
if !strings.Contains(body, expected) {
|
||||||
t.Errorf("Pi-hole page is missing %q", expected)
|
t.Errorf("Pi-hole page is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -359,7 +622,7 @@ func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T)
|
|||||||
t.Fatalf("render disconnected Pi-hole page: %v", err)
|
t.Fatalf("render disconnected Pi-hole page: %v", err)
|
||||||
}
|
}
|
||||||
body := rendered.String()
|
body := rendered.String()
|
||||||
for _, expected := range []string{`class="pihole-connect-stage"`, `class="form-panel pihole-connect-panel"`, `action="/pihole/connect"`} {
|
for _, expected := range []string{`class="pihole-connect-stage"`, `class="form-panel pihole-connect-panel"`, `action="/pihole/connect"`, `name="base_url" type="text"`, `10.0.20.100 oder https://pi.hole`} {
|
||||||
if !strings.Contains(body, expected) {
|
if !strings.Contains(body, expected) {
|
||||||
t.Errorf("disconnected Pi-hole page is missing %q", expected)
|
t.Errorf("disconnected Pi-hole page is missing %q", expected)
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-8
@@ -91,6 +91,32 @@
|
|||||||
syncIPv6Settings();
|
syncIPv6Settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setupForm = document.querySelector('[data-setup-form]');
|
||||||
|
if (setupForm) {
|
||||||
|
const password = setupForm.querySelector('[data-setup-password]');
|
||||||
|
const confirmation = setupForm.querySelector('[data-setup-confirm]');
|
||||||
|
const lengthHint = setupForm.querySelector('[data-password-length]');
|
||||||
|
const matchHint = setupForm.querySelector('[data-password-match]');
|
||||||
|
|
||||||
|
const syncPasswordGuidance = () => {
|
||||||
|
const length = password ? [...password.value].length : 0;
|
||||||
|
const longEnough = length >= 15 && length <= 128;
|
||||||
|
const matches = Boolean(confirmation?.value) && confirmation.value === password?.value;
|
||||||
|
lengthHint?.classList.toggle('is-valid', longEnough);
|
||||||
|
matchHint?.classList.toggle('is-valid', matches);
|
||||||
|
lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending');
|
||||||
|
matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending');
|
||||||
|
password?.classList.toggle('is-valid', longEnough);
|
||||||
|
confirmation?.classList.toggle('is-valid', matches);
|
||||||
|
setupForm.classList.toggle('is-password-ready', longEnough && matches);
|
||||||
|
confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
password?.addEventListener('input', syncPasswordGuidance);
|
||||||
|
confirmation?.addEventListener('input', syncPasswordGuidance);
|
||||||
|
syncPasswordGuidance();
|
||||||
|
}
|
||||||
|
|
||||||
for (const form of document.querySelectorAll('[data-target-form]')) {
|
for (const form of document.querySelectorAll('[data-target-form]')) {
|
||||||
const active = form.querySelector('[data-target-active]');
|
const active = form.querySelector('[data-target-active]');
|
||||||
const staticFields = form.querySelector('[data-static-ip-fields]');
|
const staticFields = form.querySelector('[data-static-ip-fields]');
|
||||||
@@ -226,12 +252,107 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
|
||||||
if (countdowns.length === 0) return;
|
if (softwareUpdateForms.length > 0) {
|
||||||
|
const overlay = document.querySelector('[data-software-update-overlay]');
|
||||||
|
const message = overlay?.querySelector('[data-software-update-message]');
|
||||||
|
const close = overlay?.querySelector('[data-software-update-close]');
|
||||||
|
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
|
||||||
|
const phaseOrder = ['pulling', 'restarting', 'verifying'];
|
||||||
|
let polling = false;
|
||||||
|
let targetVersion = '';
|
||||||
|
let startedAt = 0;
|
||||||
|
|
||||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
const setUpdatePhase = (phase, text) => {
|
||||||
let running = countdowns[0].dataset.running === 'true';
|
if (!overlay) return;
|
||||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
overlay.dataset.updateState = phase;
|
||||||
|
if (message && text) message.textContent = text;
|
||||||
|
const currentIndex = phaseOrder.indexOf(phase);
|
||||||
|
for (const node of phaseNodes) {
|
||||||
|
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
|
||||||
|
node.classList.toggle('is-active', nodeIndex === currentIndex);
|
||||||
|
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollSoftwareUpdate = async () => {
|
||||||
|
if (!polling) return;
|
||||||
|
if (Date.now() - startedAt > 12 * 60 * 1000) {
|
||||||
|
polling = false;
|
||||||
|
setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||||
|
if (!response.ok) throw new Error('status unavailable');
|
||||||
|
const state = await response.json();
|
||||||
|
const phase = state.phase || 'queued';
|
||||||
|
const messages = {
|
||||||
|
queued: 'Update ist eingereiht.',
|
||||||
|
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
|
||||||
|
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
|
||||||
|
verifying: 'Die neue Instanz durchläuft den Healthcheck.',
|
||||||
|
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
|
||||||
|
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
|
||||||
|
};
|
||||||
|
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.');
|
||||||
|
if (phase === 'complete') {
|
||||||
|
polling = false;
|
||||||
|
window.setTimeout(() => window.location.assign('/system'), 1400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phase === 'failed') {
|
||||||
|
polling = false;
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
|
||||||
|
}
|
||||||
|
window.setTimeout(pollSoftwareUpdate, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const form of softwareUpdateForms) {
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (polling || !overlay) return;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
targetVersion = String(formData.get('version') || '');
|
||||||
|
overlay.hidden = false;
|
||||||
|
document.body.classList.add('software-update-open');
|
||||||
|
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
|
||||||
|
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
|
||||||
|
polling = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
window.setTimeout(pollSoftwareUpdate, 400);
|
||||||
|
} catch (error) {
|
||||||
|
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close?.addEventListener('click', () => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
document.body.classList.remove('software-update-open');
|
||||||
|
close.hidden = true;
|
||||||
|
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||||
|
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||||
|
if (countdowns.length === 0 && currentIPv4Fields.length === 0) return;
|
||||||
|
|
||||||
|
let nextRun = Number(countdowns[0]?.dataset.countdown || 0);
|
||||||
|
let running = countdowns[0]?.dataset.running === 'true';
|
||||||
|
let enabled = countdowns[0]?.dataset.enabled !== 'false';
|
||||||
let lastRefresh = 0;
|
let lastRefresh = 0;
|
||||||
|
|
||||||
const formatRemaining = (seconds) => {
|
const formatRemaining = (seconds) => {
|
||||||
@@ -243,6 +364,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshSchedule = async () => {
|
const refreshSchedule = async () => {
|
||||||
|
if (document.hidden) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastRefresh < 2500) return;
|
if (now - lastRefresh < 2500) return;
|
||||||
lastRefresh = now;
|
lastRefresh = now;
|
||||||
@@ -253,12 +375,17 @@
|
|||||||
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()) {
|
||||||
|
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The visible countdown keeps its last known state when the status request fails.
|
// Visible scheduler data keeps its last known state when the request fails.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
if (countdowns.length === 0) return;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
for (const node of countdowns) {
|
for (const node of countdowns) {
|
||||||
node.textContent = 'Inaktiv';
|
node.textContent = 'Inaktiv';
|
||||||
@@ -276,8 +403,16 @@
|
|||||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (countdowns.length > 0) {
|
||||||
|
tick();
|
||||||
|
window.setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
refreshSchedule();
|
||||||
|
window.setInterval(refreshSchedule, 30000);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
lastRefresh = 0;
|
||||||
tick();
|
tick();
|
||||||
refreshSchedule();
|
refreshSchedule();
|
||||||
window.setInterval(tick, 1000);
|
});
|
||||||
window.setInterval(refreshSchedule, 30000);
|
|
||||||
})();
|
})();
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
||||||
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||||
button, input, textarea, select { font: inherit; }
|
button, input, textarea, select { font: inherit; }
|
||||||
@@ -59,12 +60,15 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
||||||
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
||||||
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
||||||
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
.nav-update-dot { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.14), 0 0 12px rgba(96,165,250,.42); }
|
||||||
|
.sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||||
|
.sidebar-account-link { display: grid; min-width: 0; grid-template-columns: 32px minmax(0,1fr); gap: 9px; align-items: center; padding: 3px; border-radius: 7px; }
|
||||||
|
.sidebar-account-link:hover { background: #1b2739; color: #fff; }
|
||||||
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
||||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
||||||
.sidebar-account small { color: #748298; font-size: 9px; }
|
.sidebar-account small { color: #748298; font-size: 9px; }
|
||||||
.sidebar-account form { display: contents; }
|
.sidebar-account form { display: block; }
|
||||||
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
||||||
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
||||||
|
|
||||||
@@ -72,10 +76,21 @@ 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-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
|
||||||
|
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
|
||||||
|
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.software-update-banner div > strong { margin-top: 2px; font-size: 12px; }
|
||||||
|
.software-update-banner-actions { display: flex; gap: 7px; align-items: center; }
|
||||||
|
.software-update-banner-actions form { display: contents; }
|
||||||
|
.software-update-banner .button-quiet { border-color: rgba(191,219,254,.2); background: rgba(255,255,255,.055); color: #dbeafe; }
|
||||||
|
.software-update-banner .button-quiet:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||||
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
||||||
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
||||||
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
||||||
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
|
.auth-shell { display: grid; min-height: 100vh; min-height: 100svh; place-items: center; padding: clamp(18px, 4vw, 48px); background: radial-gradient(circle at 12% 18%, rgba(37,99,235,.1), transparent 28%), linear-gradient(145deg, #edf2f7 0%, #f8fafc 55%, #eef2f6 100%); }
|
||||||
|
.page-setup { overflow: hidden; }
|
||||||
|
.page-setup .auth-shell { width: 100%; height: 100vh; height: 100svh; min-height: 0; overflow: hidden; padding: 0; }
|
||||||
|
|
||||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
||||||
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
||||||
@@ -315,6 +330,91 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
||||||
.security-strip span { color: var(--success); }
|
.security-strip span { color: var(--success); }
|
||||||
.login-meta { text-align: center; }
|
.login-meta { text-align: center; }
|
||||||
|
.login-card { position: relative; overflow: hidden; width: min(460px, 100%); padding: 34px; border-radius: 18px; }
|
||||||
|
.login-card::before { position: absolute; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, var(--accent), #2dd4bf); content: ""; }
|
||||||
|
.login-card-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.login-mode { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 99px; background: var(--warning-soft); color: var(--warning); font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||||
|
.login-mode.is-secure { border-color: #b4ddd4; background: #effaf7; color: var(--success); }
|
||||||
|
.login-card h1 { margin-top: 36px; }
|
||||||
|
.login-card .lede { margin-bottom: 24px; }
|
||||||
|
.onboarding-shell { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: minmax(400px, .9fr) minmax(560px, 1.1fr); overflow: hidden; background: #f8fafc; }
|
||||||
|
.onboarding-intro { position: relative; display: flex; min-width: 0; overflow: hidden; flex-direction: column; padding: clamp(34px, 4vw, 64px); background: radial-gradient(circle at 12% -5%, rgba(59,130,246,.27), transparent 34%), radial-gradient(circle at 105% 88%, rgba(45,212,191,.12), transparent 36%), linear-gradient(150deg, #0e1726 0%, #152238 100%); color: #fff; }
|
||||||
|
.onboarding-intro::before { position: absolute; inset: 0; background-image: linear-gradient(rgba(148,163,184,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.035) 1px, transparent 1px); background-size: 38px 38px; content: ""; pointer-events: none; }
|
||||||
|
.onboarding-intro::after { position: absolute; top: 0; right: 0; width: 1px; height: 100%; background: rgba(255,255,255,.12); content: ""; pointer-events: none; }
|
||||||
|
.onboarding-brand-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.onboarding-edition { color: #718096; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.auth-brand-inverse { color: #f8fafc; }
|
||||||
|
.auth-brand-inverse span { border: 1px solid #3a4b65; background: rgba(28,41,64,.88); color: #b9d0ff; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
|
||||||
|
.onboarding-copy { position: relative; z-index: 1; margin-top: clamp(38px, 6vh, 68px); }
|
||||||
|
.onboarding-eyebrow { display: inline-flex; margin-bottom: 15px; padding: 5px 8px; border: 1px solid rgba(147,164,186,.2); border-radius: 99px; background: rgba(15,23,42,.24); color: #9fb0c7; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
|
||||||
|
.onboarding-copy h1 { max-width: 620px; margin-bottom: 16px; color: #f8fafc; font-size: clamp(40px, 4.3vw, 64px); line-height: 1; letter-spacing: -.06em; }
|
||||||
|
.onboarding-copy p { max-width: 500px; margin: 0; color: #9aa8ba; font-size: clamp(11px, 1vw, 14px); line-height: 1.6; }
|
||||||
|
.onboarding-progress { position: relative; z-index: 1; max-width: 520px; margin-top: clamp(28px, 4vh, 42px); }
|
||||||
|
.onboarding-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #77869a; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.onboarding-progress strong { color: #aec8f6; font-size: 8px; }
|
||||||
|
.onboarding-progress-track { display: block; height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: rgba(148,163,184,.14); }
|
||||||
|
.onboarding-progress-track i { display: block; width: 33.333%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #3b82f6, #75a7ff); box-shadow: 0 0 16px rgba(59,130,246,.55); }
|
||||||
|
.onboarding-steps { position: relative; z-index: 1; display: grid; max-width: 520px; gap: 7px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||||
|
.onboarding-steps::before { position: absolute; top: 35px; bottom: 35px; left: 29px; z-index: -1; width: 1px; background: rgba(148,163,184,.14); content: ""; }
|
||||||
|
.onboarding-steps li { position: relative; display: grid; min-height: 62px; grid-template-columns: 36px minmax(0,1fr); gap: 13px; align-items: center; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; color: #718096; }
|
||||||
|
.onboarding-steps li > span { position: relative; z-index: 1; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(148,163,184,.16); border-radius: 10px; background: #152238; color: #718096; font-family: var(--mono); font-size: 9px; font-weight: 750; }
|
||||||
|
.onboarding-steps strong, .onboarding-steps small { display: block; }
|
||||||
|
.onboarding-steps strong { color: #bac5d4; font-size: 12px; }
|
||||||
|
.onboarding-steps small { margin-top: 2px; color: #718096; font-size: 9px; line-height: 1.45; }
|
||||||
|
.onboarding-step-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.onboarding-step-title em { padding: 3px 6px; border-radius: 99px; background: rgba(59,130,246,.18); color: #a8c7ff; font-family: var(--mono); font-size: 7px; font-style: normal; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.onboarding-steps .is-current { border-color: rgba(96,165,250,.26); background: linear-gradient(90deg, rgba(37,99,235,.18), rgba(37,99,235,.055)); box-shadow: inset 3px 0 #4f8ff7, 0 10px 28px rgba(2,8,23,.12); }
|
||||||
|
.onboarding-steps .is-current > span { border-color: #4f8ff7; background: #2563eb; color: #fff; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
|
||||||
|
.onboarding-steps .is-current strong { color: #fff; }
|
||||||
|
.onboarding-card { display: grid; min-width: 0; place-items: center; padding: clamp(32px, 4vw, 64px) clamp(36px, 6vw, 96px) clamp(48px, 7vw, 96px); background: #f8fafc; }
|
||||||
|
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||||
|
.onboarding-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: clamp(20px, 3vh, 28px); color: #7b8797; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.setup-ready { display: inline-flex; align-items: center; gap: 8px; color: #4f6074; }
|
||||||
|
.setup-ready i { width: 7px; height: 7px; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.1); }
|
||||||
|
.onboarding-form-heading h2 { margin: 0 0 10px; font-size: clamp(30px, 3.2vw, 44px); line-height: 1.08; letter-spacing: -.05em; }
|
||||||
|
.onboarding-lede { max-width: 590px; margin-bottom: clamp(18px, 2.6vh, 24px); color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.setup-form { gap: 14px; }
|
||||||
|
.setup-field-row { gap: 16px; }
|
||||||
|
.setup-form label { gap: 6px; color: #334155; font-size: 10px; }
|
||||||
|
.setup-form input { min-height: 46px; border-radius: 8px; background: #fff; }
|
||||||
|
.setup-form input.is-valid:not(:focus) { border-color: #91cfc3; box-shadow: 0 0 0 3px rgba(15,118,110,.055); }
|
||||||
|
.password-guidance { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
|
||||||
|
.password-guidance span { display: flex; min-width: 0; min-height: 38px; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid #dbe2e9; border-radius: 9px; background: #fff; color: #738093; font-size: 9px; font-weight: 700; line-height: 1.25; transition: border-color .24s ease, background .24s ease, color .24s ease, box-shadow .24s ease, transform .24s ease; }
|
||||||
|
.password-guidance i { display: grid; width: 20px; height: 20px; flex: none; place-items: center; border-radius: 7px; background: #edf1f5; color: transparent; font-family: var(--mono); font-size: 10px; font-style: normal; font-weight: 900; transform: scale(.82); transition: background .24s ease, color .18s ease, transform .3s cubic-bezier(.2,1.5,.4,1); }
|
||||||
|
.password-guidance span.is-valid { border-color: #a8dacf; background: linear-gradient(120deg, #f4fbf9, #edf9f6); color: #0d6f67; box-shadow: 0 5px 16px rgba(15,118,110,.08); transform: translateY(-1px); animation: guidance-pop .38s cubic-bezier(.2,.9,.3,1.35) both; }
|
||||||
|
.password-guidance span.is-valid i { background: #0f8a7f; color: #fff; box-shadow: 0 4px 10px rgba(15,118,110,.2); transform: scale(1); animation: guidance-check .42s cubic-bezier(.2,1.55,.4,1) both; }
|
||||||
|
.setup-submit { position: relative; width: 100%; min-height: 48px; overflow: hidden; margin-top: 2px; border-radius: 8px; transition: border-color .2s, background .2s, box-shadow .24s, transform .2s; }
|
||||||
|
.setup-submit > span { position: relative; z-index: 1; transition: transform .22s ease; }
|
||||||
|
.setup-submit:hover > span { transform: translateX(3px); }
|
||||||
|
.setup-form.is-password-ready .setup-submit { box-shadow: 0 10px 24px rgba(37,99,235,.18); }
|
||||||
|
.setup-form.is-password-ready .setup-submit::after { position: absolute; top: -80%; bottom: -80%; left: -35%; width: 24%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.48), transparent); content: ""; pointer-events: none; transform: rotate(16deg); animation: setup-button-sheen .75s ease-out both; }
|
||||||
|
.onboarding-footer { margin-top: clamp(16px, 2.5vh, 22px); padding-top: 16px; border-top: 1px solid var(--line); }
|
||||||
|
.onboarding-mode { display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 8px; align-items: center; color: #7a5a16; }
|
||||||
|
.onboarding-mode > span { display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid #d7bd80; border-radius: 50%; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||||
|
.onboarding-mode p { margin: 0; }
|
||||||
|
.onboarding-mode strong, .onboarding-mode small { display: block; }
|
||||||
|
.onboarding-mode strong { font-size: 9px; }
|
||||||
|
.onboarding-mode small { margin-top: 1px; color: #887347; font-size: 8px; font-weight: 500; line-height: 1.4; }
|
||||||
|
.onboarding-mode.is-secure { color: var(--success); }
|
||||||
|
.onboarding-mode.is-secure > span { border-color: #8bc5ba; }
|
||||||
|
.onboarding-mode.is-secure small { color: #59877f; }
|
||||||
|
@keyframes setup-reveal { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes setup-panel-reveal { from { opacity: 0; transform: translateY(18px) scale(.988); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
|
@keyframes setup-progress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
@keyframes setup-ready-pulse { 0% { box-shadow: 0 0 0 0 rgba(34,160,107,.32); } 65% { box-shadow: 0 0 0 7px rgba(34,160,107,0); } 100% { box-shadow: 0 0 0 3px rgba(34,160,107,.1); } }
|
||||||
|
@keyframes guidance-pop { 0% { transform: translateY(0) scale(.97); } 70% { transform: translateY(-2px) scale(1.015); } 100% { transform: translateY(-1px) scale(1); } }
|
||||||
|
@keyframes guidance-check { 0% { opacity: 0; transform: scale(.35) rotate(-18deg); } 70% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { opacity: 1; transform: scale(1) rotate(0); } }
|
||||||
|
@keyframes setup-button-sheen { from { left: -35%; } to { left: 118%; } }
|
||||||
|
.onboarding-brand-row { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.onboarding-copy { animation: setup-reveal .55s .06s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.onboarding-progress { animation: setup-reveal .55s .12s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.onboarding-progress-track i { transform-origin: left; animation: setup-progress-fill .75s .38s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.onboarding-steps li { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.onboarding-steps li:nth-child(1) { animation-delay: .18s; }
|
||||||
|
.onboarding-steps li:nth-child(2) { animation-delay: .24s; }
|
||||||
|
.onboarding-steps li:nth-child(3) { animation-delay: .3s; }
|
||||||
|
.onboarding-form-wrap { animation: setup-panel-reveal .58s .08s cubic-bezier(.2,.8,.3,1) both; }
|
||||||
|
.setup-ready i { animation: setup-ready-pulse .8s .58s ease-out both; }
|
||||||
.token-card { width: min(680px, 100%); }
|
.token-card { width: min(680px, 100%); }
|
||||||
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
||||||
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
||||||
@@ -336,6 +436,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
||||||
.row-arrow { color: var(--accent); }
|
.row-arrow { color: var(--accent); }
|
||||||
.access-panel { margin-top: 12px; }
|
.access-panel { margin-top: 12px; }
|
||||||
|
.access-policy-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
|
.access-policy-card { display: grid; min-height: 76px; grid-template-columns: 36px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||||
|
.access-policy-card.is-admin { border-color: #b9cdef; background: linear-gradient(120deg,#fff 62%,#f0f5ff); }
|
||||||
|
.access-policy-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; background: #edf1f5; color: #526174; font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||||
|
.access-policy-card.is-admin .access-policy-icon { background: var(--accent-soft); color: #315eaa; }
|
||||||
|
.access-policy-card strong, .access-policy-card small { display: block; }
|
||||||
|
.access-policy-card strong { font-size: 11px; }
|
||||||
|
.access-policy-card small { margin-top: 2px; color: var(--muted); font-size: 8px; line-height: 1.4; }
|
||||||
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
||||||
.access-list { display: grid; gap: 9px; }
|
.access-list { display: grid; gap: 9px; }
|
||||||
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
||||||
@@ -349,6 +457,77 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.access-target strong, .access-target small { display: block; }
|
.access-target strong, .access-target small { display: block; }
|
||||||
.access-target small { color: var(--muted); font-size: 8px; }
|
.access-target small { color: var(--muted); font-size: 8px; }
|
||||||
.access-manage-switch { white-space: nowrap; }
|
.access-manage-switch { white-space: nowrap; }
|
||||||
|
.admin-full-access-card { display: grid; grid-template-columns: 46px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 17px; border: 1px solid #acd8ce; border-radius: var(--radius); background: linear-gradient(110deg,#fff 65%,#eefaf7); box-shadow: var(--shadow); }
|
||||||
|
.admin-full-access-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; background: #dff5ef; color: var(--success); font-family: var(--mono); font-size: 13px; font-weight: 900; }
|
||||||
|
.admin-full-access-card h2 { margin-bottom: 3px; }
|
||||||
|
.admin-full-access-card p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||||
|
.account-password-panel { width: min(760px,100%); }
|
||||||
|
.user-create-panel { width: min(930px,100%); }
|
||||||
|
.user-create-panel .wide-form { max-width: none; }
|
||||||
|
.user-create-grid { gap: 14px; align-items: stretch; }
|
||||||
|
.user-create-grid > label { min-width: 0; align-content: start; }
|
||||||
|
.user-create-grid input { height: 42px; }
|
||||||
|
.user-create-identity-grid > label small { min-height: 27px; }
|
||||||
|
.field-spacer { visibility: hidden; }
|
||||||
|
|
||||||
|
.software-version-card { position: relative; display: grid; min-height: 164px; overflow: hidden; grid-template-columns: 62px minmax(0,1fr) auto; gap: 20px; align-items: center; margin-bottom: 12px; padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow); }
|
||||||
|
.software-version-card::after { position: absolute; top: -90px; right: -70px; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle,rgba(37,99,235,.08),transparent 68%); content: ""; pointer-events: none; }
|
||||||
|
.software-version-card.has-update { border-color: #b4c9ec; background: linear-gradient(120deg,#fff 55%,#f1f6ff); box-shadow: 0 16px 38px rgba(37,99,235,.09); }
|
||||||
|
.software-version-visual { position: relative; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid #cbd8e8; border-radius: 16px; background: #f4f7fb; color: #315eaa; font-family: var(--mono); font-size: 11px; font-weight: 900; }
|
||||||
|
.software-version-visual i { position: absolute; right: -3px; bottom: -3px; width: 14px; height: 14px; border: 3px solid #fff; border-radius: 50%; background: #22a06b; }
|
||||||
|
.software-version-copy { min-width: 0; }
|
||||||
|
.software-version-values { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
|
||||||
|
.software-version-cell { display: grid; min-width: 150px; gap: 3px; align-content: center; padding-right: 24px; }
|
||||||
|
.software-version-cell + .software-version-cell { padding-left: 24px; border-left: 1px solid var(--line); }
|
||||||
|
.software-version-cell span { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.software-version-cell strong { color: var(--text); font-family: var(--mono); font-size: clamp(22px,2.6vw,32px); line-height: 1.1; letter-spacing: -.04em; }
|
||||||
|
.software-version-cell.is-latest strong { color: var(--accent); }
|
||||||
|
.software-version-state { margin: 9px 0 0; color: var(--muted); font-size: 10px; }
|
||||||
|
.software-version-meta { display: flex; flex-wrap: wrap; gap: 6px 15px; margin-top: 10px; color: var(--muted); font-size: 8px; }
|
||||||
|
.software-version-meta b { color: #4b5c70; font-weight: 750; }
|
||||||
|
.software-version-action { position: relative; z-index: 1; }
|
||||||
|
.software-check-button { gap: 7px; }
|
||||||
|
.software-check-button i { font-style: normal; font-size: 15px; line-height: 1; transition: transform .2s ease; }
|
||||||
|
.software-check-button:hover i { transform: rotate(45deg); }
|
||||||
|
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
||||||
|
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||||
|
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||||
|
.update-credential-panel { width: min(930px,100%); }
|
||||||
|
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
|
||||||
|
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
|
||||||
|
.update-inline-warning p { margin: 0; }
|
||||||
|
.update-inline-warning strong, .update-inline-warning small { display: block; }
|
||||||
|
.update-inline-warning strong { color: #74540b; font-size: 9px; }
|
||||||
|
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
|
||||||
|
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s 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 .section-label { color: #7f94af; }
|
||||||
|
.software-update-dialog h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; }
|
||||||
|
.software-update-dialog p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; }
|
||||||
|
.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-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-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; }
|
||||||
|
.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-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; }
|
||||||
|
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; }
|
||||||
|
.software-update-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; }
|
||||||
|
.software-update-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; }
|
||||||
|
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; }
|
||||||
|
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; }
|
||||||
|
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||||
|
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; }
|
||||||
|
.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; }
|
||||||
|
.software-update-phases li.is-active { color: #b9d4ff; }
|
||||||
|
.software-update-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; }
|
||||||
|
.software-update-phases li.is-complete { color: #6fc5b6; }
|
||||||
|
.software-update-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; }
|
||||||
|
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||||
|
body.software-update-open { overflow: hidden; }
|
||||||
|
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes update-orbit { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.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; }
|
||||||
@@ -381,6 +560,23 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.onboarding-shell { grid-template-columns: minmax(300px, .72fr) minmax(500px, 1.28fr); }
|
||||||
|
.onboarding-intro { padding: 34px; }
|
||||||
|
.onboarding-copy { margin-top: 38px; }
|
||||||
|
.onboarding-copy h1 { font-size: 43px; }
|
||||||
|
.onboarding-progress { margin-top: 27px; }
|
||||||
|
.onboarding-steps small { display: none; }
|
||||||
|
.onboarding-card { padding: 34px 42px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px), (max-height: 600px) {
|
||||||
|
.onboarding-shell { grid-template-columns: 1fr; }
|
||||||
|
.onboarding-intro { display: none; }
|
||||||
|
.onboarding-card { padding: clamp(20px, 5vw, 38px); }
|
||||||
|
.onboarding-form-wrap { width: min(660px, 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.admin-layout { display: block; }
|
.admin-layout { display: block; }
|
||||||
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
||||||
@@ -413,6 +609,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.archive-row .button { width: 100%; }
|
.archive-row .button { width: 100%; }
|
||||||
.info-box { grid-template-columns: 1fr; }
|
.info-box { grid-template-columns: 1fr; }
|
||||||
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
||||||
|
.access-policy-grid { grid-template-columns: 1fr; }
|
||||||
.access-zone-controls { width: 100%; }
|
.access-zone-controls { width: 100%; }
|
||||||
.access-target { grid-template-columns: 1fr; }
|
.access-target { grid-template-columns: 1fr; }
|
||||||
.error-summary-grid { grid-template-columns: 1fr; }
|
.error-summary-grid { grid-template-columns: 1fr; }
|
||||||
@@ -420,9 +617,78 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.error-item > a { grid-column: 2; }
|
.error-item > a { grid-column: 2; }
|
||||||
.pihole-action-grid { grid-template-columns: 1fr; }
|
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||||
.pihole-connect-stage { min-height: 0; }
|
.pihole-connect-stage { min-height: 0; }
|
||||||
|
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
|
||||||
|
.software-update-banner-actions { grid-column: 1 / -1; }
|
||||||
|
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
|
||||||
|
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
|
||||||
|
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||||
|
.software-version-action { grid-column: 1 / -1; }
|
||||||
|
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||||
|
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
|
.admin-full-access-card > .status-pill { grid-column: 2; }
|
||||||
|
.auth-shell { align-items: start; padding: 12px; }
|
||||||
|
.page-setup .auth-shell { align-items: stretch; padding: 0; }
|
||||||
|
.setup-field-row { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||||
|
.onboarding-card { padding: 24px; }
|
||||||
|
.onboarding-card-meta { margin-bottom: 24px; }
|
||||||
|
.onboarding-form-heading h2 { font-size: 32px; }
|
||||||
|
.onboarding-lede { margin-bottom: 20px; }
|
||||||
|
.setup-form { gap: 12px; }
|
||||||
|
.setup-form input { min-height: 43px; }
|
||||||
|
.onboarding-footer { margin-top: 17px; padding-top: 15px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.access-policy-card { grid-template-columns: 34px minmax(0,1fr); }
|
||||||
|
.access-policy-card > .status-pill { grid-column: 2; justify-self: start; }
|
||||||
|
.software-version-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.software-version-cell { min-width: 0; padding-right: 12px; }
|
||||||
|
.software-version-cell + .software-version-cell { padding-left: 12px; }
|
||||||
|
.software-version-cell strong { font-size: 21px; }
|
||||||
|
.software-update-dialog { padding: 24px 18px; }
|
||||||
|
.onboarding-card { padding: 18px; }
|
||||||
|
.onboarding-card-meta { margin-bottom: 18px; }
|
||||||
|
.onboarding-form-heading h2 { font-size: 28px; }
|
||||||
|
.onboarding-lede { margin-bottom: 15px; font-size: 10px; }
|
||||||
|
.setup-field-row { grid-template-columns: 1fr; gap: 9px; }
|
||||||
|
.setup-form { gap: 9px; }
|
||||||
|
.setup-form label { gap: 4px; }
|
||||||
|
.setup-form input { min-height: 39px; }
|
||||||
|
.setup-submit { min-height: 42px; }
|
||||||
|
.onboarding-footer { margin-top: 12px; padding-top: 11px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
.onboarding-intro { padding-block: 30px; }
|
||||||
|
.onboarding-copy { margin-top: 28px; }
|
||||||
|
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||||
|
.onboarding-copy p { font-size: 11px; }
|
||||||
|
.onboarding-progress { margin-top: 20px; }
|
||||||
|
.onboarding-progress-track { margin-top: 7px; }
|
||||||
|
.onboarding-steps { gap: 4px; margin-top: 9px; }
|
||||||
|
.onboarding-steps li { min-height: 50px; padding-block: 7px; }
|
||||||
|
.onboarding-steps small { display: none; }
|
||||||
|
.onboarding-card { padding-block: 24px; }
|
||||||
|
.onboarding-card-meta { margin-bottom: 18px; }
|
||||||
|
.onboarding-form-heading h2 { font-size: 31px; }
|
||||||
|
.onboarding-lede { margin-bottom: 17px; }
|
||||||
|
.setup-form { gap: 10px; }
|
||||||
|
.setup-form input { min-height: 41px; }
|
||||||
|
.onboarding-footer { margin-top: 13px; padding-top: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 560px) {
|
||||||
|
.onboarding-card-meta { margin-bottom: 12px; }
|
||||||
|
.onboarding-lede { display: none; }
|
||||||
|
.onboarding-form-heading h2 { margin-bottom: 14px; }
|
||||||
|
.setup-form input { min-height: 38px; }
|
||||||
|
.password-guidance span { min-height: 32px; padding: 5px 7px; }
|
||||||
|
.password-guidance i { width: 18px; height: 18px; }
|
||||||
|
.setup-submit { min-height: 40px; }
|
||||||
|
.onboarding-footer { padding-top: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||||
.toast, .toast-progress { animation: none !important; }
|
.toast, .toast-progress { animation: none !important; }
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<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}}">
|
||||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
||||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder value="{{.StaticIPv4}}" inputmode="decimal" placeholder="{{if $.Settings.LastIPv4}}{{$.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||||
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
||||||
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
||||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
{{if or .IsAdmin .CanCreateTargets}}
|
{{if or .IsAdmin .CanCreateTargets}}
|
||||||
<section class="action-grid admin-actions-grid">
|
<section class="action-grid admin-actions-grid">
|
||||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
||||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder inputmode="decimal" placeholder="{{if .Settings.LastIPv4}}{{.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||||
</article>
|
</article>
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
||||||
|
|||||||
@@ -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-14">
|
<link rel="stylesheet" href="/static/styles.css?v=features-21">
|
||||||
<script src="/static/app.js?v=features-14" defer></script>
|
<script src="/static/app.js?v=features-21" 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>
|
||||||
@@ -26,13 +26,12 @@
|
|||||||
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
||||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
||||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
|
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
|
||||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
|
<a href="/access" {{if eq .Page "access"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Zugriff</a>
|
||||||
|
<a href="/system" {{if eq .Page "system"}}aria-current="page"{{end}}><span class="nav-icon">07</span>Update{{if .SoftwareUpdate.UpdateAvailable}}<i class="nav-update-dot" aria-label="Update verfügbar"></i>{{end}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{else}}03{{end}}</span>Sicherheit</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-account">
|
<div class="sidebar-account">
|
||||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
<a class="sidebar-account-link" href="/account" aria-label="Eigenes Konto öffnen"><span class="account-avatar">{{initial .User.DisplayName}}</span><span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span></a>
|
||||||
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
|
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
||||||
@@ -44,6 +43,16 @@
|
|||||||
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
|
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
|
||||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
{{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")}}
|
||||||
|
<aside class="software-update-banner" aria-label="Software-Update verfügbar">
|
||||||
|
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
|
||||||
|
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||||
|
<div class="software-update-banner-actions">
|
||||||
|
<a class="button button-quiet" href="/system">Details</a>
|
||||||
|
{{if .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}}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{{end}}
|
||||||
<main id="main" class="app-shell">
|
<main id="main" class="app-shell">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
@@ -67,6 +76,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{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">
|
||||||
|
<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}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
{{define "login"}}{{template "layout" .}}{{end}}
|
{{define "login"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="auth-card">
|
<section class="auth-card login-card">
|
||||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
<div class="login-card-top"><div class="auth-brand"><span>DD</span> DNS Control</div><span class="login-mode {{if eq .Mode "https"}}is-secure{{end}}">{{if eq .Mode "https"}}HTTPS{{else}}LAN{{end}}</span></div>
|
||||||
<h1>Anmelden</h1>
|
<h1>Willkommen zurück</h1>
|
||||||
|
<p class="lede">Melde dich mit deinem persönlichen Zugang an.</p>
|
||||||
<form action="/login" method="post" class="stacked-form">
|
<form action="/login" method="post" class="stacked-form">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<label>Benutzername
|
<label>Benutzername
|
||||||
<input name="username" type="text" autocomplete="username" required autofocus>
|
<input name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="40" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<label>Passwort
|
<label>Passwort
|
||||||
<input name="password" type="password" autocomplete="current-password" required>
|
<input name="password" type="password" autocomplete="current-password" maxlength="128" required>
|
||||||
</label>
|
</label>
|
||||||
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||||
</form>
|
</form>
|
||||||
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
|
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</article>
|
</article>
|
||||||
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
||||||
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
||||||
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="url" value="{{$integration.BaseURL}}" required><small>Basis-URL oder vollständiger <code>/api</code>-Pfad.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
||||||
<div class="panel-divider"></div>
|
<div class="panel-divider"></div>
|
||||||
<details><summary class="button button-quiet">Anwendungspasswort anzeigen</summary><form action="/pihole/{{$integration.ID}}/password" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Passwort entschlüsseln</button></form></details>
|
<details><summary class="button button-quiet">Anwendungspasswort anzeigen</summary><form action="/pihole/{{$integration.ID}}/password" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Passwort entschlüsseln</button></form></details>
|
||||||
<div class="panel-divider"></div>
|
<div class="panel-divider"></div>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<div class="form-panel pihole-connect-panel" id="pihole-connect">
|
<div class="form-panel pihole-connect-panel" id="pihole-connect">
|
||||||
<div class="panel-heading"><div><span class="section-label pihole-kicker">API-Verbindung</span><h2 id="pihole-connect-title">Pi-hole verbinden</h2></div><span class="pihole-api-badge">API v6</span></div>
|
<div class="panel-heading"><div><span class="section-label pihole-kicker">API-Verbindung</span><h2 id="pihole-connect-title">Pi-hole verbinden</h2></div><span class="pihole-api-badge">API v6</span></div>
|
||||||
<p class="panel-description">Verwende ein eigenes Pi-hole-Anwendungspasswort. Es wird mit dem Master-Key dieser Anwendung verschlüsselt.</p>
|
<p class="panel-description">Verwende ein eigenes Pi-hole-Anwendungspasswort. Es wird mit dem Master-Key dieser Anwendung verschlüsselt.</p>
|
||||||
<form action="/pihole/connect" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="url" placeholder="https://pi.hole" required><small><code>/api</code> wird automatisch ergänzt.</small></label><label>Anwendungspasswort <input name="password" type="password" autocomplete="new-password" required><small>Keinen Session-Token eintragen.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls"><span></span> TLS-Zertifikat nicht prüfen</label><div class="warning-box">Nur für ein selbstsigniertes Zertifikat in einem vertrauenswürdigen internen Netz verwenden. Bei HTTP wird das Passwort auf dem Netzwerkweg unverschlüsselt übertragen.</div><button class="button button-pihole" type="submit">Verbindung testen und speichern</button></form>
|
<form action="/pihole/connect" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" placeholder="10.0.20.100 oder https://pi.hole" required><small>IPv4-Adressen werden automatisch über HTTP verbunden; <code>/api</code> wird ergänzt.</small></label><label>Anwendungspasswort <input name="password" type="password" autocomplete="new-password" required><small>Keinen Session-Token eintragen.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls"><span></span> TLS-Zertifikat nicht prüfen</label><div class="warning-box">Nur für ein selbstsigniertes Zertifikat in einem vertrauenswürdigen internen Netz verwenden. Bei HTTP wird das Passwort auf dem Netzwerkweg unverschlüsselt übertragen.</div><button class="button button-pihole" type="submit">Verbindung testen und speichern</button></form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{{define "security"}}{{template "layout" .}}{{end}}
|
{{define "security"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
|
<header class="page-heading"><div><span class="page-kicker">Persönlich</span><h1>Mein Konto</h1><p>@{{.User.Username}} · {{if .IsAdmin}}Administrator{{else}}Mitglied{{end}}</p></div>{{if .IsAdmin}}<a class="button button-quiet" href="/access">Zugriff verwalten</a>{{end}}</header>
|
||||||
<section class="split-grid">
|
<section class="form-panel account-password-panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Eigenes Passwort ändern</h2></div></div><form action="/account/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" maxlength="128" required></label><div class="form-grid"><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="15" maxlength="128" required></label><label>Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="15" maxlength="128" required></label></div><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></section>
|
||||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
|
|
||||||
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,26 +1,64 @@
|
|||||||
{{define "setup"}}{{template "layout" .}}{{end}}
|
{{define "setup"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="auth-card">
|
<section class="onboarding-shell">
|
||||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
<aside class="onboarding-intro">
|
||||||
<h1>System einrichten</h1>
|
<div class="onboarding-brand-row">
|
||||||
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
|
<div class="auth-brand auth-brand-inverse"><span>DD</span> DNS Control</div>
|
||||||
<form action="/setup" method="post" class="stacked-form">
|
<span class="onboarding-edition">Self-hosted</span>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-copy">
|
||||||
|
<span class="onboarding-eyebrow">Erster Start</span>
|
||||||
|
<h1>DNS-Verwaltung,<br>die einfach läuft.</h1>
|
||||||
|
<p>Einrichten, verbinden, automatisch aktuell halten.</p>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-progress" aria-label="Einrichtung: Schritt 1 von 3">
|
||||||
|
<div><span>Einrichtung</span><strong>1 von 3</strong></div>
|
||||||
|
<span class="onboarding-progress-track" aria-hidden="true"><i></i></span>
|
||||||
|
</div>
|
||||||
|
<ol class="onboarding-steps" aria-label="Einrichtung">
|
||||||
|
<li class="is-current" aria-current="step"><span>01</span><div><div class="onboarding-step-title"><strong>Administrator anlegen</strong><em>Aktuell</em></div><small>Sicheren Zugang für diese Instanz erstellen.</small></div></li>
|
||||||
|
<li><span>02</span><div><strong>Dienste verbinden</strong><small>Hetzner und optional Pi-hole hinzufügen.</small></div></li>
|
||||||
|
<li><span>03</span><div><strong>Automatisieren</strong><small>DNS-Einträge zuverlässig aktuell halten.</small></div></li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="onboarding-card" aria-labelledby="setup-title">
|
||||||
|
<div class="onboarding-form-wrap">
|
||||||
|
<div class="onboarding-card-meta">
|
||||||
|
<span class="setup-ready"><i aria-hidden="true"></i> Instanz bereit</span>
|
||||||
|
<span>Schritt 1 von 3</span>
|
||||||
|
</div>
|
||||||
|
<header class="onboarding-form-heading">
|
||||||
|
<h2 id="setup-title">Administrator anlegen</h2>
|
||||||
|
<p class="onboarding-lede">Lege den ersten Administrator für diese Instanz an.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form action="/setup" method="post" class="stacked-form setup-form" data-setup-form>
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<div class="form-grid"><label>Admin-Benutzername
|
<div class="form-grid setup-field-row"><label>Benutzername
|
||||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
|
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="40" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<label>Anzeigename
|
<label>Anzeigename
|
||||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
|
<input name="display_name" type="text" value="{{index .Form "display_name"}}" autocomplete="name" maxlength="80" required>
|
||||||
</label></div>
|
</label></div>
|
||||||
<label>Admin-Passwort
|
<div class="form-grid setup-field-row"><label>Admin-Passphrase
|
||||||
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
|
<input name="password" type="password" autocomplete="new-password" minlength="15" maxlength="128" aria-describedby="password-guidance" data-setup-password required>
|
||||||
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
|
|
||||||
</label>
|
</label>
|
||||||
<label>Passwort wiederholen
|
<label>Passphrase wiederholen
|
||||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
|
<input name="confirm_password" type="password" autocomplete="new-password" minlength="15" maxlength="128" data-setup-confirm required>
|
||||||
</label>
|
</label></div>
|
||||||
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
|
<div class="password-guidance" id="password-guidance" aria-live="polite">
|
||||||
|
<span data-password-length><i aria-hidden="true">✓</i> Mindestens 15 Zeichen</span>
|
||||||
|
<span data-password-match><i aria-hidden="true">✓</i> Eingaben stimmen überein</span>
|
||||||
|
</div>
|
||||||
|
<button class="button button-primary setup-submit" type="submit">Administrator erstellen <span aria-hidden="true">→</span></button>
|
||||||
</form>
|
</form>
|
||||||
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
|
|
||||||
|
<div class="onboarding-footer onboarding-mode {{if eq .Mode "https"}}is-secure{{end}}">
|
||||||
|
<span aria-hidden="true">{{if eq .Mode "https"}}●{{else}}!{{end}}</span>
|
||||||
|
<p><strong>{{if eq .Mode "https"}}HTTPS-Modus aktiv{{else}}LAN-Modus aktiv{{end}}</strong><small>{{if eq .Mode "https"}}Verschlüsselte Verbindung{{else}}Nur im lokalen Netz verwenden{{end}}</small></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{{define "system"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header class="page-heading system-heading">
|
||||||
|
<div><span class="page-kicker">Wartung</span><h1>Software-Update</h1><p>Version prüfen und die Instanz kontrolliert aktualisieren.</p></div>
|
||||||
|
<form action="/system/check" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet software-check-button" type="submit"><i aria-hidden="true">↻</i> Nach Update suchen</button></form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="software-version-card {{if .SoftwareUpdate.UpdateAvailable}}has-update{{end}}">
|
||||||
|
<div class="software-version-visual" aria-hidden="true"><span>DC</span><i></i></div>
|
||||||
|
<div class="software-version-copy">
|
||||||
|
<div class="software-version-values">
|
||||||
|
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
|
||||||
|
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
|
||||||
|
<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>
|
||||||
|
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="software-version-action">
|
||||||
|
{{if and .SoftwareUpdate.UpdateAvailable .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 software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||||
|
</form>
|
||||||
|
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||||
|
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span>
|
||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel update-credential-panel" 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>
|
||||||
|
<form action="/system/update-credential" method="post" class="stacked-form">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
|
||||||
|
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
||||||
|
<div class="form-actions"><button class="button button-primary" type="submit">Zugang sicher speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title">
|
||||||
|
<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}}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<a class="back-link" href="/users">← Benutzer</a>
|
<a class="back-link" href="/access">← Zugriff & Sicherheit</a>
|
||||||
<span class="page-kicker">Benutzerprofil</span>
|
<span class="page-kicker">Benutzerprofil</span>
|
||||||
<h1>{{.ManagedUser.DisplayName}}</h1>
|
<h1>{{.ManagedUser.DisplayName}}</h1>
|
||||||
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
||||||
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
|
<label>Neues Passwort <input name="password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
|
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
|
||||||
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
||||||
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
||||||
</form>
|
</form>
|
||||||
@@ -68,6 +68,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{{else}}
|
{{else}}
|
||||||
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
|
<section class="admin-full-access-card access-panel"><span class="admin-full-access-icon" aria-hidden="true">A</span><div><span class="section-label">Administratorrolle</span><h2>Vollzugriff ist fest mit der Rolle verbunden</h2><p>Für Administratoren gibt es keine bearbeitbaren Domain- oder Host-Einzelrechte.</p></div><span class="status-pill status-good">Alle Rechte</span></section>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{{define "user_new"}}{{template "layout" .}}{{end}}
|
{{define "user_new"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
|
<header class="page-heading"><div><a class="back-link" href="/access">← Zugriff & Sicherheit</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten starten ohne Freigaben.</p></div></header>
|
||||||
<section class="form-panel compact-panel">
|
<section class="form-panel compact-panel user-create-panel">
|
||||||
<form action="/users" method="post" class="stacked-form wide-form">
|
<form action="/users" 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>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
|
<div class="form-grid user-create-grid user-create-identity-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required><small class="field-spacer" aria-hidden="true"> </small></label></div>
|
||||||
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
|
<div class="form-grid user-create-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" maxlength="128" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" maxlength="128" required></label></div>
|
||||||
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
||||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
|
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/access">Abbrechen</a></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{{define "users"}}{{template "layout" .}}{{end}}
|
{{define "users"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
|
<header class="page-heading"><div><span class="page-kicker">Administration</span><h1>Zugriff & Sicherheit</h1><p>Konten, Rollen und Freigaben an einem Ort.</p></div><div class="heading-actions"><a class="button button-quiet" href="/account">Mein Konto</a><a class="button button-primary" href="/users/new">Benutzer anlegen</a></div></header>
|
||||||
|
<section class="access-policy-grid" aria-label="Rollenmodell">
|
||||||
|
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
|
||||||
|
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
|
||||||
|
</section>
|
||||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{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">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user