Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb60c06c0e | ||
|
|
f02ec9977c | ||
|
|
a000152423 | ||
|
|
2082b68794 | ||
|
|
42fa6efc84 | ||
|
|
42085dcde0 | ||
|
|
1c32ca0671 | ||
|
|
1a6b4d0939 | ||
|
|
17d517e9dc | ||
|
|
c8728746f1 | ||
|
|
888d9a54a7 |
No files matched your search
@@ -1,10 +0,0 @@
|
|||||||
# For LAN use, set this to the host's specific private address, for example 192.168.178.10.
|
|
||||||
# Keep 127.0.0.1 when a reverse proxy terminates HTTPS locally.
|
|
||||||
BIND_ADDRESS=127.0.0.1
|
|
||||||
WEB_PORT=8080
|
|
||||||
|
|
||||||
# lan is only appropriate for an isolated, trusted LAN. Set https behind a TLS reverse proxy.
|
|
||||||
APP_MODE=lan
|
|
||||||
|
|
||||||
# Create this file once with: openssl rand -base64 32 > secrets/dyndns_master_key
|
|
||||||
MASTER_KEY_FILE=./secrets/dyndns_master_key
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Unveränderlichen Release-Tag aus der Gitea Container Registry verwenden.
|
|
||||||
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
|
|
||||||
IMAGE_TAG=v1.0.0
|
|
||||||
|
|
||||||
# Hinter einem HTTPS-Reverse-Proxy ausschließlich an localhost binden.
|
|
||||||
BIND_ADDRESS=127.0.0.1
|
|
||||||
WEB_PORT=8080
|
|
||||||
APP_MODE=https
|
|
||||||
|
|
||||||
# Datei einmalig auf dem Produktionsserver erzeugen und dauerhaft sichern.
|
|
||||||
MASTER_KEY_FILE=./secrets/dyndns_master_key
|
|
||||||
@@ -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,277 @@
|
|||||||
|
# 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`
|
||||||
|
- Versionshinweis in `UPDATE.md`
|
||||||
|
- `ARG VERSION` in `Dockerfile`
|
||||||
|
- Fallback-Version in `internal/buildinfo/version.go`
|
||||||
|
|
||||||
|
`MINIMUM_UPDATE_VERSION`, `MinimumUpdateVersion`, `MinimumAgentVersion` und die Vertragsnummern sind Kompatibilitätsgrenzen. Ändere sie nicht bei normalen Releases, sondern nur bei einem bewusst geplanten Vertragsbruch mit passender Migration und Dokumentation.
|
||||||
|
|
||||||
|
Danach dürfen die gepinnten Image-Referenzen nicht mehr auf die vorherige Version zeigen. Historische Hinweise zur v1.4-Update-Basis bleiben bestehen. Setze `PREVIOUS` auf die tatsächlich vorherige Version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PREVIOUS=v1.2.1
|
||||||
|
if grep -n "domain-control-webinterface-prod:$PREVIOUS" README.md compose.yml; then
|
||||||
|
echo "Alte Image-Version $PREVIOUS ist noch eingetragen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker compose -f compose.yml config --quiet
|
||||||
|
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
|
||||||
|
|
||||||
|
Wenn Repository-Actions aktiviert sind, startet ein `v*`-Tag den Gitea-Workflow. Prüfe dann zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden. Sind Repository-Actions deaktiviert, verwende ausschließlich den manuellen Multi-Arch-Fallback.
|
||||||
|
|
||||||
|
### Bekannter Gitea-Runner-Zustand
|
||||||
|
|
||||||
|
Stand 29.08.2026 ist kein Gitea-Actions-Runner aktiv. Repository-Actions sind deshalb deaktiviert. Run 7 für `v1.3.0` liegt weiterhin intern auf `queued`, kann im deaktivierten Zustand aber nicht ausgeführt werden; sein Commit-Status wurde nach der bereits abgeschlossenen manuellen Veröffentlichung auf `success` korrigiert. Gitea 1.26.4 bietet keinen API-Endpunkt zum Abbrechen einzelner Runs. Verändere deshalb nicht direkt die Gitea-Datenbank.
|
||||||
|
|
||||||
|
Bevor Actions oder ein Runner später aktiviert werden, muss Run 7 in der Gitea-Weboberfläche abgebrochen werden. Andernfalls könnte er das veraltete Image erneut als `stable` veröffentlichen. Aktiviere Actions erst danach und nur zusammen mit einem funktionsfähigen Runner.
|
||||||
|
|
||||||
|
### Manueller Multi-Arch-Fallback
|
||||||
|
|
||||||
|
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 \
|
||||||
|
--build-arg "VERSION=$VERSION" \
|
||||||
|
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION" \
|
||||||
|
--tag "git.j-mahler.de/admin/domain-control-webinterface-prod:stable" \
|
||||||
|
--label org.opencontainers.image.source=https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface \
|
||||||
|
--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.
|
||||||
+31
-6
@@ -1,5 +1,14 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM golang:1.25.12-alpine AS build
|
ARG VERSION=v1.4.0
|
||||||
|
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
||||||
|
ARG MINIMUM_AGENT_PROTOCOL=2
|
||||||
|
ARG RUNTIME_CONTRACT_VERSION=2
|
||||||
|
ARG DATABASE_CONTRACT_VERSION=1
|
||||||
|
ARG SCHEMA_VERSION=11
|
||||||
|
|
||||||
|
FROM golang:1.25.13-alpine AS build
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
|
||||||
RUN apk add --no-cache build-base
|
RUN apk add --no-cache build-base
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -8,21 +17,37 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dyndns-web ./cmd/dyndns-web
|
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/domain-control ./cmd/dyndns-web
|
||||||
|
|
||||||
FROM alpine:3.23.2 AS runtime
|
FROM alpine:3.23.2 AS runtime
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG MINIMUM_UPDATE_VERSION
|
||||||
|
ARG MINIMUM_AGENT_PROTOCOL
|
||||||
|
ARG RUNTIME_CONTRACT_VERSION
|
||||||
|
ARG DATABASE_CONTRACT_VERSION
|
||||||
|
ARG SCHEMA_VERSION
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates && \
|
RUN apk add --no-cache ca-certificates && \
|
||||||
addgroup -S -g 10001 dyndns && \
|
addgroup -S -g 10001 dyndns && \
|
||||||
adduser -S -D -H -u 10001 -G dyndns dyndns && \
|
adduser -S -D -H -u 10001 -G dyndns dyndns && \
|
||||||
mkdir -p /data && chown dyndns:dyndns /data
|
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets
|
||||||
|
|
||||||
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
|
COPY --from=build /out/domain-control /usr/local/bin/domain-control
|
||||||
|
RUN ln -s domain-control /usr/local/bin/update-agent
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}" \
|
||||||
|
de.j-mahler.domain-control.minimum-update-version="${MINIMUM_UPDATE_VERSION}" \
|
||||||
|
de.j-mahler.domain-control.minimum-agent-protocol="${MINIMUM_AGENT_PROTOCOL}" \
|
||||||
|
de.j-mahler.domain-control.runtime-contract="${RUNTIME_CONTRACT_VERSION}" \
|
||||||
|
de.j-mahler.domain-control.database-contract="${DATABASE_CONTRACT_VERSION}" \
|
||||||
|
de.j-mahler.domain-control.schema-version="${SCHEMA_VERSION}"
|
||||||
|
|
||||||
USER dyndns:dyndns
|
USER dyndns:dyndns
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dyndns-web", "healthcheck"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/domain-control", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/dyndns-web"]
|
ENTRYPOINT ["/usr/local/bin/domain-control"]
|
||||||
|
CMD ["serve"]
|
||||||
-170
@@ -1,170 +0,0 @@
|
|||||||
# Produktivbetrieb mit Gitea und Docker Compose
|
|
||||||
|
|
||||||
Dieses Repository veröffentlicht versionierte Container-Images unter:
|
|
||||||
|
|
||||||
```text
|
|
||||||
git.j-mahler.de/admin/domain-control-webinterface-prod:vX.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Image enthält ausschließlich die Anwendung. SQLite-Datenbank und Master-Key liegen dauerhaft auf dem Produktionsserver und bleiben bei Image-Updates erhalten.
|
|
||||||
|
|
||||||
## 1. Gitea für Releases vorbereiten
|
|
||||||
|
|
||||||
In den Repository-Einstellungen unter **Actions → Secrets** diese zwei Secrets anlegen:
|
|
||||||
|
|
||||||
- `REGISTRY_USERNAME`: Gitea-Benutzer oder eigener Release-Bot
|
|
||||||
- `REGISTRY_TOKEN`: persönlicher Gitea-Token mit Schreibrecht für Container-Pakete
|
|
||||||
|
|
||||||
Auf der Gitea-Instanz muss ein Actions-Runner mit dem Label `ubuntu-latest` registriert sein. Jeder Git-Tag, der mit `v` beginnt, startet anschließend `.gitea/workflows/release.yml` und baut Images für AMD64 und ARM64.
|
|
||||||
|
|
||||||
Erstes Release veröffentlichen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Nach erfolgreicher Action stehen `v1.0.0` und der bewegliche Tag `stable` in der Gitea Container Registry. Auf Produktion immer den unveränderlichen Versionstag verwenden.
|
|
||||||
|
|
||||||
### Image alternativ manuell veröffentlichen
|
|
||||||
|
|
||||||
Falls Gitea Actions noch nicht eingerichtet ist:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "$GITEA_TOKEN" | docker login git.j-mahler.de --username DEIN_BENUTZER --password-stdin
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t git.j-mahler.de/admin/domain-control-webinterface-prod:v1.0.0 \
|
|
||||||
-t git.j-mahler.de/admin/domain-control-webinterface-prod:stable \
|
|
||||||
--push .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Produktionsserver vorbereiten
|
|
||||||
|
|
||||||
Voraussetzungen:
|
|
||||||
|
|
||||||
- Linux-Server mit Docker Engine und Docker-Compose-Plugin
|
|
||||||
- HTTPS-Reverse-Proxy wie Caddy, nginx oder Traefik
|
|
||||||
- DNS-Name, der auf den Server zeigt
|
|
||||||
|
|
||||||
Repository installieren:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /opt/domain-control
|
|
||||||
sudo chown "$USER":"$USER" /opt/domain-control
|
|
||||||
git clone https://git.j-mahler.de/admin/domain-control-webinterface-prod.git /opt/domain-control
|
|
||||||
cd /opt/domain-control
|
|
||||||
|
|
||||||
cp .env.production.example .env.production
|
|
||||||
mkdir -p secrets backups
|
|
||||||
chmod 700 secrets backups
|
|
||||||
openssl rand -base64 32 > secrets/dyndns_master_key
|
|
||||||
chmod 644 secrets/dyndns_master_key
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Master-Key wird vom unprivilegierten Benutzer im Container gelesen. Deshalb ist die Datei lesbar, während das übergeordnete Verzeichnis auf dem Host nur für seinen Besitzer zugänglich bleibt. Den Schlüssel niemals nachträglich ersetzen: Ohne ihn lassen sich gespeicherte Hetzner-Tokens nicht mehr entschlüsseln.
|
|
||||||
|
|
||||||
Bei einer privaten Registry auf dem Produktionsserver einmalig mit einem Token anmelden, der nur Leserechte auf Container-Pakete besitzt:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "$GITEA_PULL_TOKEN" | docker login git.j-mahler.de --username deploy-bot --password-stdin
|
|
||||||
```
|
|
||||||
|
|
||||||
In `.env.production` mindestens `IMAGE_TAG` kontrollieren:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
|
|
||||||
IMAGE_TAG=v1.0.0
|
|
||||||
BIND_ADDRESS=127.0.0.1
|
|
||||||
WEB_PORT=8080
|
|
||||||
APP_MODE=https
|
|
||||||
MASTER_KEY_FILE=./secrets/dyndns_master_key
|
|
||||||
```
|
|
||||||
|
|
||||||
Anwendung laden und starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml pull
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml up -d
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml ps
|
|
||||||
curl --fail http://127.0.0.1:8080/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. HTTPS-Reverse-Proxy
|
|
||||||
|
|
||||||
Port 8080 bleibt mit `BIND_ADDRESS=127.0.0.1` ausschließlich lokal erreichbar. Ein minimales Caddy-Beispiel:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
dns.example.de {
|
|
||||||
reverse_proxy 127.0.0.1:8080
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach `https://dns.example.de` öffnen und den ersten Administrator anlegen. `APP_MODE=https` sorgt dabei für HTTPS-gebundene Session-Cookies.
|
|
||||||
|
|
||||||
Nur in einem isolierten, vertrauenswürdigen LAN darf stattdessen eine private Server-IP und `APP_MODE=lan` verwendet werden. Den Dienst nicht unverschlüsselt über `0.0.0.0` ins Internet stellen.
|
|
||||||
|
|
||||||
## 4. Backup vor jedem Update
|
|
||||||
|
|
||||||
Für eine konsistente SQLite-Kopie wird der Container kurz gestoppt:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/domain-control
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml stop dyndns
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
-v domain-control-data:/data:ro \
|
|
||||||
-v "$PWD/backups:/backup" \
|
|
||||||
alpine:3.23.2 \
|
|
||||||
cp /data/dyndns.db /backup/dyndns-before-update.db
|
|
||||||
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml start dyndns
|
|
||||||
```
|
|
||||||
|
|
||||||
Zusätzlich `secrets/dyndns_master_key` verschlüsselt und außerhalb dieses Servers sichern. Eine Datenbanksicherung ohne den dazugehörigen Master-Key ist für gespeicherte API-Tokens unbrauchbar.
|
|
||||||
|
|
||||||
## 5. Update
|
|
||||||
|
|
||||||
Nach Veröffentlichung beispielsweise von `v1.1.0`:
|
|
||||||
|
|
||||||
1. Backup wie oben erstellen.
|
|
||||||
2. In `.env.production` setzen:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
IMAGE_TAG=v1.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Neues Image laden und Container ersetzen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml pull
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml up -d
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml ps
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml logs --tail=100
|
|
||||||
curl --fail http://127.0.0.1:8080/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
Das benannte Volume `domain-control-data` wird dabei nicht ersetzt.
|
|
||||||
|
|
||||||
## 6. Rollback
|
|
||||||
|
|
||||||
Ohne Datenbankmigration reicht es, in `.env.production` den vorherigen `IMAGE_TAG` einzutragen und erneut `pull` sowie `up -d` auszuführen.
|
|
||||||
|
|
||||||
Wenn die neue Version eine Datenbankmigration ausgeführt hat, gemeinsam auf das vorherige Image und die unmittelbar davor erstellte Datenbanksicherung zurückgehen. Automatische, unbeaufsichtigte Updates sind deshalb nicht empfohlen.
|
|
||||||
|
|
||||||
## 7. Nützliche Betriebsbefehle
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Status
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml ps
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml logs -f --tail=100
|
|
||||||
|
|
||||||
# Neustart
|
|
||||||
docker compose --env-file .env.production -f compose.production.yml restart dyndns
|
|
||||||
|
|
||||||
# Bereits heruntergeladene, ungenutzte Images aufräumen
|
|
||||||
docker image prune
|
|
||||||
```
|
|
||||||
@@ -1,76 +1,98 @@
|
|||||||
# Hetzner DynDNS Dashboard
|
# Hetzner DynDNS
|
||||||
|
|
||||||
Ein schlankes, selbst gehostetes Dashboard für mehrere Hetzner-DNS-Zonen und DynDNS-Ziele. Der Container erkennt seine öffentliche IPv4/IPv6 in einem einstellbaren Rhythmus und aktualisiert ausschließlich aktivierte A-/AAAA-Records über die Hetzner Cloud DNS API.
|
Weboberfläche für mehrere Hetzner-DNS-Zonen, eine Pi-hole-v6-Verbindung, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt; lokale DNS-Einträge lassen sich direkt über die Pi-hole-API verwalten.
|
||||||
|
|
||||||
## Was es kann
|
## Starten
|
||||||
|
|
||||||
- Mehrere Hetzner-Domains mit getrennten, verschlüsselten API-Tokens
|
Die Datei [`compose.yml`](compose.yml) in Portainer, Dockge oder einem anderen Docker-Webinterface als Stack einfügen und starten.
|
||||||
- Import vorhandener A-/AAAA-Records und Anlegen neuer DynDNS-Hosts
|
|
||||||
- Reversibles Trennen von DynDNS-Zielen: Hetzner-Records bleiben bestehen und werden im lokalen Archiv angezeigt
|
|
||||||
- Globale, konfigurierbare IP-Prüfung mit HTTPS-Fallbackquellen
|
|
||||||
- Live-Countdown bis zum nächsten Scheduler-Lauf und manueller Sofortlauf für Admins
|
|
||||||
- Beschreibungen lokal und als Hetzner-Record-Kommentar
|
|
||||||
- Sichere Mehrbenutzerverwaltung:
|
|
||||||
- `Admin`: vollständige Zonen-, Token-, Updater- und Benutzerverwaltung
|
|
||||||
- `Mitglied`: ausschließlich explizit freigegebene Domains oder einzelne Subdomains
|
|
||||||
- SQLite-Datenvolume, Docker Secret für die Token-Verschlüsselung und kein externer Login-Dienst
|
|
||||||
|
|
||||||
## Schnellstart
|
Alternativ im ausgecheckten Repository:
|
||||||
|
|
||||||
1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p secrets
|
docker compose up -d
|
||||||
openssl rand -base64 32 > secrets/dyndns_master_key
|
|
||||||
chmod 700 secrets
|
|
||||||
chmod 644 secrets/dyndns_master_key
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Für LAN-Zugriff in `.env` `BIND_ADDRESS` auf die konkrete private IP des Docker-Hosts setzen, zum Beispiel `192.168.178.10`. Verwende im HTTP-LAN-Modus nie `0.0.0.0`.
|
Danach aufrufen:
|
||||||
|
|
||||||
Die Schlüsseldatei benötigt bei Docker Compose Leserechte für den unprivilegierten Containerprozess. Das Verzeichnis selbst bleibt deshalb auf dem Host mit Modus `700` geschützt; im Container läuft nur der nicht-root DynDNS-Prozess.
|
```text
|
||||||
|
http://IP-DES-DOCKER-HOSTS:9090
|
||||||
|
```
|
||||||
|
|
||||||
3. Starten:
|
Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch in getrennten Docker-Volumes angelegt. Es werden keine `.env`-Datei und kein manuell erzeugtes Secret benötigt.
|
||||||
|
|
||||||
|
## Das Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
dyndns:
|
||||||
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 20s
|
||||||
|
ports: ["9090:8080"]
|
||||||
|
volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
|
||||||
|
labels: {de.j-mahler.domain-control.role: app}
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
|
||||||
|
update-agent:
|
||||||
|
image: *image
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:10001"
|
||||||
|
command: [update-agent]
|
||||||
|
healthcheck: {disable: true}
|
||||||
|
network_mode: none
|
||||||
|
volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
pids_limit: 32
|
||||||
|
mem_limit: 64m
|
||||||
|
|
||||||
|
volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden.
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
|
||||||
|
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
|
||||||
|
|
||||||
|
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. 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.
|
||||||
|
|
||||||
|
`v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an.
|
||||||
|
|
||||||
|
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
|
||||||
|
|
||||||
|
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Öffne `http://<host-ip>:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token.
|
Compose ersetzt beide Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes. Vor einem späteren manuellen `docker compose up` muss der dort gepinnte Tag deshalb auf die gewünschte Version gesetzt werden. Meldet die WebUI **Compose aktualisieren**, unterstützt der vorhandene Agent den benötigten Vertrag nicht und beide Container werden auf diesem Weg gemeinsam aktualisiert.
|
||||||
|
|
||||||
## Produktivbetrieb
|
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.
|
||||||
|
|
||||||
Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut:
|
## Funktionen
|
||||||
|
|
||||||
```bash
|
- mehrere Domains und getrennt verschlüsselte Hetzner-API-Tokens
|
||||||
docker compose --env-file .env.production -f compose.production.yml pull
|
- automatischer Import vorhandener A-/AAAA-Records
|
||||||
docker compose --env-file .env.production -f compose.production.yml up -d
|
- DynDNS pro Subdomain und IP-Familie aktivierbar
|
||||||
```
|
- lokales Archiv statt Löschen von Hetzner-Records
|
||||||
|
- frei einstellbares Update-Intervall mit Countdown
|
||||||
|
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||||
|
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
||||||
|
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
||||||
|
- 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
|
||||||
|
|
||||||
Die vollständige Anleitung für Gitea Actions, HTTPS-Reverse-Proxy, Installation, Backups, Updates und Rollbacks steht in [PRODUCTION.md](PRODUCTION.md).
|
Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
|
||||||
|
|
||||||
## Sicherheit
|
Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`.
|
||||||
|
|
||||||
- Passwörter werden mit Argon2id gehasht.
|
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
|
||||||
- Hetzner-Tokens werden mit XChaCha20-Poly1305 verschlüsselt; der Schlüssel liegt als Docker Secret außerhalb des Datenvolumes.
|
|
||||||
- Cookies sind `HttpOnly` und `SameSite=Strict`; alle schreibenden Formulare verwenden CSRF-Tokens.
|
|
||||||
- Token-Anzeige verlangt die erneute Eingabe des Admin-Passworts und setzt `Cache-Control: no-store`.
|
|
||||||
- Unprivilegierte Nutzer sehen niemals API-Tokens, globale IP-Quellen, Update-Historie anderer Domains oder Benutzerverwaltung.
|
|
||||||
|
|
||||||
`APP_MODE=lan` ist nur für ein isoliertes, vertrauenswürdiges Netz geeignet. Passwort und API-Token sind bei HTTP auf einem fremden oder geteilten Netz nicht gegen Mitlesen geschützt. Für WLAN mit Gästen oder externen Zugriff setze einen HTTPS-Reverse-Proxy vor den Container und verwende `APP_MODE=https`.
|
|
||||||
|
|
||||||
## Berechtigungen
|
|
||||||
|
|
||||||
Ein Admin legt unter **Benutzer** Konten an und entscheidet pro Mitglied:
|
|
||||||
|
|
||||||
- **Ganze Domain**: Zugriff auf alle aktuellen und später importierten/angelegten DynDNS-Ziele dieser Zone.
|
|
||||||
- **Einzelne Subdomain**: Zugriff nur auf den konkreten Host, etwa `home.example.de`.
|
|
||||||
|
|
||||||
Mitglieder dürfen ihren freigegebenen Host aktivieren/deaktivieren und dessen Beschreibung pflegen. Sie können keine DNS-Records löschen, Domains importieren, API-Tokens einsehen oder globale Einstellungen verändern.
|
|
||||||
|
|
||||||
## Daten und Betrieb
|
|
||||||
|
|
||||||
Die persistente Datenbank liegt im Docker-Volume `dyndns-data`. Sichere für Backups sowohl das Volume als auch das separate Master-Key-Secret; ohne den Master-Key lassen sich gespeicherte Hetzner-Tokens absichtlich nicht wiederherstellen.
|
|
||||||
|
|
||||||
Der Container bietet `/healthz` und `/readyz`. Für eine Aktualisierung ohne Warten auf das Intervall gibt es im Admin-Dashboard **Jetzt aktualisieren**.
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
|
Die aktuelle Compose-Version ist `v1.4.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. Direkte Sprünge über mehrere Versionen sind möglich, wenn
|
||||||
|
das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese
|
||||||
|
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
||||||
|
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
|
||||||
|
|
||||||
|
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
|
||||||
|
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
||||||
|
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
||||||
|
eigene Versionsnummer älter als die der Web-App ist. Erscheint **Compose
|
||||||
|
aktualisieren**, benötigt das Ziel einen neueren Agent-Vertrag; dann beide
|
||||||
|
Container wie unten beschrieben gemeinsam aktualisieren.
|
||||||
|
|
||||||
|
## Mit Docker Compose
|
||||||
|
|
||||||
|
In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
dyndns:
|
||||||
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0
|
||||||
|
update-agent:
|
||||||
|
image: *image
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach im Ordner der Compose-Datei ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Web-App und der Update-Agent verwenden durch den YAML-Anker dieselbe
|
||||||
|
Version. Für das Update ist kein `docker compose down` nötig. Insbesondere
|
||||||
|
niemals `docker compose down -v` verwenden, da `-v` die Daten- und
|
||||||
|
Secret-Volumes löschen würde.
|
||||||
|
|
||||||
|
Der Ein-Klick-Updater kann die auf dem Host liegende `compose.yml` nicht
|
||||||
|
verändern. Vor jedem späteren manuellen Compose-Lauf daher zuerst den gepinnten
|
||||||
|
Tag auf die gewünschte Version setzen, damit Compose keine ältere Version
|
||||||
|
wiederherstellt.
|
||||||
|
|
||||||
|
Der Healthcheck lässt sich anschließend direkt prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail http://127.0.0.1:9090/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Die erwartete Antwort lautet `ok`.
|
||||||
|
|
||||||
|
## Portainer oder Dockge
|
||||||
|
|
||||||
|
1. Den bestehenden Stack bearbeiten.
|
||||||
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.4.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 --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und
|
||||||
|
`dyndns-secrets` gemeinsam gesichert werden. Nur zusammen enthalten sie sowohl
|
||||||
|
die Datenbank als auch den zur Entschlüsselung benötigten Master-Key.
|
||||||
+65
-4
@@ -7,36 +7,72 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"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"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
command := commandForInvocation(os.Args)
|
||||||
|
if command == "healthcheck" {
|
||||||
os.Exit(healthcheck())
|
os.Exit(healthcheck())
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
if err := run(logger); err != nil {
|
var err error
|
||||||
|
switch command {
|
||||||
|
case "serve":
|
||||||
|
err = run(logger)
|
||||||
|
case "update-agent":
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
err = updateagent.Serve(ctx, updateagent.ServerConfigFromEnvironment(), logger)
|
||||||
|
default:
|
||||||
|
err = errors.New("unknown command; expected serve, healthcheck, or update-agent")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
logger.Error("application stopped", "error", err)
|
logger.Error("application stopped", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func commandForInvocation(arguments []string) string {
|
||||||
|
if len(arguments) == 0 {
|
||||||
|
return "invalid"
|
||||||
|
}
|
||||||
|
// v1.3 Compose files override the agent entrypoint and inherit the image's
|
||||||
|
// default "serve" command. Keep that one-time manual upgrade path working
|
||||||
|
// while the image itself still contains only one executable.
|
||||||
|
if filepath.Base(arguments[0]) == "update-agent" && (len(arguments) == 1 || (len(arguments) == 2 && arguments[1] == "serve")) {
|
||||||
|
return "update-agent"
|
||||||
|
}
|
||||||
|
if len(arguments) == 1 {
|
||||||
|
return "serve"
|
||||||
|
}
|
||||||
|
if len(arguments) == 2 {
|
||||||
|
return arguments[1]
|
||||||
|
}
|
||||||
|
return "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
func run(logger *slog.Logger) error {
|
||||||
|
// Credentials and session data must never inherit a permissive process umask.
|
||||||
|
syscall.Umask(0o077)
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cipher, err := secure.LoadCipher(cfg.MasterKeyFile)
|
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -51,6 +87,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
|
||||||
@@ -63,7 +103,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
|
||||||
}
|
}
|
||||||
@@ -71,6 +111,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(),
|
||||||
@@ -78,6 +120,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() {
|
||||||
@@ -98,6 +141,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,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCommandForInvocationKeepsV13ComposeAgentCompatible(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
arguments []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{arguments: []string{"/usr/local/bin/domain-control"}, want: "serve"},
|
||||||
|
{arguments: []string{"/usr/local/bin/domain-control", "update-agent"}, want: "update-agent"},
|
||||||
|
{arguments: []string{"/usr/local/bin/domain-control", "healthcheck"}, want: "healthcheck"},
|
||||||
|
{arguments: []string{"/usr/local/bin/update-agent"}, want: "update-agent"},
|
||||||
|
{arguments: []string{"/usr/local/bin/update-agent", "serve"}, want: "update-agent"},
|
||||||
|
{arguments: []string{"/usr/local/bin/domain-control", "serve", "extra"}, want: "invalid"},
|
||||||
|
{arguments: nil, want: "invalid"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := commandForInvocation(test.arguments); got != test.want {
|
||||||
|
t.Errorf("commandForInvocation(%q) = %q; want %q", test.arguments, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: domain-control
|
|
||||||
|
|
||||||
services:
|
|
||||||
dyndns:
|
|
||||||
image: ${DYNDNS_IMAGE:-git.j-mahler.de/admin/domain-control-webinterface-prod}:${IMAGE_TAG:-v1.0.0}
|
|
||||||
restart: unless-stopped
|
|
||||||
init: true
|
|
||||||
stop_grace_period: 20s
|
|
||||||
ports:
|
|
||||||
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
|
|
||||||
environment:
|
|
||||||
APP_MODE: ${APP_MODE:-https}
|
|
||||||
APP_ADDRESS: :8080
|
|
||||||
APP_DATA_DIR: /data
|
|
||||||
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
|
|
||||||
volumes:
|
|
||||||
- dyndns-data:/data
|
|
||||||
secrets:
|
|
||||||
- dyndns_master_key
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:rw,noexec,nosuid,size=16m
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: 10m
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
dyndns_master_key:
|
|
||||||
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
dyndns-data:
|
|
||||||
name: domain-control-data
|
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
dyndns:
|
||||||
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 20s
|
||||||
|
ports: ["9090:8080"]
|
||||||
|
volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"]
|
||||||
|
labels: {de.j-mahler.domain-control.role: app}
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
|
||||||
|
update-agent:
|
||||||
|
image: *image
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "0:10001"
|
||||||
|
command: [update-agent]
|
||||||
|
healthcheck: {disable: true}
|
||||||
|
network_mode: none
|
||||||
|
volumes: ["/var/run/docker.sock:/var/run/docker.sock", "dyndns-update-control:/run/domain-control-update"]
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777"]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
pids_limit: 32
|
||||||
|
mem_limit: 64m
|
||||||
|
|
||||||
|
volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
dyndns:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
image: local/hetzner-dyndns-dashboard:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Use one explicit RFC1918 host address for LAN access, never 0.0.0.0 in HTTP mode.
|
|
||||||
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
|
|
||||||
environment:
|
|
||||||
APP_MODE: ${APP_MODE:-lan}
|
|
||||||
APP_ADDRESS: :8080
|
|
||||||
APP_DATA_DIR: /data
|
|
||||||
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
|
|
||||||
volumes:
|
|
||||||
- dyndns-data:/data
|
|
||||||
secrets:
|
|
||||||
- dyndns_master_key
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:rw,noexec,nosuid,size=16m
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
dyndns_master_key:
|
|
||||||
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
dyndns-data:
|
|
||||||
@@ -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,375 @@
|
|||||||
|
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
|
||||||
|
AgentCompatible bool
|
||||||
|
AgentStatus updateprotocol.Status
|
||||||
|
AgentVersion string
|
||||||
|
AgentProtocol int
|
||||||
|
ComposeSyncRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
store *store.Store
|
||||||
|
cipher *secure.Cipher
|
||||||
|
logger *slog.Logger
|
||||||
|
apiURL string
|
||||||
|
interval time.Duration
|
||||||
|
agent *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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
settings = manager.reconcileInstalledVersion(context.Background(), settings)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if !state.AgentAvailable {
|
||||||
|
return errors.New("Update-Agent ist nicht erreichbar")
|
||||||
|
}
|
||||||
|
if !state.AgentCompatible {
|
||||||
|
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
||||||
|
}
|
||||||
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
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, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
|
||||||
|
if err := m.agent.Start(ctx, request); err != nil {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
m.clearDesiredVersion(cleanupCtx, version)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err == nil {
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
|
||||||
|
status, err := m.agent.Status(ctx)
|
||||||
|
m.stateMu.Lock()
|
||||||
|
desiredVersion := m.state.DesiredVersion
|
||||||
|
m.state.AgentAvailable = err == nil
|
||||||
|
if err == nil {
|
||||||
|
m.state.AgentStatus = status
|
||||||
|
m.state.AgentVersion = status.AgentVersion
|
||||||
|
m.state.AgentProtocol = status.Protocol
|
||||||
|
m.state.AgentCompatible = agentCompatible(status)
|
||||||
|
m.state.ComposeSyncRequired = !m.state.AgentCompatible
|
||||||
|
} else {
|
||||||
|
m.state.AgentCompatible = false
|
||||||
|
m.state.ComposeSyncRequired = false
|
||||||
|
}
|
||||||
|
m.stateMu.Unlock()
|
||||||
|
if err == nil && status.Phase == updateprotocol.PhaseFailed && status.TargetVersion != "" {
|
||||||
|
m.clearDesiredVersion(ctx, status.TargetVersion)
|
||||||
|
} else if err == nil && status.Phase == updateprotocol.PhaseIdle && desiredVersion != "" {
|
||||||
|
// An agent restart intentionally resets its in-memory queue. Do not leave
|
||||||
|
// an update marked as pending when no update can still be running.
|
||||||
|
m.clearDesiredVersion(ctx, desiredVersion)
|
||||||
|
}
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchLatest(ctx context.Context, 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.AgentCompatible = m.state.AgentCompatible
|
||||||
|
state.AgentStatus = m.state.AgentStatus
|
||||||
|
state.AgentVersion = m.state.AgentVersion
|
||||||
|
state.AgentProtocol = m.state.AgentProtocol
|
||||||
|
state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible
|
||||||
|
m.state = state
|
||||||
|
m.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) reconcileInstalledVersion(ctx context.Context, settings model.SoftwareUpdateSettings) model.SoftwareUpdateSettings {
|
||||||
|
if settings.DesiredVersion == "" {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
comparison, err := compareVersions(buildinfo.Version, settings.DesiredVersion)
|
||||||
|
if err != nil || comparison < 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
if err := m.store.ClearDesiredSoftwareVersion(ctx, settings.DesiredVersion); err != nil {
|
||||||
|
m.logger.Warn("clear completed software update state", "error", err)
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
settings.DesiredVersion = ""
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) clearDesiredVersion(ctx context.Context, expected string) {
|
||||||
|
if expected == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.store.ClearDesiredSoftwareVersion(ctx, expected); err != nil {
|
||||||
|
m.logger.Warn("clear stale software update state", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("refresh software update state", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.setStoredState(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentCompatible(status updateprotocol.Status) bool {
|
||||||
|
if status.Protocol < buildinfo.MinimumAgentProtocol || status.Protocol > buildinfo.UpdateProtocolVersion {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
comparison, err := compareVersions(status.AgentVersion, buildinfo.MinimumAgentVersion)
|
||||||
|
return err == nil && comparison >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) refreshAgentStatus(ctx context.Context) {
|
||||||
|
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = m.UpdateStatus(statusCtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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/buildinfo"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.4.1","html_url":"https://example.invalid/releases/v1.4.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.4.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.4.1" || state.LastError == "" {
|
||||||
|
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status updateprotocol.Status
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "v1.4 baseline", status: updateprotocol.Status{AgentVersion: "v1.4.0", Protocol: 2}, want: true},
|
||||||
|
{name: "newer compatible agent version", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion}, want: true},
|
||||||
|
{name: "legacy protocol", status: updateprotocol.Status{Protocol: 0}},
|
||||||
|
{name: "old agent version", status: updateprotocol.Status{AgentVersion: "v1.3.0", Protocol: 2}},
|
||||||
|
{name: "future unknown protocol", status: updateprotocol.Status{AgentVersion: "v2.0.0", Protocol: buildinfo.UpdateProtocolVersion + 1}},
|
||||||
|
{name: "missing version", status: updateprotocol.Status{Protocol: 2}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := agentCompatible(test.status); got != test.want {
|
||||||
|
t.Fatalf("agentCompatible(%#v) = %v; want %v", test.status, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
if err := database.SetDesiredSoftwareVersion(ctx, buildinfo.Version); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cipher, err := secure.LoadCipher(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := New(database, cipher, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if manager.State().DesiredVersion != "" {
|
||||||
|
t.Fatalf("installed version remained pending: %#v", manager.State())
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.DesiredVersion != "" {
|
||||||
|
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
|
||||||
|
func compareVersions(left, right string) (int, error) {
|
||||||
|
return updateprotocol.CompareVersions(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validReleaseVersion(version string) bool {
|
||||||
|
if !updateprotocol.ValidVersion(version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := updateprotocol.CompareVersions(version, version)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package appupdate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{left: "v1.2.2", right: "v1.2.3", want: -1},
|
||||||
|
{left: "v1.3.0", right: "v2.0.0", want: -1},
|
||||||
|
{left: "v2.0.0", right: "v1.99.99", want: 1},
|
||||||
|
{left: "v1.2.2", right: "v1.2.2", want: 0},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
got, err := compareVersions(test.left, test.right)
|
||||||
|
if err != nil || got != test.want {
|
||||||
|
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
|
||||||
|
if validReleaseVersion(invalid) {
|
||||||
|
t.Errorf("invalid release version accepted: %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package buildinfo
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UpdateProtocolVersion is returned by the privileged update agent. Version
|
||||||
|
// 2 requires the running version so the agent can verify it independently.
|
||||||
|
UpdateProtocolVersion = 2
|
||||||
|
|
||||||
|
// The contracts below are deliberately stable across normal releases. A
|
||||||
|
// breaking container or database change must use a new contract and cannot
|
||||||
|
// be installed through the unattended update path.
|
||||||
|
RuntimeContractVersion = 2
|
||||||
|
DatabaseContractVersion = 1
|
||||||
|
CurrentSchemaVersion = 11
|
||||||
|
MinimumUpdateVersion = "v1.4.0"
|
||||||
|
MinimumAgentVersion = "v1.4.0"
|
||||||
|
MinimumAgentProtocol = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is replaced through -ldflags for release images. The source fallback
|
||||||
|
// keeps local builds and tests honest about the currently checked-out release.
|
||||||
|
var Version = "v1.4.0"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package buildinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerfileMetadataMatchesRuntimeContracts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
content, err := os.ReadFile("../../Dockerfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dockerfile := string(content)
|
||||||
|
wanted := []string{
|
||||||
|
"ARG VERSION=" + Version,
|
||||||
|
"ARG MINIMUM_UPDATE_VERSION=" + MinimumUpdateVersion,
|
||||||
|
"ARG MINIMUM_AGENT_PROTOCOL=" + strconv.Itoa(MinimumAgentProtocol),
|
||||||
|
"ARG RUNTIME_CONTRACT_VERSION=" + strconv.Itoa(RuntimeContractVersion),
|
||||||
|
"ARG DATABASE_CONTRACT_VERSION=" + strconv.Itoa(DatabaseContractVersion),
|
||||||
|
"ARG SCHEMA_VERSION=" + strconv.Itoa(CurrentSchemaVersion),
|
||||||
|
}
|
||||||
|
for _, line := range wanted {
|
||||||
|
if !strings.Contains(dockerfile, line+"\n") {
|
||||||
|
t.Errorf("Dockerfile is missing %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versionLinkerFlag := "-X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}"
|
||||||
|
if count := strings.Count(dockerfile, versionLinkerFlag); count != 1 {
|
||||||
|
t.Fatalf("release version must be linked into the control binary, found %d flags", count)
|
||||||
|
}
|
||||||
|
if !strings.Contains(dockerfile, "ln -s domain-control /usr/local/bin/update-agent") {
|
||||||
|
t.Fatal("Dockerfile is missing the v1.3 Compose compatibility symlink")
|
||||||
|
}
|
||||||
|
for path, marker := range map[string]string{
|
||||||
|
"../../compose.yml": "domain-control-webinterface-prod:" + Version,
|
||||||
|
"../../README.md": "domain-control-webinterface-prod:" + Version,
|
||||||
|
"../../UPDATE.md": "Compose-Version ist `" + Version + "`",
|
||||||
|
} {
|
||||||
|
data, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), marker) {
|
||||||
|
t.Errorf("%s is not synchronized with %s", path, Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package config
|
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) {
|
||||||
@@ -50,9 +55,14 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
|
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keyFile := os.Getenv("APP_MASTER_KEY_FILE")
|
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
|
||||||
if keyFile == "" {
|
updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour)
|
||||||
return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)")
|
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"
|
||||||
@@ -74,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
|
||||||
|
|||||||
@@ -72,6 +72,51 @@ type CNAMERecord struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PiHoleIntegration is one remotely managed Pi-hole v6 instance. API
|
||||||
|
// passwords are returned separately and never exposed to templates.
|
||||||
|
type PiHoleIntegration struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
InsecureTLS bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
LastSyncAt time.Time
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PiHoleCredential struct {
|
||||||
|
Ciphertext []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
|
||||||
|
// merely retained at Pi-hole after being removed from the active WebUI list.
|
||||||
|
type PiHoleRecordLink struct {
|
||||||
|
ID int64
|
||||||
|
IntegrationID int64
|
||||||
|
Active bool
|
||||||
|
Value string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
ArchivedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package pihole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxResponseBody = 1 << 20
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Key string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
message := strings.TrimSpace(e.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = http.StatusText(e.Status)
|
||||||
|
}
|
||||||
|
if e.Key != "" {
|
||||||
|
return fmt.Sprintf("Pi-hole API %d (%s): %s", e.Status, e.Key, message)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Pi-hole API %d: %s", e.Status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(rawURL string, insecureTLS bool) (*Client, error) {
|
||||||
|
baseURL, err := normalizeBaseURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: nil,
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
InsecureSkipVerify: insecureTLS, // Explicit admin opt-in for self-signed Pi-hole certificates.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
http: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return errors.New("Pi-hole API redirects are not allowed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
|
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") {
|
||||||
|
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return "", errors.New("Pi-hole-Adresse darf keine Zugangsdaten, Query-Parameter oder Fragmente enthalten")
|
||||||
|
}
|
||||||
|
if parsed.Path == "" || parsed.Path == "/" {
|
||||||
|
parsed.Path = "/api"
|
||||||
|
} else {
|
||||||
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||||
|
if !strings.HasSuffix(parsed.Path, "/api") {
|
||||||
|
parsed.Path += "/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.RawPath = ""
|
||||||
|
return strings.TrimRight(parsed.String(), "/"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) BaseURL() string { return c.baseURL }
|
||||||
|
|
||||||
|
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
|
||||||
|
var hosts []string
|
||||||
|
err := c.withSession(ctx, password, func(sid string) error {
|
||||||
|
var response struct {
|
||||||
|
Config struct {
|
||||||
|
DNS struct {
|
||||||
|
Hosts []string `json:"hosts"`
|
||||||
|
} `json:"dns"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, "/config/dns/hosts", sid, nil, &response, http.StatusOK); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hosts = append([]string(nil), response.Config.DNS.Hosts...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return hosts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddHost(ctx context.Context, password, value string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
return c.addHost(ctx, sid, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
return c.deleteHost(ctx, sid, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHosts removes a complete set of local DNS entries in one authenticated
|
||||||
|
// Pi-hole session. Missing values are accepted so an interrupted deletion can
|
||||||
|
// safely be retried.
|
||||||
|
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
|
||||||
|
return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacement string) error {
|
||||||
|
if previous == replacement {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.withSession(ctx, password, func(sid string) error {
|
||||||
|
if err := c.deleteHost(ctx, sid, previous); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.addHost(ctx, sid, replacement); err != nil {
|
||||||
|
if rollbackErr := c.addHost(ctx, sid, previous); rollbackErr != nil {
|
||||||
|
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen: %v; Wiederherstellung ebenfalls fehlgeschlagen: %w", err, rollbackErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen; ursprünglicher Eintrag wurde wiederhergestellt: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) addHost(ctx context.Context, sid, value string) error {
|
||||||
|
return c.doJSON(ctx, http.MethodPut, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deleteHost(ctx context.Context, sid, value string) error {
|
||||||
|
return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error {
|
||||||
|
sid, err := c.authenticate(ctx, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
|
||||||
|
}()
|
||||||
|
return operation(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
|
||||||
|
var response struct {
|
||||||
|
Session struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"session"`
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{Password: password}
|
||||||
|
if err := c.doJSON(ctx, http.MethodPost, "/auth", "", payload, &response, http.StatusOK); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !response.Session.Valid || strings.TrimSpace(response.Session.SID) == "" {
|
||||||
|
message := strings.TrimSpace(response.Session.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "Pi-hole hat keine gültige API-Session erstellt"
|
||||||
|
}
|
||||||
|
return "", errors.New(message)
|
||||||
|
}
|
||||||
|
return response.Session.SID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, path, sid string, requestBody, responseBody any, accepted ...int) error {
|
||||||
|
var body io.Reader
|
||||||
|
if requestBody != nil {
|
||||||
|
encoded, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "domain-control-webinterface/1")
|
||||||
|
if requestBody != nil {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if sid != "" {
|
||||||
|
request.Header.Set("X-FTL-SID", sid)
|
||||||
|
}
|
||||||
|
response, err := c.http.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Pi-hole API nicht erreichbar: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
content, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, status := range accepted {
|
||||||
|
if response.StatusCode == status {
|
||||||
|
if responseBody != nil && len(content) > 0 {
|
||||||
|
if err := json.Unmarshal(content, responseBody); err != nil {
|
||||||
|
return fmt.Errorf("ungültige Antwort der Pi-hole API: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var apiResponse struct {
|
||||||
|
Error struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(content, &apiResponse)
|
||||||
|
return &APIError{Status: response.StatusCode, Key: apiResponse.Error.Key, Message: apiResponse.Error.Message}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package pihole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientManagesLocalDNSHostsWithShortLivedSessions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var mu sync.Mutex
|
||||||
|
hosts := []string{"192.168.1.10 nas.lan"}
|
||||||
|
logouts := 0
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/auth" && r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"test-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/api/auth" && r.Method == http.MethodDelete {
|
||||||
|
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||||
|
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
logouts++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-FTL-SID") != "test-sid" {
|
||||||
|
http.Error(w, "missing sid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["` + strings.Join(hosts, `","`) + `"]}}}`))
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodPut:
|
||||||
|
hosts = append(hosts, strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/"))
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodDelete:
|
||||||
|
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
||||||
|
for index, host := range hosts {
|
||||||
|
if host == value {
|
||||||
|
hosts = append(hosts[:index], hosts[index+1:]...)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
client, err := New(api.URL, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
listed, err := client.ListHosts(ctx, "app-password")
|
||||||
|
if err != nil || len(listed) != 1 || listed[0] != "192.168.1.10 nas.lan" {
|
||||||
|
t.Fatalf("hosts not listed: %#v err=%v", listed, err)
|
||||||
|
}
|
||||||
|
if err := client.AddHost(ctx, "app-password", "192.168.1.20 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.ReplaceHost(ctx, "app-password", "192.168.1.20 printer.lan", "192.168.1.21 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteHost(ctx, "app-password", "192.168.1.21 printer.lan"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteHosts(ctx, "app-password", []string{"192.168.1.10 nas.lan", "192.168.1.99 already-gone.lan"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if logouts != 5 {
|
||||||
|
t.Fatalf("expected one logout per operation, got %d", logouts)
|
||||||
|
}
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Fatalf("batch deletion left hosts behind: %#v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesAndNormalizesPiHoleURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, err := New("https://pi.hole/", false)
|
||||||
|
if err != nil || client.BaseURL() != "https://pi.hole/api" {
|
||||||
|
t.Fatalf("unexpected normalized URL %q err=%v", client.BaseURL(), err)
|
||||||
|
}
|
||||||
|
client, err = New("http://pihole.local/custom", false)
|
||||||
|
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
|
||||||
|
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"} {
|
||||||
|
if _, err := New(invalid, false); err == nil {
|
||||||
|
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{}
|
||||||
|
|||||||
+105
-4
@@ -9,7 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"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"
|
||||||
@@ -20,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 {
|
||||||
@@ -27,10 +37,70 @@ type Cipher struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadCipher(path string) (*Cipher, error) {
|
func LoadCipher(path string) (*Cipher, error) {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect master key: %w", err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil, errors.New("master key must be a regular file")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("secure master key: %w", err)
|
||||||
|
}
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read master key: %w", err)
|
return nil, fmt.Errorf("read master key: %w", err)
|
||||||
}
|
}
|
||||||
|
return cipherFromKey(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOrCreateCipher loads an existing master key or creates one on first start.
|
||||||
|
// The key must live on a persistent volume separate from the application data.
|
||||||
|
func LoadOrCreateCipher(path string) (*Cipher, error) {
|
||||||
|
directory := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create master key directory: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(directory)
|
||||||
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil, errors.New("master key directory must be a regular directory")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(directory, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("secure master key directory: %w", err)
|
||||||
|
}
|
||||||
|
cipher, err := LoadCipher(path)
|
||||||
|
if err == nil {
|
||||||
|
return cipher, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := make([]byte, chacha20poly1305.KeySize)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("create master key: %w", err)
|
||||||
|
}
|
||||||
|
encoded := []byte(base64.RawStdEncoding.EncodeToString(key))
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
return LoadCipher(path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create master key file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write(encoded); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil, fmt.Errorf("write master key: %w", err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil, fmt.Errorf("close master key file: %w", err)
|
||||||
|
}
|
||||||
|
return &Cipher{key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cipherFromKey(raw []byte) (*Cipher, error) {
|
||||||
raw = []byte(strings.TrimSpace(string(raw)))
|
raw = []byte(strings.TrimSpace(string(raw)))
|
||||||
key, err := base64.RawStdEncoding.DecodeString(string(raw))
|
key, err := base64.RawStdEncoding.DecodeString(string(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,8 +156,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 {
|
||||||
@@ -99,6 +191,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
|
||||||
@@ -109,12 +204,18 @@ func VerifyPassword(encoded, password string) bool {
|
|||||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +42,58 @@ func TestCipherRoundTripAndFingerprint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := filepath.Join(t.TempDir(), "secrets", "master_key")
|
||||||
|
first, err := LoadOrCreateCipher(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if permissions := info.Mode().Perm(); permissions != 0o600 {
|
||||||
|
t.Fatalf("master key permissions = %o, want 600", permissions)
|
||||||
|
}
|
||||||
|
directoryInfo, err := os.Stat(filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if permissions := directoryInfo.Mode().Perm(); permissions != 0o700 {
|
||||||
|
t.Fatalf("master key directory permissions = %o, want 700", permissions)
|
||||||
|
}
|
||||||
|
ciphertext, nonce, err := first.Encrypt("persistent token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := LoadOrCreateCipher(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plaintext, err := second.Decrypt(ciphertext, nonce)
|
||||||
|
if err != nil || plaintext != "persistent token" {
|
||||||
|
t.Fatalf("created key was not reused: plaintext=%q err=%v", plaintext, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCipherRejectsSymlinkedMasterKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
target := filepath.Join(directory, "target")
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if err := os.WriteFile(target, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(directory, "master_key")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadCipher(link); err == nil {
|
||||||
|
t.Fatal("symlinked master key was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordHash(t *testing.T) {
|
func TestPasswordHash(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
hash, err := HashPassword("a secure password")
|
hash, err := HashPassword("a secure password")
|
||||||
@@ -53,7 +106,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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+539
-10
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,13 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
Zone model.Zone
|
Zone model.Zone
|
||||||
@@ -36,9 +44,13 @@ type AccessSelection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Open(ctx context.Context, path string) (*Store, error) {
|
func Open(ctx context.Context, path string) (*Store, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
dataDir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||||
return nil, fmt.Errorf("create data directory: %w", err)
|
return nil, fmt.Errorf("create data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(dataDir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("secure data directory: %w", err)
|
||||||
|
}
|
||||||
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on")
|
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open database: %w", err)
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
@@ -48,6 +60,10 @@ func Open(ctx context.Context, path string) (*Store, error) {
|
|||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("secure database file: %w", err)
|
||||||
|
}
|
||||||
store := &Store{db: db}
|
store := &Store{db: db}
|
||||||
if err := store.migrate(ctx); err != nil {
|
if err := store.migrate(ctx); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
@@ -97,10 +113,29 @@ func (s *Store) migrate(ctx context.Context) error {
|
|||||||
if err := s.applyMigrationV7(ctx, tx); err != nil {
|
if err := s.applyMigrationV7(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.applyMigrationV8(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyMigrationV9(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyMigrationV10(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.applyMigrationV11(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
|
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
|
||||||
}
|
}
|
||||||
|
var appliedMigrations int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&appliedMigrations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if appliedMigrations != buildinfo.CurrentSchemaVersion {
|
||||||
|
return fmt.Errorf("database migration chain is incomplete: have %d of %d", appliedMigrations, buildinfo.CurrentSchemaVersion)
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +207,74 @@ func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV8(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 8`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV8); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v8: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(8, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV9(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 9`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV9); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v9: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(9, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 10`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, schemaV10); err != nil {
|
||||||
|
return fmt.Errorf("apply schema v10: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(10, ?)`, now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
@@ -351,6 +454,54 @@ const schemaV7 = `
|
|||||||
ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const schemaV8 = `
|
||||||
|
CREATE TABLE pihole_integrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
password_ciphertext BLOB NOT NULL,
|
||||||
|
password_nonce BLOB NOT NULL,
|
||||||
|
insecure_tls INTEGER NOT NULL DEFAULT 0 CHECK (insecure_tls IN (0,1)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const schemaV9 = `
|
||||||
|
CREATE TABLE pihole_record_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
integration_id INTEGER NOT NULL REFERENCES pihole_integrations(id) ON DELETE CASCADE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
archived_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(integration_id, value)
|
||||||
|
);
|
||||||
|
CREATE INDEX pihole_record_links_integration_archived_idx ON pihole_record_links(integration_id, archived_at);
|
||||||
|
`
|
||||||
|
|
||||||
|
const schemaV10 = `
|
||||||
|
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
|
||||||
|
`
|
||||||
|
|
||||||
|
const schemaV11 = `
|
||||||
|
CREATE TABLE software_update_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
registry_username TEXT NOT NULL DEFAULT '',
|
||||||
|
token_ciphertext BLOB NOT NULL DEFAULT X'',
|
||||||
|
token_nonce BLOB NOT NULL DEFAULT X'',
|
||||||
|
latest_version TEXT NOT NULL DEFAULT '',
|
||||||
|
release_url TEXT NOT NULL DEFAULT '',
|
||||||
|
desired_version TEXT NOT NULL DEFAULT '',
|
||||||
|
last_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
|
||||||
|
`
|
||||||
|
|
||||||
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
|
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)
|
||||||
@@ -358,9 +509,21 @@ 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) {
|
||||||
var hash string
|
var hash string
|
||||||
@@ -379,6 +542,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))
|
||||||
}
|
}
|
||||||
@@ -429,18 +611,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
|
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 {
|
||||||
@@ -485,6 +715,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
|
||||||
}
|
}
|
||||||
@@ -556,7 +793,7 @@ func (s *Store) ListAccessibleTargets(ctx context.Context, userID, zoneID int64)
|
|||||||
rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at
|
rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at
|
||||||
FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS (
|
FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS (
|
||||||
SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id)
|
SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id)
|
||||||
) ORDER BY CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
|
) ORDER BY t.active DESC, CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -600,8 +837,48 @@ 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) ClearDesiredSoftwareVersion(ctx context.Context, expected string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = '', updated_at = ? WHERE id = 1 AND desired_version = ?`, now(), expected)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
|
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `UPDATE settings SET 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,8 +968,209 @@ func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) (model.PiHoleIntegration, error) {
|
||||||
|
timestamp := now()
|
||||||
|
result, err := s.db.ExecContext(ctx, `INSERT INTO pihole_integrations(name, base_url, password_ciphertext, password_nonce, insecure_tls, created_at, updated_at)
|
||||||
|
SELECT ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM pihole_integrations)`,
|
||||||
|
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), timestamp, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return model.PiHoleIntegration{}, err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return model.PiHoleIntegration{}, ErrPiHoleAlreadyConfigured
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return model.PiHoleIntegration{}, err
|
||||||
|
}
|
||||||
|
return s.GetPiHoleIntegration(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleIntegration(ctx context.Context, id int64) (model.PiHoleIntegration, error) {
|
||||||
|
return scanPiHoleIntegration(s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE id = ?`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleIntegrationWithCredential(ctx context.Context, id int64) (model.PiHoleIntegration, model.PiHoleCredential, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error, password_ciphertext, password_nonce FROM pihole_integrations WHERE id = ?`, id)
|
||||||
|
var integration model.PiHoleIntegration
|
||||||
|
var credential model.PiHoleCredential
|
||||||
|
var insecureTLS int
|
||||||
|
var createdAt, updatedAt, lastSyncAt int64
|
||||||
|
err := row.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError, &credential.Ciphertext, &credential.Nonce)
|
||||||
|
integration.InsecureTLS = intBool(insecureTLS)
|
||||||
|
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
|
||||||
|
return integration, credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleIntegrations(ctx context.Context) ([]model.PiHoleIntegration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations ORDER BY name COLLATE NOCASE, id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var integrations []model.PiHoleIntegration
|
||||||
|
for rows.Next() {
|
||||||
|
integration, scanErr := scanPiHoleIntegration(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
integrations = append(integrations, integration)
|
||||||
|
}
|
||||||
|
return integrations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET name = ?, base_url = ?, password_ciphertext = ?, password_nonce = ?, insecure_tls = ?, updated_at = ?, last_error = '' WHERE id = ?`,
|
||||||
|
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), now(), integration.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count, _ := result.RowsAffected(); count != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkPiHoleSynced(ctx context.Context, id int64, lastError string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePiHoleIntegration(ctx context.Context, id int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_integrations WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count, _ := result.RowsAffected(); count != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SyncPiHoleRecordLinks(ctx context.Context, integrationID int64, remoteValues []string) ([]model.PiHoleRecordLink, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ?`, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing := make(map[string]model.PiHoleRecordLink)
|
||||||
|
for rows.Next() {
|
||||||
|
link, scanErr := scanPiHoleRecordLink(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
existing[link.Value] = link
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remote := make(map[string]bool, len(remoteValues))
|
||||||
|
for _, value := range remoteValues {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || remote[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remote[value] = true
|
||||||
|
if _, found := existing[value]; !found {
|
||||||
|
timestamp := now()
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at) VALUES(?, 1, ?, ?, ?)`, integrationID, value, timestamp, timestamp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value, link := range existing {
|
||||||
|
active := remote[value]
|
||||||
|
if link.Active != active {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), now(), link.ID, integrationID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.ListPiHoleRecordLinks(ctx, integrationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleRecordLinks(ctx context.Context, integrationID int64) ([]model.PiHoleRecordLink, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ? ORDER BY value COLLATE NOCASE, id`, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var links []model.PiHoleRecordLink
|
||||||
|
for rows.Next() {
|
||||||
|
link, scanErr := scanPiHoleRecordLink(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
return links, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPiHoleRecordLink(ctx context.Context, integrationID, linkID int64) (model.PiHoleRecordLink, error) {
|
||||||
|
return scanPiHoleRecordLink(s.db.QueryRowContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LinkPiHoleRecord(ctx context.Context, integrationID int64, value string, active bool) error {
|
||||||
|
timestamp := now()
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at, archived_at) VALUES(?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(integration_id, value) DO UPDATE SET active = excluded.active, updated_at = excluded.updated_at, archived_at = 0`, integrationID, boolInt(active), value, timestamp, timestamp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePiHoleRecordLink(ctx context.Context, integrationID, linkID int64, value string, active bool) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, value = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), value, now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ArchivePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RestorePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = 0, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) {
|
func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -792,9 +1270,19 @@ 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 {
|
||||||
result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = ?, last_status = 'Getrennt', last_error = '' WHERE id = ? AND archived_at = 0`, now(), id)
|
result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = ?, last_status = 'Getrennt', last_error = '' WHERE id = ? AND archived_at = 0`, now(), id)
|
||||||
@@ -819,7 +1307,7 @@ func (s *Store) RestoreTarget(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) {
|
func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1016,6 +1504,7 @@ func (s *Store) CountErrors(ctx context.Context) (int, error) {
|
|||||||
err := s.db.QueryRowContext(ctx, `SELECT
|
err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
(SELECT COUNT(*) FROM targets WHERE last_error != '') +
|
(SELECT COUNT(*) FROM targets WHERE last_error != '') +
|
||||||
(SELECT COUNT(*) FROM zones WHERE last_error != '') +
|
(SELECT COUNT(*) FROM zones WHERE last_error != '') +
|
||||||
|
(SELECT COUNT(*) FROM pihole_integrations WHERE last_error != '') +
|
||||||
(SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count)
|
(SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count)
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
@@ -1032,6 +1521,9 @@ func (s *Store) ClearErrors(ctx context.Context) error {
|
|||||||
if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil {
|
if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE pihole_integrations SET last_error = '' WHERE last_error != ''`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1536,23 @@ func (s *Store) ClearErrors(ctx context.Context) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListPiHoleIntegrationsWithErrors(ctx context.Context) ([]model.PiHoleIntegration, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE last_error != '' ORDER BY name COLLATE NOCASE`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var integrations []model.PiHoleIntegration
|
||||||
|
for rows.Next() {
|
||||||
|
integration, scanErr := scanPiHoleIntegration(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
integrations = append(integrations, integration)
|
||||||
|
}
|
||||||
|
return integrations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AddAudit(ctx context.Context, kind, message string) error {
|
func (s *Store) AddAudit(ctx context.Context, kind, message string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
|
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
|
||||||
return err
|
return err
|
||||||
@@ -1159,6 +1668,26 @@ func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) {
|
|||||||
return zone, err
|
return zone, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanPiHoleIntegration(scanner interface{ Scan(...any) error }) (model.PiHoleIntegration, error) {
|
||||||
|
var integration model.PiHoleIntegration
|
||||||
|
var insecureTLS int
|
||||||
|
var createdAt, updatedAt, lastSyncAt int64
|
||||||
|
err := scanner.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError)
|
||||||
|
integration.InsecureTLS = intBool(insecureTLS)
|
||||||
|
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
|
||||||
|
return integration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPiHoleRecordLink(scanner interface{ Scan(...any) error }) (model.PiHoleRecordLink, error) {
|
||||||
|
var link model.PiHoleRecordLink
|
||||||
|
var active int
|
||||||
|
var createdAt, updatedAt, archivedAt int64
|
||||||
|
err := scanner.Scan(&link.ID, &link.IntegrationID, &active, &link.Value, &createdAt, &updatedAt, &archivedAt)
|
||||||
|
link.Active = intBool(active)
|
||||||
|
link.CreatedAt, link.UpdatedAt, link.ArchivedAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(archivedAt)
|
||||||
|
return link, err
|
||||||
|
}
|
||||||
|
|
||||||
func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) {
|
func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) {
|
||||||
var target model.Target
|
var target model.Target
|
||||||
var updateIPv4, updateIPv6, active int
|
var updateIPv4, updateIPv6, active int
|
||||||
|
|||||||
@@ -2,14 +2,106 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
databasePath := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
legacy, err := sql.Open("sqlite3", databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := legacy.ExecContext(ctx, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := legacy.ExecContext(ctx, schemaV1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := legacy.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, 1)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := legacy.ExecContext(ctx, `INSERT INTO admins(id, password_hash, created_at, updated_at) VALUES(1, 'legacy-password-hash', 1, 1)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := legacy.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := Open(ctx, databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
var applied int
|
||||||
|
if err := database.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version BETWEEN 1 AND ?`, buildinfo.CurrentSchemaVersion).Scan(&applied); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if applied != buildinfo.CurrentSchemaVersion {
|
||||||
|
t.Fatalf("direct migration applied %d of %d schemas", applied, buildinfo.CurrentSchemaVersion)
|
||||||
|
}
|
||||||
|
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||||
|
if err != nil || admin.Role != "admin" || !admin.Active {
|
||||||
|
t.Fatalf("legacy administrator was not retained: %#v, %v", admin, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dataDir := filepath.Join(t.TempDir(), "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
databasePath := filepath.Join(dataDir, "app.db")
|
||||||
|
database, err := Open(ctx, databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
dirInfo, err := os.Stat(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dbInfo, err := os.Stat(databasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dirInfo.Mode().Perm() != 0o700 || dbInfo.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("insecure storage permissions: dir=%o database=%o", dirInfo.Mode().Perm(), dbInfo.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -45,10 +137,14 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
|||||||
if home.StaticIPv4 != "203.0.113.10" {
|
if home.StaticIPv4 != "203.0.113.10" {
|
||||||
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
t.Fatalf("static IPv4 was not persisted: %#v", home)
|
||||||
}
|
}
|
||||||
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60})
|
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "aaa", FQDN: "aaa.example.de", UpdateIPv4: true, TTL: 60})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
orderedTargets, err := database.ListTargets(ctx, zone.ID)
|
||||||
|
if err != nil || len(orderedTargets) != 2 || orderedTargets[0].ID != home.ID || orderedTargets[1].ID != other.ID {
|
||||||
|
t.Fatalf("active domain target was not sorted before inactive target: %#v err=%v", orderedTargets, err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -74,7 +170,7 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
|
||||||
if err != nil || len(targets) != 2 {
|
if err != nil || len(targets) != 2 || targets[0].ID != home.ID || targets[1].ID != other.ID {
|
||||||
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
|
||||||
}
|
}
|
||||||
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
|
||||||
@@ -136,6 +232,190 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hash, err := secure.HashPassword("a secure administrator password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin, _, err := database.GetUserByUsername(ctx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
|
||||||
|
t.Fatalf("last active administrator could be removed: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||||
|
t.Fatalf("administrator accepted editable individual grants: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
|
||||||
|
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
|
||||||
|
}
|
||||||
|
grants, err := database.ListAccessGrants(ctx, member.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(grants) != 0 {
|
||||||
|
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
|
||||||
|
}
|
||||||
|
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
|
||||||
|
t.Fatalf("promoted administrator accepted individual grants: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checkedAt := time.Now().UTC().Truncate(time.Second)
|
||||||
|
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
|
||||||
|
t.Fatalf("software update state was not persisted safely: %#v", settings)
|
||||||
|
}
|
||||||
|
if err := database.ClearDesiredSoftwareVersion(ctx, "v9.9.9"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err = database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil || settings.DesiredVersion != "v1.2.3" {
|
||||||
|
t.Fatalf("unrelated update cleared the pending target: %#v, %v", settings, err)
|
||||||
|
}
|
||||||
|
if err := database.ClearDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err = database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil || settings.DesiredVersion != "" {
|
||||||
|
t.Fatalf("matching completed target remained pending: %#v, %v", settings, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
created, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole Zuhause", BaseURL: "https://pi.hole/api", InsecureTLS: true}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Zweites Pi-hole", BaseURL: "https://second.pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")}); !errors.Is(err, ErrPiHoleAlreadyConfigured) {
|
||||||
|
t.Fatalf("second Pi-hole integration was not rejected: %v", err)
|
||||||
|
}
|
||||||
|
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, created.ID)
|
||||||
|
if err != nil || loaded.Name != "Pi-hole Zuhause" || loaded.BaseURL != "https://pi.hole/api" || !loaded.InsecureTLS || string(credential.Ciphertext) != "encrypted" {
|
||||||
|
t.Fatalf("Pi-hole integration not persisted: %#v %#v err=%v", loaded, credential, err)
|
||||||
|
}
|
||||||
|
loaded.Name = "Pi-hole Büro"
|
||||||
|
loaded.InsecureTLS = false
|
||||||
|
if err := database.UpdatePiHoleIntegration(ctx, loaded, model.PiHoleCredential{Ciphertext: []byte("new-encrypted"), Nonce: []byte("new-nonce")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.MarkPiHoleSynced(ctx, loaded.ID, "connection failed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||||
|
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole Büro" || integrations[0].LastError != "connection failed" || integrations[0].LastSyncAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole integration status not listed: %#v err=%v", integrations, err)
|
||||||
|
}
|
||||||
|
links, err := database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.20 nas.lan", "192.168.1.30 printer.lan"})
|
||||||
|
if err != nil || len(links) != 2 || !links[0].Active || !links[1].Active {
|
||||||
|
t.Fatalf("Pi-hole record links not synchronized: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
firstID, secondID := links[0].ID, links[1].ID
|
||||||
|
if err := database.ArchivePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||||
|
if err != nil || len(links) != 2 || links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record link not archived: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
if err := database.RestorePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.UpdatePiHoleRecordLink(ctx, loaded.ID, firstID, "192.168.1.21 nas.lan", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.DeletePiHoleRecordLink(ctx, loaded.ID, secondID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
|
||||||
|
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.21 nas.lan" || links[0].Active || !links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record link lifecycle failed: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.21 nas.lan"})
|
||||||
|
if err != nil || len(links) != 1 || !links[0].Active {
|
||||||
|
t.Fatalf("remote Pi-hole record did not become active: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, nil)
|
||||||
|
if err != nil || len(links) != 1 || links[0].Active {
|
||||||
|
t.Fatalf("missing remote Pi-hole record was deleted instead of retained as inactive: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
if err := database.DeletePiHoleIntegration(ctx, loaded.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.GetPiHoleIntegration(ctx, loaded.ID); err == nil {
|
||||||
|
t.Fatal("deleted Pi-hole integration still exists")
|
||||||
|
}
|
||||||
|
if links, err := database.ListPiHoleRecordLinks(ctx, loaded.ID); err != nil || len(links) != 0 {
|
||||||
|
t.Fatalf("Pi-hole record links were not removed with integration: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -160,6 +440,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)
|
||||||
}
|
}
|
||||||
@@ -167,6 +450,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)
|
||||||
}
|
}
|
||||||
@@ -227,6 +514,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)
|
||||||
}
|
}
|
||||||
@@ -263,7 +557,7 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "aaa", FQDN: "aaa.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -277,18 +571,21 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
|
|||||||
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "zzz", FQDN: "zzz.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
records, err := database.ListCNAMERecords(ctx, zone.ID)
|
||||||
if err != nil || len(records) != 2 {
|
if err != nil || len(records) != 2 {
|
||||||
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
|
||||||
}
|
}
|
||||||
|
if records[0].RecordName != "zzz" || records[1].RecordName != "aaa" {
|
||||||
|
t.Fatalf("active CNAME was not sorted before inactive CNAME: %#v", records)
|
||||||
|
}
|
||||||
byName := make(map[string]model.CNAMERecord, len(records))
|
byName := make(map[string]model.CNAMERecord, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
byName[record.RecordName] = record
|
byName[record.RecordName] = record
|
||||||
}
|
}
|
||||||
if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 {
|
if byName["aaa"].Active || !byName["zzz"].Active || byName["zzz"].Destination != "assets.example.net." || byName["zzz"].TTL != 120 {
|
||||||
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner interface {
|
||||||
|
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Controller struct {
|
||||||
|
runner Runner
|
||||||
|
logger *slog.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
status updateprotocol.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewController(runner Runner, logger *slog.Logger) *Controller {
|
||||||
|
return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentStatus(phase string) updateprotocol.Status {
|
||||||
|
return updateprotocol.Status{
|
||||||
|
Phase: phase,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
AgentVersion: buildinfo.Version,
|
||||||
|
Protocol: buildinfo.UpdateProtocolVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /v1/status", c.statusHandler)
|
||||||
|
mux.HandleFunc("POST /v1/update", c.updateHandler)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(c.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||||
|
defer r.Body.Close()
|
||||||
|
var request updateprotocol.Request
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&request); err != nil {
|
||||||
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Version = strings.TrimSpace(request.Version)
|
||||||
|
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
||||||
|
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
||||||
|
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
|
||||||
|
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
||||||
|
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !c.begin(request.Version) {
|
||||||
|
http.Error(w, "update already running", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
|
||||||
|
go c.run(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) begin(version string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.status.Busy() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion}
|
||||||
|
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,105 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const secret = "sensitive-registry-token"
|
||||||
|
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
|
||||||
|
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
|
||||||
|
t.Fatalf("credential was not redacted: %q", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingRunner struct {
|
||||||
|
started chan updateprotocol.Request
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
|
||||||
|
progress(updateprotocol.PhasePulling, "image is being pulled")
|
||||||
|
r.started <- request
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-r.release:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
|
||||||
|
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
handler := controller.Handler()
|
||||||
|
|
||||||
|
invalid := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
|
||||||
|
if invalid.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
|
||||||
|
}
|
||||||
|
trailing := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
|
||||||
|
if trailing.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
|
||||||
|
}
|
||||||
|
invalidCurrent := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(invalidCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"1.3","registry_username":"user","registry_token":"secret-token"}`)))
|
||||||
|
if invalidCurrent.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid current version accepted: status=%d", invalidCurrent.Code)
|
||||||
|
}
|
||||||
|
missingCurrent := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(missingCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","registry_username":"user","registry_token":"secret-token"}`)))
|
||||||
|
if missingCurrent.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = "registry-token-must-not-leak"
|
||||||
|
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||||
|
accepted := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||||
|
if accepted.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case request := <-runner.started:
|
||||||
|
if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret {
|
||||||
|
t.Fatalf("runner received an altered request: %#v", request)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("update runner did not start")
|
||||||
|
}
|
||||||
|
|
||||||
|
busy := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||||
|
if busy.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
|
||||||
|
}
|
||||||
|
status := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
||||||
|
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) || !strings.Contains(status.Body.String(), `"protocol_version":2`) {
|
||||||
|
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
close(runner.release)
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
if controller.Status().Phase != updateprotocol.PhaseComplete {
|
||||||
|
t.Fatalf("update did not complete: %#v", controller.Status())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
|
||||||
|
|
||||||
|
const (
|
||||||
|
labelImageVersion = "org.opencontainers.image.version"
|
||||||
|
labelMinimumVersion = "de.j-mahler.domain-control.minimum-update-version"
|
||||||
|
labelMinimumAgent = "de.j-mahler.domain-control.minimum-agent-protocol"
|
||||||
|
labelRuntimeContract = "de.j-mahler.domain-control.runtime-contract"
|
||||||
|
labelDatabaseContract = "de.j-mahler.domain-control.database-contract"
|
||||||
|
labelSchemaVersion = "de.j-mahler.domain-control.schema-version"
|
||||||
|
runtimeEntrypoint = "/usr/local/bin/domain-control"
|
||||||
|
runtimeCommand = "serve"
|
||||||
|
runtimeUser = "dyndns:dyndns"
|
||||||
|
runtimeWorkingDirectory = "/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DockerRunner struct {
|
||||||
|
docker *dockerClient
|
||||||
|
imageRepository string
|
||||||
|
targetLabel string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateError struct {
|
||||||
|
message string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *updateError) Error() string { return e.err.Error() }
|
||||||
|
func (e *updateError) Unwrap() error { return e.err }
|
||||||
|
func (e *updateError) PublicMessage() string { return e.message }
|
||||||
|
|
||||||
|
func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner {
|
||||||
|
if imageRepository == "" {
|
||||||
|
imageRepository = defaultImageRepository
|
||||||
|
}
|
||||||
|
return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) {
|
||||||
|
if !updateprotocol.ValidVersion(request.Version) {
|
||||||
|
return errors.New("invalid target version")
|
||||||
|
}
|
||||||
|
if err := r.docker.negotiate(ctx); err != nil {
|
||||||
|
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
||||||
|
}
|
||||||
|
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
|
||||||
|
}
|
||||||
|
inspected, err := r.docker.inspectContainer(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
||||||
|
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||||
|
}
|
||||||
|
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Die laufende Version konnte nicht sicher bestimmt werden; bitte einmal mit Docker Compose aktualisieren", err: err}
|
||||||
|
}
|
||||||
|
if comparison, compareErr := updateprotocol.CompareVersions(currentVersion, request.Version); compareErr != nil || comparison >= 0 {
|
||||||
|
return &updateError{message: "Die Zielversion ist nicht neuer als die laufende Version", err: fmt.Errorf("invalid version transition %s to %s", currentVersion, request.Version)}
|
||||||
|
}
|
||||||
|
currentImage, err := r.docker.inspectImage(ctx, inspected.Image)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Die laufende Container-Basis konnte nicht geprüft werden", err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetReference := r.imageRepository + ":" + request.Version
|
||||||
|
progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft")
|
||||||
|
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
|
||||||
|
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
|
||||||
|
}
|
||||||
|
targetImage, err := r.docker.inspectImage(ctx, targetReference)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Das geladene Image konnte nicht geprüft werden; der Dienst läuft unverändert weiter", err: err}
|
||||||
|
}
|
||||||
|
if err := validateUpdateCompatibility(currentVersion, request.Version, currentImage, targetImage); err != nil {
|
||||||
|
return &updateError{message: "Dieses Release unterstützt kein sicheres Direktupdate von der laufenden Version; bitte Docker Compose verwenden", err: err}
|
||||||
|
}
|
||||||
|
replacementConfig, err := buildReplacementConfig(inspected, currentImage.Config, targetImage.Config, targetReference)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Die neue Container-Konfiguration konnte nicht sicher vorbereitet werden", err: err}
|
||||||
|
}
|
||||||
|
originalName := strings.TrimPrefix(inspected.Name, "/")
|
||||||
|
if originalName == "" {
|
||||||
|
return errors.New("target container has no name")
|
||||||
|
}
|
||||||
|
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano())
|
||||||
|
var oldStopped, oldRenamed bool
|
||||||
|
var newID string
|
||||||
|
defer func() {
|
||||||
|
if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil {
|
||||||
|
r.logger.Error("software update rollback failed", "error", rollbackErr)
|
||||||
|
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet")
|
||||||
|
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, replacementConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.docker.startContainer(ctx, newID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
|
||||||
|
if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
|
||||||
|
r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err)
|
||||||
|
}
|
||||||
|
oldStopped, oldRenamed, newID = false, false, ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error {
|
||||||
|
var failures []string
|
||||||
|
if newID != "" {
|
||||||
|
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
|
||||||
|
failures = append(failures, "stop replacement: "+err.Error())
|
||||||
|
}
|
||||||
|
if err := r.docker.removeContainer(ctx, newID, true); err != nil {
|
||||||
|
failures = append(failures, "remove replacement: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldRenamed {
|
||||||
|
if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil {
|
||||||
|
failures = append(failures, "restore name: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldStopped {
|
||||||
|
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
|
||||||
|
failures = append(failures, "restart previous container: "+err.Error())
|
||||||
|
} else if err := r.docker.waitHealthy(ctx, oldID, oldImageID, "", 60*time.Second); err != nil {
|
||||||
|
failures = append(failures, "verify previous container: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
return errors.New(strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dockerClient struct {
|
||||||
|
socket string
|
||||||
|
client *http.Client
|
||||||
|
apiPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerSummary struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Names []string `json:"Names"`
|
||||||
|
State string `json:"State"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerInspect struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Config json.RawMessage `json:"Config"`
|
||||||
|
HostConfig json.RawMessage `json:"HostConfig"`
|
||||||
|
NetworkSettings struct {
|
||||||
|
Networks map[string]endpointSettings `json:"Networks"`
|
||||||
|
} `json:"NetworkSettings"`
|
||||||
|
State containerState `json:"State"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageInspect struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Config json.RawMessage `json:"Config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageRuntimeConfig struct {
|
||||||
|
User string `json:"User"`
|
||||||
|
Entrypoint []string `json:"Entrypoint"`
|
||||||
|
Cmd []string `json:"Cmd"`
|
||||||
|
WorkingDir string `json:"WorkingDir"`
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
ExposedPorts map[string]any `json:"ExposedPorts"`
|
||||||
|
Healthcheck *struct {
|
||||||
|
Test []string `json:"Test"`
|
||||||
|
} `json:"Healthcheck"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointSettings struct {
|
||||||
|
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
|
||||||
|
Links []string `json:"Links,omitempty"`
|
||||||
|
Aliases []string `json:"Aliases,omitempty"`
|
||||||
|
MacAddress string `json:"MacAddress,omitempty"`
|
||||||
|
DriverOpts map[string]string `json:"DriverOpts,omitempty"`
|
||||||
|
GwPriority int `json:"GwPriority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerState struct {
|
||||||
|
Running bool `json:"Running"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
Health *struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
} `json:"Health"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDockerClient(socket string) *dockerClient {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket)
|
||||||
|
},
|
||||||
|
MaxIdleConns: 2,
|
||||||
|
MaxIdleConnsPerHost: 2,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
return &dockerClient{socket: socket, client: &http.Client{Transport: transport}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) negotiate(ctx context.Context) error {
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var version struct {
|
||||||
|
APIVersion string `json:"ApiVersion"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil {
|
||||||
|
return fmt.Errorf("decode Docker API version: %w", err)
|
||||||
|
}
|
||||||
|
for _, character := range version.APIVersion {
|
||||||
|
if (character < '0' || character > '9') && character != '.' {
|
||||||
|
return errors.New("Docker returned an invalid API version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version.APIVersion == "" {
|
||||||
|
return errors.New("Docker returned an empty API version")
|
||||||
|
}
|
||||||
|
c.apiPrefix = "/v" + version.APIVersion
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) {
|
||||||
|
filters, _ := json.Marshal(map[string][]string{"label": {label}})
|
||||||
|
query := url.Values{"all": {"1"}, "filters": {string(filters)}}
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return containerSummary{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return containerSummary{}, dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var containers []containerSummary
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil {
|
||||||
|
return containerSummary{}, err
|
||||||
|
}
|
||||||
|
var running []containerSummary
|
||||||
|
for _, container := range containers {
|
||||||
|
if container.State == "running" {
|
||||||
|
running = append(running, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(running) != 1 {
|
||||||
|
return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running))
|
||||||
|
}
|
||||||
|
return running[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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) inspectImage(ctx context.Context, reference string) (imageInspect, error) {
|
||||||
|
if strings.TrimSpace(reference) == "" {
|
||||||
|
return imageInspect{}, errors.New("image reference is empty")
|
||||||
|
}
|
||||||
|
response, err := c.request(ctx, http.MethodGet, "/images/"+url.PathEscape(reference)+"/json", nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return imageInspect{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return imageInspect{}, dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var inspected imageInspect
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
|
||||||
|
return imageInspect{}, err
|
||||||
|
}
|
||||||
|
if inspected.ID == "" || len(inspected.Config) == 0 {
|
||||||
|
return imageInspect{}, errors.New("Docker returned incomplete image metadata")
|
||||||
|
}
|
||||||
|
return inspected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
|
||||||
|
query := url.Values{"t": {fmt.Sprint(seconds)}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error {
|
||||||
|
query := url.Values{"name": {name}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) {
|
||||||
|
var config struct {
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
||||||
|
return "", fmt.Errorf("decode running container version: %w", err)
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(repository, ":") + ":"
|
||||||
|
var imageVersion string
|
||||||
|
if strings.HasPrefix(config.Image, prefix) {
|
||||||
|
if version := strings.TrimPrefix(config.Image, prefix); updateprotocol.ValidVersion(version) {
|
||||||
|
imageVersion = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelVersion := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||||
|
if labelVersion != "" && !updateprotocol.ValidVersion(labelVersion) {
|
||||||
|
return "", errors.New("running container has an invalid version label")
|
||||||
|
}
|
||||||
|
if imageVersion == "" || labelVersion == "" {
|
||||||
|
return "", errors.New("running container has no trustworthy version metadata")
|
||||||
|
}
|
||||||
|
if imageVersion != labelVersion {
|
||||||
|
return "", fmt.Errorf("running image reference %s does not match version label %s", imageVersion, labelVersion)
|
||||||
|
}
|
||||||
|
actualVersion := imageVersion
|
||||||
|
if reported != "" {
|
||||||
|
if !updateprotocol.ValidVersion(reported) || reported != actualVersion {
|
||||||
|
return "", fmt.Errorf("reported version %q does not match running version %q", reported, actualVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actualVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUpdateCompatibility(currentVersion, targetVersion string, currentImage, targetImage imageInspect) error {
|
||||||
|
if comparison, err := updateprotocol.CompareVersions(currentVersion, targetVersion); err != nil || comparison >= 0 {
|
||||||
|
return fmt.Errorf("target %s is not newer than %s", targetVersion, currentVersion)
|
||||||
|
}
|
||||||
|
var current, target imageRuntimeConfig
|
||||||
|
if err := json.Unmarshal(currentImage.Config, ¤t); err != nil {
|
||||||
|
return fmt.Errorf("decode current image config: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(targetImage.Config, &target); err != nil {
|
||||||
|
return fmt.Errorf("decode target image config: %w", err)
|
||||||
|
}
|
||||||
|
if current.Labels[labelImageVersion] != currentVersion {
|
||||||
|
return fmt.Errorf("current image version label is %q", current.Labels[labelImageVersion])
|
||||||
|
}
|
||||||
|
if target.Labels[labelImageVersion] != targetVersion {
|
||||||
|
return fmt.Errorf("target image version label is %q", target.Labels[labelImageVersion])
|
||||||
|
}
|
||||||
|
minimumVersion := target.Labels[labelMinimumVersion]
|
||||||
|
if !updateprotocol.ValidVersion(minimumVersion) {
|
||||||
|
return errors.New("target image has no valid minimum update version")
|
||||||
|
}
|
||||||
|
if comparison, err := updateprotocol.CompareVersions(currentVersion, minimumVersion); err != nil || comparison < 0 {
|
||||||
|
return fmt.Errorf("target requires at least %s", minimumVersion)
|
||||||
|
}
|
||||||
|
minimumAgent, err := strconv.Atoi(target.Labels[labelMinimumAgent])
|
||||||
|
if err != nil || minimumAgent < buildinfo.MinimumAgentProtocol || minimumAgent > buildinfo.UpdateProtocolVersion {
|
||||||
|
return fmt.Errorf("target requires unsupported update protocol %q", target.Labels[labelMinimumAgent])
|
||||||
|
}
|
||||||
|
runtimeContract := strconv.Itoa(buildinfo.RuntimeContractVersion)
|
||||||
|
if current.Labels[labelRuntimeContract] != runtimeContract || target.Labels[labelRuntimeContract] != runtimeContract {
|
||||||
|
return fmt.Errorf("unsupported runtime contract transition %q to %q", current.Labels[labelRuntimeContract], target.Labels[labelRuntimeContract])
|
||||||
|
}
|
||||||
|
databaseContract := strconv.Itoa(buildinfo.DatabaseContractVersion)
|
||||||
|
if current.Labels[labelDatabaseContract] != databaseContract || target.Labels[labelDatabaseContract] != databaseContract {
|
||||||
|
return fmt.Errorf("unsupported database contract transition %q to %q", current.Labels[labelDatabaseContract], target.Labels[labelDatabaseContract])
|
||||||
|
}
|
||||||
|
currentSchema, currentSchemaErr := strconv.Atoi(current.Labels[labelSchemaVersion])
|
||||||
|
targetSchema, targetSchemaErr := strconv.Atoi(target.Labels[labelSchemaVersion])
|
||||||
|
if currentSchemaErr != nil || targetSchemaErr != nil || currentSchema < buildinfo.CurrentSchemaVersion || targetSchema < currentSchema {
|
||||||
|
return fmt.Errorf("unsupported schema transition %q to %q", current.Labels[labelSchemaVersion], target.Labels[labelSchemaVersion])
|
||||||
|
}
|
||||||
|
if !compatibleRuntimeConfig(current) {
|
||||||
|
return errors.New("current image violates the stable runtime contract")
|
||||||
|
}
|
||||||
|
if !compatibleRuntimeConfig(target) {
|
||||||
|
return errors.New("target image violates the stable runtime contract")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatibleRuntimeConfig(config imageRuntimeConfig) bool {
|
||||||
|
if config.User != runtimeUser || config.WorkingDir != runtimeWorkingDirectory || len(config.Entrypoint) != 1 || config.Entrypoint[0] != runtimeEntrypoint || len(config.Cmd) != 1 || config.Cmd[0] != runtimeCommand {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := config.ExposedPorts["8080/tcp"]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return config.Healthcheck != nil && strings.Join(config.Healthcheck.Test, "\x00") == strings.Join([]string{"CMD", runtimeEntrypoint, "healthcheck"}, "\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReplacementConfig(inspected containerInspect, oldImageConfig, targetImageConfig json.RawMessage, targetReference string) (map[string]json.RawMessage, error) {
|
||||||
|
oldContainer, err := decodeConfigMap(inspected.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode running container config: %w", err)
|
||||||
|
}
|
||||||
|
oldImage, err := decodeConfigMap(oldImageConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode running image config: %w", err)
|
||||||
|
}
|
||||||
|
target, err := decodeConfigMap(targetImageConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode target image config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range []string{
|
||||||
|
"Domainname", "AttachStdin", "AttachStdout", "AttachStderr", "Tty", "OpenStdin", "StdinOnce",
|
||||||
|
"User", "Cmd", "Entrypoint", "WorkingDir", "Healthcheck", "StopSignal", "StopTimeout", "Shell",
|
||||||
|
"NetworkDisabled", "MacAddress",
|
||||||
|
} {
|
||||||
|
preserveConfigOverride(target, oldContainer, oldImage, field)
|
||||||
|
}
|
||||||
|
preserveCustomHostname(target, oldContainer, inspected.ID)
|
||||||
|
if err := mergeEnvironment(target, oldContainer, oldImage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := mergeLabels(target, oldContainer, oldImage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, field := range []string{"ExposedPorts", "Volumes"} {
|
||||||
|
if err := mergeAddedMapKeys(target, oldContainer, oldImage, field); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encodedReference, _ := json.Marshal(targetReference)
|
||||||
|
target["Image"] = encodedReference
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeConfigMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||||
|
var config map[string]json.RawMessage
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, errors.New("empty config")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preserveConfigOverride(target, container, image map[string]json.RawMessage, field string) {
|
||||||
|
containerValue, exists := container[field]
|
||||||
|
if !exists || rawJSONEqual(containerValue, image[field]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target[field] = append(json.RawMessage(nil), containerValue...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preserveCustomHostname(target, container map[string]json.RawMessage, containerID string) {
|
||||||
|
var hostname string
|
||||||
|
if err := json.Unmarshal(container["Hostname"], &hostname); err != nil || hostname == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shortID := containerID
|
||||||
|
if len(shortID) > 12 {
|
||||||
|
shortID = shortID[:12]
|
||||||
|
}
|
||||||
|
if hostname == containerID || hostname == shortID {
|
||||||
|
delete(target, "Hostname")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target["Hostname"] = append(json.RawMessage(nil), container["Hostname"]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEnvironment(target, container, image map[string]json.RawMessage) error {
|
||||||
|
targetValues, err := decodeStringSlice(target["Env"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode target environment: %w", err)
|
||||||
|
}
|
||||||
|
containerValues, err := decodeStringSlice(container["Env"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode container environment: %w", err)
|
||||||
|
}
|
||||||
|
imageValues, err := decodeStringSlice(image["Env"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image environment: %w", err)
|
||||||
|
}
|
||||||
|
result, positions := indexedEnvironment(targetValues)
|
||||||
|
imageEnvironment := make(map[string]string, len(imageValues))
|
||||||
|
for _, value := range imageValues {
|
||||||
|
imageEnvironment[environmentKey(value)] = value
|
||||||
|
}
|
||||||
|
for _, value := range containerValues {
|
||||||
|
key := environmentKey(value)
|
||||||
|
if previous, exists := imageEnvironment[key]; exists && previous == value {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if position, exists := positions[key]; exists {
|
||||||
|
result[position] = value
|
||||||
|
} else {
|
||||||
|
positions[key] = len(result)
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(result)
|
||||||
|
target["Env"] = encoded
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringSlice(raw json.RawMessage) ([]string, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexedEnvironment(values []string) ([]string, map[string]int) {
|
||||||
|
result := append([]string(nil), values...)
|
||||||
|
positions := make(map[string]int, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
positions[environmentKey(value)] = index
|
||||||
|
}
|
||||||
|
return result, positions
|
||||||
|
}
|
||||||
|
|
||||||
|
func environmentKey(value string) string {
|
||||||
|
if index := strings.IndexByte(value, '='); index >= 0 {
|
||||||
|
return value[:index]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeLabels(target, container, image map[string]json.RawMessage) error {
|
||||||
|
targetLabels, err := decodeStringMap(target["Labels"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode target labels: %w", err)
|
||||||
|
}
|
||||||
|
containerLabels, err := decodeStringMap(container["Labels"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode container labels: %w", err)
|
||||||
|
}
|
||||||
|
imageLabels, err := decodeStringMap(image["Labels"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image labels: %w", err)
|
||||||
|
}
|
||||||
|
if targetLabels == nil {
|
||||||
|
targetLabels = make(map[string]string)
|
||||||
|
}
|
||||||
|
for key, value := range containerLabels {
|
||||||
|
if protectedImageLabel(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if imageValue, exists := imageLabels[key]; exists && imageValue == value {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetLabels[key] = value
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(targetLabels)
|
||||||
|
target["Labels"] = encoded
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectedImageLabel(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case labelImageVersion, labelMinimumVersion, labelMinimumAgent, labelRuntimeContract, labelDatabaseContract, labelSchemaVersion:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringMap(raw json.RawMessage) (map[string]string, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var values map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAddedMapKeys(target, container, image map[string]json.RawMessage, field string) error {
|
||||||
|
targetValues, err := decodeRawMap(target[field])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode target %s: %w", field, err)
|
||||||
|
}
|
||||||
|
containerValues, err := decodeRawMap(container[field])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode container %s: %w", field, err)
|
||||||
|
}
|
||||||
|
imageValues, err := decodeRawMap(image[field])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %s: %w", field, err)
|
||||||
|
}
|
||||||
|
if targetValues == nil {
|
||||||
|
targetValues = make(map[string]json.RawMessage)
|
||||||
|
}
|
||||||
|
for key, value := range containerValues {
|
||||||
|
if imageValue, exists := imageValues[key]; exists && rawJSONEqual(value, imageValue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetValues[key] = value
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(targetValues)
|
||||||
|
target[field] = encoded
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRawMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var values map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawJSONEqual(left, right json.RawMessage) bool {
|
||||||
|
if len(left) == 0 && len(right) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var a, b any
|
||||||
|
if err := json.Unmarshal(left, &a); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(right, &b); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return reflect.DeepEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name string, body map[string]json.RawMessage) (string, error) {
|
||||||
|
body["HostConfig"] = inspected.HostConfig
|
||||||
|
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
|
||||||
|
oldIDPrefix := inspected.ID
|
||||||
|
if len(oldIDPrefix) > 12 {
|
||||||
|
oldIDPrefix = oldIDPrefix[:12]
|
||||||
|
}
|
||||||
|
for network, endpoint := range inspected.NetworkSettings.Networks {
|
||||||
|
aliases := endpoint.Aliases[:0]
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if alias != inspected.ID && alias != oldIDPrefix {
|
||||||
|
aliases = append(aliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint.Aliases = aliases
|
||||||
|
endpoints[network] = endpoint
|
||||||
|
}
|
||||||
|
networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints})
|
||||||
|
body["NetworkingConfig"] = networkingJSON
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
query := url.Values{"name": {name}}
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
return "", dockerStatusError(response)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if created.ID == "" {
|
||||||
|
return "", errors.New("Docker created a replacement without an ID")
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) startContainer(ctx context.Context, id string) error {
|
||||||
|
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error {
|
||||||
|
query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}}
|
||||||
|
response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dockerStatusError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error {
|
||||||
|
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 expectedImageID != "" && inspected.Image != expectedImageID {
|
||||||
|
return fmt.Errorf("container uses image %s instead of %s", inspected.Image, expectedImageID)
|
||||||
|
}
|
||||||
|
if expectedVersion != "" {
|
||||||
|
var config struct {
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(inspected.Config, &config); err != nil {
|
||||||
|
return fmt.Errorf("decode replacement version: %w", err)
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||||
|
if version != expectedVersion {
|
||||||
|
return fmt.Errorf("container version is %q instead of %q", version, expectedVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inspected.State.Health == nil {
|
||||||
|
if time.Since(startedAt) >= 8*time.Second {
|
||||||
|
return errors.New("replacement container has no healthcheck")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inspected.State.Health.Status == "healthy" {
|
||||||
|
consecutiveHealthy++
|
||||||
|
if consecutiveHealthy >= 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveHealthy = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) 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,205 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||||
|
target := compatibleImage("v2.0.0", "v1.4.0")
|
||||||
|
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", current, target); err != nil {
|
||||||
|
t.Fatalf("compatible direct update was rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
||||||
|
version, err := resolveCurrentVersion("", config, "registry.example/app")
|
||||||
|
if err != nil || version != "v1.4.0" {
|
||||||
|
t.Fatalf("exact image version was not resolved: version=%q err=%v", version, err)
|
||||||
|
}
|
||||||
|
if _, err := resolveCurrentVersion("v1.5.0", config, "registry.example/app"); err == nil {
|
||||||
|
t.Fatal("untrusted reported version overrode the running image metadata")
|
||||||
|
}
|
||||||
|
mismatch := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.3.0"}}`)
|
||||||
|
if _, err := resolveCurrentVersion("", mismatch, "registry.example/app"); err == nil {
|
||||||
|
t.Fatal("mismatching image reference and label were accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUpdateCompatibilityRejectsUnsupportedJump(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(map[string]any)
|
||||||
|
}{
|
||||||
|
{name: "minimum version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumVersion] = "v1.5.0" }},
|
||||||
|
{name: "runtime contract", mutate: func(config map[string]any) {
|
||||||
|
config["Labels"].(map[string]string)[labelRuntimeContract] = strconv.Itoa(buildinfo.RuntimeContractVersion + 1)
|
||||||
|
}},
|
||||||
|
{name: "database contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelDatabaseContract] = "2" }},
|
||||||
|
{name: "agent protocol", mutate: func(config map[string]any) {
|
||||||
|
config["Labels"].(map[string]string)[labelMinimumAgent] = strconv.Itoa(buildinfo.UpdateProtocolVersion + 1)
|
||||||
|
}},
|
||||||
|
{name: "wrong image version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelImageVersion] = "v1.9.9" }},
|
||||||
|
{name: "missing healthcheck", mutate: func(config map[string]any) { delete(config, "Healthcheck") }},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
image := compatibleImage("v2.0.0", "v1.4.0")
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(image.Config, &config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
labels := make(map[string]string)
|
||||||
|
for key, value := range config["Labels"].(map[string]any) {
|
||||||
|
labels[key] = value.(string)
|
||||||
|
}
|
||||||
|
config["Labels"] = labels
|
||||||
|
test.mutate(config)
|
||||||
|
image.Config, _ = json.Marshal(config)
|
||||||
|
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", compatibleImage("v1.4.0", "v1.4.0"), image); err == nil {
|
||||||
|
t.Fatal("incompatible image was accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUpdateCompatibilityRejectsSchemaRegressionAfterEarlierSkip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
current := compatibleImage("v2.0.0", "v1.4.0")
|
||||||
|
target := compatibleImage("v2.1.0", "v1.4.0")
|
||||||
|
current.Config = withImageLabel(t, current.Config, labelSchemaVersion, "20")
|
||||||
|
target.Config = withImageLabel(t, target.Config, labelSchemaVersion, "19")
|
||||||
|
if err := validateUpdateCompatibility("v2.0.0", "v2.1.0", current, target); err == nil {
|
||||||
|
t.Fatal("target with an older schema contract was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReplacementConfigUsesNewImageDefaultsAndKeepsDeploymentOverrides(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
oldImage := json.RawMessage(`{
|
||||||
|
"User":"dyndns:dyndns",
|
||||||
|
"Entrypoint":["/old-entrypoint"],
|
||||||
|
"WorkingDir":"/data",
|
||||||
|
"Env":["PATH=/old","OLD_DEFAULT=1"],
|
||||||
|
"Labels":{"org.opencontainers.image.version":"v1.3.0","image-owned":"old"},
|
||||||
|
"ExposedPorts":{"8080/tcp":{}},
|
||||||
|
"Volumes":{"/data":{}}
|
||||||
|
}`)
|
||||||
|
targetImage := json.RawMessage(`{
|
||||||
|
"User":"dyndns:dyndns",
|
||||||
|
"Entrypoint":["/new-entrypoint"],
|
||||||
|
"WorkingDir":"/data",
|
||||||
|
"Env":["PATH=/new","NEW_DEFAULT=1"],
|
||||||
|
"Labels":{"org.opencontainers.image.version":"v2.0.0","image-owned":"new"},
|
||||||
|
"ExposedPorts":{"8080/tcp":{}},
|
||||||
|
"Volumes":{"/data":{}}
|
||||||
|
}`)
|
||||||
|
container := containerInspect{
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
Config: json.RawMessage(`{
|
||||||
|
"Hostname":"0123456789ab",
|
||||||
|
"User":"dyndns:dyndns",
|
||||||
|
"Entrypoint":["/old-entrypoint"],
|
||||||
|
"WorkingDir":"/data",
|
||||||
|
"Env":["PATH=/old","OLD_DEFAULT=1","CUSTOM=yes"],
|
||||||
|
"Labels":{"org.opencontainers.image.version":"v0.0.0","image-owned":"old","com.docker.compose.project":"domain-control","de.j-mahler.domain-control.role":"app"},
|
||||||
|
"ExposedPorts":{"8080/tcp":{},"9091/tcp":{}},
|
||||||
|
"Volumes":{"/data":{},"/custom":{}}
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
config, err := buildReplacementConfig(container, oldImage, targetImage, "registry.example/app:v2.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := config["Hostname"]; exists {
|
||||||
|
t.Fatal("generated container hostname was copied")
|
||||||
|
}
|
||||||
|
var image, user string
|
||||||
|
var entrypoint, environment []string
|
||||||
|
var labels map[string]string
|
||||||
|
var ports, volumes map[string]any
|
||||||
|
_ = json.Unmarshal(config["Image"], &image)
|
||||||
|
_ = json.Unmarshal(config["User"], &user)
|
||||||
|
_ = json.Unmarshal(config["Entrypoint"], &entrypoint)
|
||||||
|
_ = json.Unmarshal(config["Env"], &environment)
|
||||||
|
_ = json.Unmarshal(config["Labels"], &labels)
|
||||||
|
_ = json.Unmarshal(config["ExposedPorts"], &ports)
|
||||||
|
_ = json.Unmarshal(config["Volumes"], &volumes)
|
||||||
|
if image != "registry.example/app:v2.0.0" || user != runtimeUser || len(entrypoint) != 1 || entrypoint[0] != "/new-entrypoint" {
|
||||||
|
t.Fatalf("new image defaults were not retained: image=%q user=%q entrypoint=%v", image, user, entrypoint)
|
||||||
|
}
|
||||||
|
if !contains(environment, "PATH=/new") || !contains(environment, "NEW_DEFAULT=1") || !contains(environment, "CUSTOM=yes") || contains(environment, "OLD_DEFAULT=1") {
|
||||||
|
t.Fatalf("environment was merged incorrectly: %v", environment)
|
||||||
|
}
|
||||||
|
if labels[labelImageVersion] != "v2.0.0" || labels["image-owned"] != "new" || labels["com.docker.compose.project"] != "domain-control" || labels["de.j-mahler.domain-control.role"] != "app" {
|
||||||
|
t.Fatalf("labels were merged incorrectly: %#v", labels)
|
||||||
|
}
|
||||||
|
if _, exists := ports["9091/tcp"]; !exists {
|
||||||
|
t.Fatalf("custom exposed port was lost: %#v", ports)
|
||||||
|
}
|
||||||
|
if _, exists := volumes["/custom"]; !exists {
|
||||||
|
t.Fatalf("custom volume was lost: %#v", volumes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatibleImage(version, minimum string) imageInspect {
|
||||||
|
config := map[string]any{
|
||||||
|
"User": runtimeUser,
|
||||||
|
"Entrypoint": []string{runtimeEntrypoint},
|
||||||
|
"Cmd": []string{runtimeCommand},
|
||||||
|
"WorkingDir": runtimeWorkingDirectory,
|
||||||
|
"ExposedPorts": map[string]any{
|
||||||
|
"8080/tcp": map[string]any{},
|
||||||
|
},
|
||||||
|
"Healthcheck": map[string]any{
|
||||||
|
"Test": []string{"CMD", runtimeEntrypoint, "healthcheck"},
|
||||||
|
},
|
||||||
|
"Labels": map[string]string{
|
||||||
|
labelImageVersion: version,
|
||||||
|
labelMinimumVersion: minimum,
|
||||||
|
labelMinimumAgent: strconv.Itoa(buildinfo.MinimumAgentProtocol),
|
||||||
|
labelRuntimeContract: strconv.Itoa(buildinfo.RuntimeContractVersion),
|
||||||
|
labelDatabaseContract: strconv.Itoa(buildinfo.DatabaseContractVersion),
|
||||||
|
labelSchemaVersion: strconv.Itoa(buildinfo.CurrentSchemaVersion),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(config)
|
||||||
|
return imageInspect{ID: "sha256:target", Config: encoded}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withImageLabel(t *testing.T, raw json.RawMessage, key, value string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
labels, ok := config["Labels"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("image labels are missing")
|
||||||
|
}
|
||||||
|
labels[key] = value
|
||||||
|
encoded, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(values []string, expected string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == expected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Socket string
|
||||||
|
DockerSocket string
|
||||||
|
ImageRepository string
|
||||||
|
TargetLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServerConfigFromEnvironment() ServerConfig {
|
||||||
|
return ServerConfig{
|
||||||
|
Socket: environmentValue("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
|
||||||
|
DockerSocket: environmentValue("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||||
|
ImageRepository: environmentValue("UPDATE_IMAGE_REPOSITORY", defaultImageRepository),
|
||||||
|
TargetLabel: environmentValue("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Serve(ctx context.Context, config ServerConfig, logger *slog.Logger) error {
|
||||||
|
if err := validateServerConfig(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
directory := filepath.Dir(config.Socket)
|
||||||
|
if err := os.MkdirAll(directory, 0o711); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(directory)
|
||||||
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return errors.New("update agent socket directory is not a regular directory")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(directory, 0o711); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(config.Socket); err == nil {
|
||||||
|
if info.Mode()&os.ModeSocket == 0 {
|
||||||
|
return errors.New("update agent socket path is occupied by a non-socket file")
|
||||||
|
}
|
||||||
|
if err := os.Remove(config.Socket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("unix", config.Socket)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
defer os.Remove(config.Socket)
|
||||||
|
// The agent container runs as uid 0 with gid 10001. Group-only access lets
|
||||||
|
// the unprivileged web process connect without exposing the control socket.
|
||||||
|
if err := os.Chmod(config.Socket, 0o660); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := NewDockerRunner(config.DockerSocket, config.ImageRepository, config.TargetLabel, logger)
|
||||||
|
controller := NewController(runner, logger)
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: controller.Handler(),
|
||||||
|
ReadHeaderTimeout: 3 * time.Second,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 5 * time.Second,
|
||||||
|
IdleTimeout: 15 * time.Second,
|
||||||
|
MaxHeaderBytes: 8 << 10,
|
||||||
|
}
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("update agent ready", "socket", config.Socket)
|
||||||
|
serverErrors <- server.Serve(listener)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-serverErrors:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateServerConfig(config ServerConfig) error {
|
||||||
|
socket := filepath.Clean(config.Socket)
|
||||||
|
dockerSocket := filepath.Clean(config.DockerSocket)
|
||||||
|
if !filepath.IsAbs(socket) || filepath.Dir(socket) == "/" {
|
||||||
|
return errors.New("update agent socket must use a dedicated absolute directory")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(dockerSocket) || socket == dockerSocket {
|
||||||
|
return errors.New("Docker socket path is invalid")
|
||||||
|
}
|
||||||
|
if config.ImageRepository == "" || strings.ContainsAny(config.ImageRepository, " \t\r\n") {
|
||||||
|
return errors.New("update image repository is invalid")
|
||||||
|
}
|
||||||
|
if len(config.TargetLabel) == 0 || len(config.TargetLabel) > 256 || !strings.Contains(config.TargetLabel, "=") {
|
||||||
|
return errors.New("update target label is invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func environmentValue(name, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package updateagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateServerConfigRejectsUnsafeSocketPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
valid := ServerConfig{
|
||||||
|
Socket: "/run/domain-control-update/agent.sock",
|
||||||
|
DockerSocket: "/var/run/docker.sock",
|
||||||
|
ImageRepository: defaultImageRepository,
|
||||||
|
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||||
|
}
|
||||||
|
if err := validateServerConfig(valid); err != nil {
|
||||||
|
t.Fatalf("valid config rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, socket := range []string{"agent.sock", "/agent.sock", "/var/run/docker.sock"} {
|
||||||
|
candidate := valid
|
||||||
|
candidate.Socket = socket
|
||||||
|
if err := validateServerConfig(candidate); err == nil {
|
||||||
|
t.Errorf("unsafe socket path %q was accepted", socket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeDoesNotDeleteRegularFileAtSocketPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
socket := filepath.Join(directory, "agent.sock")
|
||||||
|
if err := os.WriteFile(socket, []byte("keep"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config := ServerConfig{
|
||||||
|
Socket: socket,
|
||||||
|
DockerSocket: "/var/run/docker.sock",
|
||||||
|
ImageRepository: defaultImageRepository,
|
||||||
|
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||||
|
}
|
||||||
|
err := Serve(context.Background(), config, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("regular file at the socket path was silently replaced")
|
||||||
|
}
|
||||||
|
content, readErr := os.ReadFile(socket)
|
||||||
|
if readErr != nil || string(content) != "keep" {
|
||||||
|
t.Fatalf("regular file was changed: content=%q err=%v", content, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package updateprotocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseIdle = "idle"
|
||||||
|
PhaseQueued = "queued"
|
||||||
|
PhasePulling = "pulling"
|
||||||
|
PhaseRestarting = "restarting"
|
||||||
|
PhaseVerifying = "verifying"
|
||||||
|
PhaseComplete = "complete"
|
||||||
|
PhaseFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
CurrentVersion string `json:"current_version"`
|
||||||
|
RegistryUsername string `json:"registry_username"`
|
||||||
|
RegistryToken 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"`
|
||||||
|
AgentVersion string `json:"agent_version,omitempty"`
|
||||||
|
Protocol int `json:"protocol_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) Busy() bool {
|
||||||
|
return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidVersion(version string) bool {
|
||||||
|
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareVersions compares strict stable vMAJOR.MINOR.PATCH versions. It does
|
||||||
|
// not require callers to install intermediate releases.
|
||||||
|
func CompareVersions(left, right string) (int, error) {
|
||||||
|
a, err := parseVersion(left)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
b, err := parseVersion(right)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for index := range a {
|
||||||
|
if a[index] < b[index] {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
if a[index] > b[index] {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVersion(raw string) ([3]int, error) {
|
||||||
|
if !ValidVersion(raw) {
|
||||||
|
return [3]int{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
|
||||||
|
var values [3]int
|
||||||
|
for index, part := range parts {
|
||||||
|
value, err := strconv.Atoi(part)
|
||||||
|
if err != nil || value < 0 || value > 1_000_000 {
|
||||||
|
return [3]int{}, fmt.Errorf("version contains an invalid number")
|
||||||
|
}
|
||||||
|
values[index] = value
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
|
||||||
|
"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/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type piHoleView struct {
|
||||||
|
Integration model.PiHoleIntegration
|
||||||
|
Records []piHoleRecordView
|
||||||
|
ArchivedRecords []piHoleRecordView
|
||||||
|
FetchError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type piHoleRecordView struct {
|
||||||
|
LinkID int64
|
||||||
|
Active bool
|
||||||
|
Raw string
|
||||||
|
IP string
|
||||||
|
Hostnames string
|
||||||
|
Valid bool
|
||||||
|
ArchivedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var view *piHoleView
|
||||||
|
if len(integrations) > 0 {
|
||||||
|
integration := integrations[0]
|
||||||
|
current := piHoleView{Integration: integration}
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
|
||||||
|
var links []model.PiHoleRecordLink
|
||||||
|
if accessErr == nil {
|
||||||
|
var hosts []string
|
||||||
|
hosts, accessErr = client.ListHosts(r.Context(), password)
|
||||||
|
if accessErr == nil {
|
||||||
|
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
record := parsePiHoleRecord(link.Value)
|
||||||
|
record.LinkID = link.ID
|
||||||
|
record.Active = link.Active
|
||||||
|
record.ArchivedAt = link.ArchivedAt
|
||||||
|
if link.ArchivedAt.IsZero() {
|
||||||
|
current.Records = append(current.Records, record)
|
||||||
|
} else {
|
||||||
|
current.ArchivedRecords = append(current.ArchivedRecords, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
current.FetchError = safeMessage(accessErr)
|
||||||
|
current.Integration.LastError = current.FetchError
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError)
|
||||||
|
} else {
|
||||||
|
current.Integration.LastError = ""
|
||||||
|
current.Integration.LastSyncAt = time.Now().UTC()
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||||
|
}
|
||||||
|
sortPiHoleRecords(current.Records)
|
||||||
|
sortPiHoleRecords(current.ArchivedRecords)
|
||||||
|
view = ¤t
|
||||||
|
}
|
||||||
|
s.render(w, "pihole", viewData{
|
||||||
|
Title: "Pi-hole",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
Flash: notice(r),
|
||||||
|
Now: time.Now(),
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
PiHoleView: view,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(integrations) > 0 {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Es ist bereits ein Pi-hole verbunden. Bearbeite die bestehende Verbindung oder entferne sie zuerst.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const name = "Pi-hole"
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
if password == "" || len(password) > 1024 {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Ein gültiges Pi-hole-Anwendungspasswort ist erforderlich.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
insecureTLS := checked(r, "insecure_tls")
|
||||||
|
client, err := pihole.New(r.FormValue("base_url"), insecureTLS)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ciphertext, nonce, err := s.cipher.Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.CreatePiHoleIntegration(r.Context(), model.PiHoleIntegration{Name: name, BaseURL: client.BaseURL(), InsecureTLS: insecureTLS}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||||
|
if err != nil {
|
||||||
|
message := "Pi-hole-Verbindung konnte nicht gespeichert werden: " + safeMessage(err)
|
||||||
|
if errors.Is(err, store.ErrPiHoleAlreadyConfigured) {
|
||||||
|
message = "Es ist bereits ein Pi-hole verbunden."
|
||||||
|
}
|
||||||
|
s.redirectNotice(w, r, "/pihole", message, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
if password == "" {
|
||||||
|
password, err = s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(password) > 1024 {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Das Anwendungspasswort ist zu lang.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration.InsecureTLS = checked(r, "insecure_tls")
|
||||||
|
client, err := pihole.New(r.FormValue("base_url"), integration.InsecureTLS)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := client.ListHosts(r.Context(), password); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration.BaseURL = client.BaseURL()
|
||||||
|
ciphertext, nonce, err := s.cipher.Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.UpdatePiHoleIntegration(r.Context(), integration, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce}); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
|
||||||
|
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
|
||||||
|
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.credential_revealed", "Pi-hole-Anwendungspasswort angezeigt durch "+session.Username)
|
||||||
|
s.render(w, "token", viewData{
|
||||||
|
Title: "Pi-hole-Passwort anzeigen",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
SecretTitle: "Pi-hole-Anwendungspasswort",
|
||||||
|
SecretScope: "Pi-hole: " + integration.BaseURL,
|
||||||
|
SecretValue: password,
|
||||||
|
SecretBackURL: "/pihole#pihole-connection",
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
Now: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||||
|
if err == nil {
|
||||||
|
_, err = client.ListHosts(r.Context(), password)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
message := safeMessage(err)
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRemove(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.removed", "Pi-hole-Verbindung entfernt: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung entfernt; lokale DNS-Einträge auf dem Pi-hole blieben unverändert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, password, err := s.piHoleAccess(r.Context(), id)
|
||||||
|
var hosts []string
|
||||||
|
if err == nil {
|
||||||
|
hosts, err = client.ListHosts(r.Context(), password)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = client.DeleteHosts(r.Context(), password, hosts)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Pi-hole und lokale DNS-Einträge konnten nicht vollständig gelöscht werden", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.deleted", "Pi-hole-Verbindung und lokale DNS-Einträge gelöscht: "+integration.Name+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung und alle lokalen DNS-Einträge wurden vollständig gelöscht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := checked(r, "active")
|
||||||
|
if active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
accessErr = client.AddHost(r.Context(), password, value)
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht angelegt werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.LinkPiHoleRecord(r.Context(), id, value, active); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_created", "Lokalen DNS-Eintrag angelegt: "+value+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag angelegt"+piHoleActivitySuffix(active)+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordUpdate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous, err := validatePiHoleRawRecord(link.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replacement, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := checked(r, "active")
|
||||||
|
if link.Active || active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
switch {
|
||||||
|
case link.Active && active:
|
||||||
|
accessErr = client.ReplaceHost(r.Context(), password, previous, replacement)
|
||||||
|
case link.Active:
|
||||||
|
accessErr = client.DeleteHost(r.Context(), password, previous)
|
||||||
|
default:
|
||||||
|
accessErr = client.AddHost(r.Context(), password, replacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht geändert werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.UpdatePiHoleRecordLink(r.Context(), id, link.ID, replacement, active); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_updated", "Lokalen DNS-Eintrag geändert: "+previous+" → "+replacement+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag gespeichert"+piHoleActivitySuffix(active)+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := validatePiHoleRawRecord(link.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if link.Active {
|
||||||
|
client, password, accessErr := s.piHoleAccess(r.Context(), id)
|
||||||
|
if accessErr == nil {
|
||||||
|
accessErr = client.DeleteHost(r.Context(), password, value)
|
||||||
|
}
|
||||||
|
if accessErr != nil {
|
||||||
|
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht gelöscht werden", accessErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.DeletePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_deleted", "Lokalen DNS-Eintrag gelöscht: "+value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag bei Pi-hole gelöscht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordArchive(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.ArchivePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht aus der WebUI entfernt werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_archived", "Lokalen DNS-Eintrag aus der WebUI entfernt: "+link.Value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag aus der WebUI entfernt; bei Pi-hole blieb er unverändert.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordRestore(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link, ok := s.piHoleRecordLink(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RestorePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht wiederhergestellt werden: "+safeMessage(err), "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.store.AddAudit(r.Context(), "pihole.record_restored", "Lokalen DNS-Eintrag wieder in der WebUI verknüpft: "+link.Value+" durch "+session.Username)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), "Eintrag wieder mit der WebUI verknüpft.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleRecordLink(w http.ResponseWriter, r *http.Request, integrationID int64) (model.PiHoleRecordLink, bool) {
|
||||||
|
linkID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("link_id")), 10, 64)
|
||||||
|
if err != nil || linkID <= 0 {
|
||||||
|
http.Error(w, "Ungültiger Pi-hole-Eintrag", http.StatusBadRequest)
|
||||||
|
return model.PiHoleRecordLink{}, false
|
||||||
|
}
|
||||||
|
link, err := s.store.GetPiHoleRecordLink(r.Context(), integrationID, linkID)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return model.PiHoleRecordLink{}, false
|
||||||
|
}
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleActivitySuffix(active bool) string {
|
||||||
|
if active {
|
||||||
|
return " und bei Pi-hole aktiviert"
|
||||||
|
}
|
||||||
|
return " und bei Pi-hole deaktiviert"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleAccess(ctx context.Context, id int64) (*pihole.Client, string, error) {
|
||||||
|
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
client, err := pihole.New(integration.BaseURL, integration.InsecureTLS)
|
||||||
|
return client, password, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) piHoleOperationError(w http.ResponseWriter, r *http.Request, id int64, prefix string, err error) {
|
||||||
|
message := piHoleErrorMessage(err)
|
||||||
|
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
|
||||||
|
s.redirectNotice(w, r, piHoleURL(id), prefix+": "+message, "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleErrorMessage(err error) string {
|
||||||
|
var apiErr *pihole.APIError
|
||||||
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden && strings.Contains(strings.ToLower(apiErr.Message), "read-only") {
|
||||||
|
return "Pi-hole erlaubt dieser API-Sitzung nur Lesezugriff. Aktiviere für das Anwendungspasswort webserver.api.app_sudo=true und stelle sicher, dass misc.readOnly=false ist"
|
||||||
|
}
|
||||||
|
return safeMessage(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePiHoleRecord(rawIP, rawHostnames string) (string, error) {
|
||||||
|
address, err := netip.ParseAddr(strings.TrimSpace(rawIP))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("Bitte eine gültige IPv4- oder IPv6-Adresse angeben")
|
||||||
|
}
|
||||||
|
address = address.Unmap()
|
||||||
|
hostnames, err := normalizePiHoleHostnames(rawHostnames)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return address.String() + " " + strings.Join(hostnames, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePiHoleHostnames(raw string) ([]string, error) {
|
||||||
|
values := strings.Fields(strings.ToLower(strings.TrimSpace(raw)))
|
||||||
|
if len(values) == 0 || len(values) > 20 {
|
||||||
|
return nil, errors.New("Bitte mindestens einen und höchstens 20 Hostnamen angeben")
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSuffix(value, ".")
|
||||||
|
ascii, err := idna.Lookup.ToASCII(value)
|
||||||
|
if err != nil || ascii == "" || len(ascii) > 253 {
|
||||||
|
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(ascii, ".") {
|
||||||
|
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
|
||||||
|
}
|
||||||
|
for _, character := range label {
|
||||||
|
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '-' {
|
||||||
|
return nil, errors.New("Hostnamen dürfen nur Buchstaben, Zahlen, Punkte und Bindestriche enthalten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[ascii] {
|
||||||
|
seen[ascii] = true
|
||||||
|
result = append(result, ascii)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePiHoleRawRecord(raw string) (string, error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" || len(value) > 2048 {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if unicode.IsControl(character) {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := strings.Fields(value)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(fields[0]); err != nil {
|
||||||
|
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
|
||||||
|
}
|
||||||
|
if _, err := normalizePiHoleHostnames(strings.Join(fields[1:], " ")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePiHoleRecord(raw string) piHoleRecordView {
|
||||||
|
view := piHoleRecordView{Raw: raw}
|
||||||
|
fields := strings.Fields(raw)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
view.Hostnames = raw
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(fields[0]); err != nil {
|
||||||
|
view.Hostnames = strings.Join(fields[1:], " ")
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
view.IP = fields[0]
|
||||||
|
view.Hostnames = strings.Join(fields[1:], " ")
|
||||||
|
view.Valid = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortPiHoleRecords(records []piHoleRecordView) {
|
||||||
|
sort.Slice(records, func(left, right int) bool {
|
||||||
|
if records[left].Active != records[right].Active {
|
||||||
|
return records[left].Active
|
||||||
|
}
|
||||||
|
return strings.ToLower(records[left].Hostnames) < strings.ToLower(records[right].Hostnames)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func piHoleURL(_ int64) string {
|
||||||
|
return "/pihole#local-dns"
|
||||||
|
}
|
||||||
+282
-41
@@ -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 {
|
||||||
@@ -57,7 +61,10 @@ type viewData struct {
|
|||||||
ArchivedTargets []model.Target
|
ArchivedTargets []model.Target
|
||||||
CNAMERecords []model.CNAMERecord
|
CNAMERecords []model.CNAMERecord
|
||||||
Settings model.Settings
|
Settings model.Settings
|
||||||
Token string
|
SecretTitle string
|
||||||
|
SecretScope string
|
||||||
|
SecretValue string
|
||||||
|
SecretBackURL string
|
||||||
Form map[string]string
|
Form map[string]string
|
||||||
Users []model.User
|
Users []model.User
|
||||||
ManagedUser model.User
|
ManagedUser model.User
|
||||||
@@ -68,7 +75,10 @@ type viewData struct {
|
|||||||
ErrorTargets []model.Target
|
ErrorTargets []model.Target
|
||||||
ErrorZones []model.Zone
|
ErrorZones []model.Zone
|
||||||
ErrorRuns []model.UpdateRun
|
ErrorRuns []model.UpdateRun
|
||||||
|
ErrorPiHoles []model.PiHoleIntegration
|
||||||
ErrorCount int
|
ErrorCount int
|
||||||
|
PiHoleView *piHoleView
|
||||||
|
SoftwareUpdate appupdate.State
|
||||||
}
|
}
|
||||||
|
|
||||||
type flashMessage struct {
|
type flashMessage struct {
|
||||||
@@ -86,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 {
|
||||||
@@ -124,14 +134,33 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
|
||||||
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
|
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
|
||||||
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
|
||||||
|
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
|
||||||
|
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
|
||||||
|
mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave))
|
||||||
|
mux.Handle("POST /pihole/{id}/password", s.adminOnly(s.piHolePassword))
|
||||||
|
mux.Handle("POST /pihole/{id}/refresh", s.adminOnly(s.piHoleRefresh))
|
||||||
|
mux.Handle("POST /pihole/{id}/remove", s.adminOnly(s.piHoleRemove))
|
||||||
|
mux.Handle("POST /pihole/{id}/delete", s.adminOnly(s.piHoleDelete))
|
||||||
|
mux.Handle("POST /pihole/{id}/records", s.adminOnly(s.piHoleRecordCreate))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/update", s.adminOnly(s.piHoleRecordUpdate))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/delete", s.adminOnly(s.piHoleRecordDelete))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/archive", s.adminOnly(s.piHoleRecordArchive))
|
||||||
|
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
|
||||||
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
|
mux.Handle("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))
|
||||||
@@ -140,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,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
|
||||||
@@ -261,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)
|
||||||
@@ -277,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)
|
||||||
@@ -624,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
|
||||||
}
|
}
|
||||||
@@ -640,7 +696,18 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
|
|||||||
}
|
}
|
||||||
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
|
||||||
_ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name)
|
_ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name)
|
||||||
s.render(w, "token", viewData{Title: "API-Token anzeigen", Page: "domains", CSRF: session.CSRFToken, Zone: zone, Token: token, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
|
s.render(w, "token", viewData{
|
||||||
|
Title: "API-Token anzeigen",
|
||||||
|
Page: "domains",
|
||||||
|
CSRF: session.CSRFToken,
|
||||||
|
SecretTitle: "Hetzner API-Token",
|
||||||
|
SecretScope: "Zone: " + zone.Name,
|
||||||
|
SecretValue: token,
|
||||||
|
SecretBackURL: domainURL(zone.ID),
|
||||||
|
User: userFromSession(session),
|
||||||
|
IsAdmin: true,
|
||||||
|
Now: time.Now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1212,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 {
|
||||||
@@ -1228,7 +1415,12 @@ func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *mod
|
|||||||
s.serverError(w, err)
|
s.serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorCount: len(targets) + len(zones) + len(runs), Now: time.Now()})
|
piHoles, err := s.store.ListPiHoleIntegrationsWithErrors(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorPiHoles: piHoles, ErrorCount: len(targets) + len(zones) + len(runs) + len(piHoles), Now: time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1244,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) {
|
||||||
@@ -1257,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) {
|
||||||
@@ -1305,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,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) {
|
||||||
@@ -1347,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
|
||||||
@@ -1405,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) {
|
||||||
@@ -1491,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.")
|
||||||
}
|
}
|
||||||
@@ -1543,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.")
|
||||||
}
|
}
|
||||||
@@ -1686,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")
|
||||||
@@ -1699,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) {
|
||||||
@@ -1727,16 +1919,21 @@ func (s *Server) redirectNotice(w http.ResponseWriter, r *http.Request, target,
|
|||||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'")
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'")
|
||||||
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
if s.config.CookieSecure {
|
||||||
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTemplates() (map[string]*template.Template, error) {
|
func loadTemplates() (map[string]*template.Template, error) {
|
||||||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "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() {
|
||||||
@@ -1755,6 +1952,7 @@ func loadTemplates() (map[string]*template.Template, error) {
|
|||||||
"statusClass": statusClass,
|
"statusClass": statusClass,
|
||||||
"ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} },
|
"ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} },
|
||||||
"supportedTTL": supportedTTL,
|
"supportedTTL": supportedTTL,
|
||||||
|
"contains": strings.Contains,
|
||||||
"families": func(target model.Target) string {
|
"families": func(target model.Target) string {
|
||||||
var result []string
|
var result []string
|
||||||
if target.UpdateIPv4 {
|
if target.UpdateIPv4 {
|
||||||
@@ -2006,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 {
|
||||||
@@ -2020,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) {
|
||||||
@@ -2033,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
|
||||||
|
}
|
||||||
+728
-2
@@ -2,13 +2,24 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"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/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/secure"
|
||||||
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInputNormalizers(t *testing.T) {
|
func TestInputNormalizers(t *testing.T) {
|
||||||
@@ -70,6 +81,71 @@ func TestInputNormalizers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPiHoleReadOnlyErrorExplainsRequiredWritePermission(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
message := piHoleErrorMessage(&pihole.APIError{Status: http.StatusForbidden, Key: "forbidden", Message: "Unable to change configuration (read-only)"})
|
||||||
|
for _, expected := range []string{"webserver.api.app_sudo=true", "misc.readOnly=false"} {
|
||||||
|
if !strings.Contains(message, expected) {
|
||||||
|
t.Errorf("actionable Pi-hole read-only message is missing %q: %s", expected, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeadersProtectSensitiveResponses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := &Server{config: config.Config{CookieSecure: true}}
|
||||||
|
handler := server.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
for name, expected := range map[string]string{
|
||||||
|
"Content-Security-Policy": "default-src 'self'",
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
|
"Strict-Transport-Security": "max-age=31536000",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(response.Header().Get(name), expected) {
|
||||||
|
t.Errorf("security header %s=%q does not contain %q", name, response.Header().Get(name), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleRecordNormalization(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
value, err := normalizePiHoleRecord("192.168.1.20", "NAS.LAN nas")
|
||||||
|
if err != nil || value != "192.168.1.20 nas.lan nas" {
|
||||||
|
t.Fatalf("Pi-hole IPv4 record normalization failed: %q err=%v", value, err)
|
||||||
|
}
|
||||||
|
value, err = normalizePiHoleRecord("2001:0db8::20", "drucker.lan")
|
||||||
|
if err != nil || value != "2001:db8::20 drucker.lan" {
|
||||||
|
t.Fatalf("Pi-hole IPv6 record normalization failed: %q err=%v", value, err)
|
||||||
|
}
|
||||||
|
for _, invalid := range [][2]string{{"not-an-ip", "nas.lan"}, {"192.168.1.20", "bad_name"}, {"192.168.1.20", ""}} {
|
||||||
|
if _, err := normalizePiHoleRecord(invalid[0], invalid[1]); err == nil {
|
||||||
|
t.Errorf("invalid Pi-hole record accepted: %#v", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := validatePiHoleRawRecord("192.168.1.20 nas.lan"); err != nil || raw != "192.168.1.20 nas.lan" {
|
||||||
|
t.Fatalf("valid raw Pi-hole record rejected: %q err=%v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleRecordsSortActiveEntriesFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
records := []piHoleRecordView{
|
||||||
|
{Hostnames: "aaa.lan", Active: false},
|
||||||
|
{Hostnames: "zzz.lan", Active: true},
|
||||||
|
{Hostnames: "bbb.lan", Active: true},
|
||||||
|
}
|
||||||
|
sortPiHoleRecords(records)
|
||||||
|
if records[0].Hostnames != "bbb.lan" || records[1].Hostnames != "zzz.lan" || records[2].Hostnames != "aaa.lan" {
|
||||||
|
t.Fatalf("Pi-hole records not sorted by active state and hostname: %#v", records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if got := unixSeconds(time.Time{}); got != 0 {
|
if got := unixSeconds(time.Time{}); got != 0 {
|
||||||
@@ -149,6 +225,275 @@ 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.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, 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", "v2.0.0 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")
|
||||||
|
}
|
||||||
|
var syncPage bytes.Buffer
|
||||||
|
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
|
||||||
|
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(syncPage.String(), "Compose einmal aktualisieren") || strings.Contains(syncPage.String(), "Jetzt aktualisieren") {
|
||||||
|
t.Fatal("compose synchronization state was not rendered cleanly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||||
|
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) {
|
||||||
@@ -164,7 +509,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}},
|
||||||
}
|
}
|
||||||
@@ -177,11 +522,16 @@ 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"`, `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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, removed := range []string{"Entfernt nur die lokale DynDNS-Verknüpfung", "Löscht das Ziel und alle zugehörigen A-/AAAA-Records", "Der CNAME-Record bei Hetzner bleibt unverändert"} {
|
||||||
|
if strings.Contains(body, removed) {
|
||||||
|
t.Errorf("domain record action still contains removed description %q", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||||
@@ -214,3 +564,379 @@ func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
|||||||
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPiHolePageRendersConnectionAndLocalDNSControls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load templates: %v", err)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{
|
||||||
|
Title: "Pi-hole",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: "csrf",
|
||||||
|
IsAdmin: true,
|
||||||
|
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
|
||||||
|
PiHoleView: &piHoleView{
|
||||||
|
Integration: model.PiHoleIntegration{ID: 7, Name: "Pi-hole", BaseURL: "https://pi.hole/api"},
|
||||||
|
Records: []piHoleRecordView{{LinkID: 11, Active: true, Raw: "192.168.1.20 nas.lan", IP: "192.168.1.20", Hostnames: "nas.lan", Valid: true}},
|
||||||
|
ArchivedRecords: []piHoleRecordView{{LinkID: 12, Active: false, Raw: "192.168.1.30 old.lan", IP: "192.168.1.30", Hostnames: "old.lan", Valid: true, ArchivedAt: time.Now()}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
|
||||||
|
t.Fatalf("render Pi-hole page: %v", err)
|
||||||
|
}
|
||||||
|
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"`, `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) {
|
||||||
|
t.Errorf("Pi-hole page is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "app-password") {
|
||||||
|
t.Fatal("Pi-hole application password leaked into rendered page")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `action="/pihole/connect"`) {
|
||||||
|
t.Fatal("connected Pi-hole page still offered a second integration")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, ">A / AAAA<") {
|
||||||
|
t.Fatal("new Pi-hole record card still renders the removed A / AAAA badge")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Schreibzugriff für das Anwendungspasswort") || strings.Contains(body, "Pi-hole benötigt webserver.api.app_sudo") {
|
||||||
|
t.Fatal("Pi-hole page still renders the permanent write-permission warning")
|
||||||
|
}
|
||||||
|
for _, removed := range []string{"Trennt nur die lokale Verknüpfung. Der DNS-Eintrag bei Pi-hole bleibt bestehen", "Löscht den Eintrag aus der WebUI und unmittelbar bei Pi-hole"} {
|
||||||
|
if strings.Contains(body, removed) {
|
||||||
|
t.Errorf("Pi-hole record action still contains removed description %q", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load templates: %v", err)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
data := viewData{
|
||||||
|
Title: "Pi-hole",
|
||||||
|
Page: "pihole",
|
||||||
|
CSRF: "csrf",
|
||||||
|
IsAdmin: true,
|
||||||
|
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
|
||||||
|
}
|
||||||
|
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
|
||||||
|
t.Fatalf("render disconnected Pi-hole page: %v", err)
|
||||||
|
}
|
||||||
|
body := rendered.String()
|
||||||
|
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) {
|
||||||
|
t.Errorf("disconnected Pi-hole page is missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, removed := range []string{"Noch kein Pi-hole verbunden", "Verbinde genau einen Pi-hole", `href="#pihole-connect"`, `class="empty-board`} {
|
||||||
|
if strings.Contains(body, removed) {
|
||||||
|
t.Errorf("disconnected Pi-hole page still renders removed empty-state content %q", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
|
||||||
|
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
|
||||||
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
keyFile := filepath.Join(dir, "master-key")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
server := &Server{store: database, cipher: cipher}
|
||||||
|
form := url.Values{
|
||||||
|
"csrf": {"csrf-token"},
|
||||||
|
"base_url": {api.URL},
|
||||||
|
"password": {"separate-app-password"},
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/pihole/connect", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.piHoleConnect(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unexpected connect response: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
integrations, err := database.ListPiHoleIntegrations(ctx)
|
||||||
|
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" {
|
||||||
|
t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err)
|
||||||
|
}
|
||||||
|
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decrypted, err := cipher.Decrypt(credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") {
|
||||||
|
t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
adminHash, err := secure.HashPassword("correct admin password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.CreateAdmin(ctx, "admin", "Administrator", adminHash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
keyFile := filepath.Join(dir, "master-key")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
const secret = "super-secret-pihole-app-password"
|
||||||
|
ciphertext, nonce, err := cipher.Encrypt(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
templates, err := loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{store: database, cipher: cipher, templates: templates}
|
||||||
|
session := &model.Session{UserID: 1, CSRFToken: "csrf-token", Username: "admin", DisplayName: "Administrator", Role: "admin"}
|
||||||
|
requestFor := func(password, csrf string) *http.Request {
|
||||||
|
form := url.Values{"csrf": {csrf}, "password": {password}}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/pihole/1/password", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetPathValue("id", "1")
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong := httptest.NewRecorder()
|
||||||
|
server.piHolePassword(wrong, requestFor("wrong admin password", "csrf-token"), session)
|
||||||
|
if wrong.Code != http.StatusSeeOther || strings.Contains(wrong.Body.String(), secret) || strings.Contains(wrong.Header().Get("Location"), secret) {
|
||||||
|
t.Fatalf("wrong admin password exposed secret or returned wrong status: status=%d location=%q", wrong.Code, wrong.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidCSRF := httptest.NewRecorder()
|
||||||
|
server.piHolePassword(invalidCSRF, requestFor("correct admin password", "wrong-csrf"), session)
|
||||||
|
if invalidCSRF.Code != http.StatusForbidden || strings.Contains(invalidCSRF.Body.String(), secret) {
|
||||||
|
t.Fatalf("invalid CSRF exposed secret or returned wrong status: status=%d", invalidCSRF.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
revealed := httptest.NewRecorder()
|
||||||
|
server.piHolePassword(revealed, requestFor("correct admin password", "csrf-token"), session)
|
||||||
|
if revealed.Code != http.StatusOK || !strings.Contains(revealed.Body.String(), secret) || !strings.Contains(revealed.Body.String(), "Pi-hole-Anwendungspasswort") {
|
||||||
|
t.Fatalf("valid reauthentication did not reveal secret: status=%d body=%s", revealed.Code, revealed.Body.String())
|
||||||
|
}
|
||||||
|
if cacheControl := revealed.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") {
|
||||||
|
t.Fatalf("secret response is cacheable: Cache-Control=%q", cacheControl)
|
||||||
|
}
|
||||||
|
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integration.ID)
|
||||||
|
if err != nil || loaded.ID != integration.ID || strings.Contains(string(credential.Ciphertext), secret) {
|
||||||
|
t.Fatalf("Pi-hole credential was not retained as ciphertext: %#v err=%v", loaded, err)
|
||||||
|
}
|
||||||
|
events, err := database.RecentAudit(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
foundRevealAudit := false
|
||||||
|
for _, event := range events {
|
||||||
|
if strings.Contains(event.Message, secret) {
|
||||||
|
t.Fatalf("Pi-hole secret leaked into audit log: %#v", event)
|
||||||
|
}
|
||||||
|
if event.Kind == "pihole.credential_revealed" {
|
||||||
|
foundRevealAudit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundRevealAudit {
|
||||||
|
t.Fatal("Pi-hole credential reveal was not audited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleRecordCanBeRemovedFromAndRestoredToWebUI(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() })
|
||||||
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const value = "192.168.1.20 nas.lan"
|
||||||
|
if _, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{value}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := &Server{store: database}
|
||||||
|
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
||||||
|
requestFor := func(path string) *http.Request {
|
||||||
|
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetPathValue("id", "1")
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.piHoleRecordArchive(response, requestFor("/pihole/1/records/archive"), session)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unexpected archive response: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
links, err := database.ListPiHoleRecordLinks(ctx, integration.ID)
|
||||||
|
if err != nil || len(links) != 1 || links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record was not removed from active WebUI list: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
server.piHoleRecordRestore(response, requestFor("/pihole/1/records/restore"), session)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unexpected restore response: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
links, err = database.ListPiHoleRecordLinks(ctx, integration.ID)
|
||||||
|
if err != nil || len(links) != 1 || !links[0].ArchivedAt.IsZero() {
|
||||||
|
t.Fatalf("Pi-hole record was not restored to active WebUI list: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiHoleRecordActiveSwitchDeletesAndRecreatesRemoteHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
remoteHosts := map[string]bool{"192.168.1.20 nas.lan": true}
|
||||||
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"switch-sid","message":"ok"}}`))
|
||||||
|
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Header.Get("X-FTL-SID") == "switch-sid":
|
||||||
|
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
remoteHosts[value] = true
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if !remoteHosts[value] {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(remoteHosts, value)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer api.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
keyFile := filepath.Join(dir, "master-key")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
ciphertext, nonce, err := cipher.Encrypt("app-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
links, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.20 nas.lan"})
|
||||||
|
if err != nil || len(links) != 1 {
|
||||||
|
t.Fatalf("record link setup failed: %#v err=%v", links, err)
|
||||||
|
}
|
||||||
|
server := &Server{store: database, cipher: cipher}
|
||||||
|
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
|
||||||
|
update := func(ip string, active bool) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}, "ip": {ip}, "hostnames": {"nas.lan"}}
|
||||||
|
if active {
|
||||||
|
form.Set("active", "on")
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/pihole/1/records/update", strings.NewReader(form.Encode()))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetPathValue("id", "1")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.piHoleRecordUpdate(response, request, session)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
if response := update("192.168.1.20", false); response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unexpected deactivate response: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
link, err := database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
||||||
|
if err != nil || link.Active || remoteHosts[link.Value] {
|
||||||
|
t.Fatalf("deactivated record remained active: %#v remote=%#v err=%v", link, remoteHosts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response := update("192.168.1.21", true); response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unexpected activate response: status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
link, err = database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
|
||||||
|
if err != nil || !link.Active || link.Value != "192.168.1.21 nas.lan" || !remoteHosts[link.Value] {
|
||||||
|
t.Fatalf("reactivated record was not recreated: %#v remote=%#v err=%v", link, remoteHosts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
-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]');
|
||||||
@@ -179,6 +205,37 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const form of document.querySelectorAll('[data-dirty-form]')) {
|
||||||
|
const submit = form.querySelector('[data-dirty-submit]');
|
||||||
|
if (!submit) continue;
|
||||||
|
const state = () => new URLSearchParams(new FormData(form)).toString();
|
||||||
|
const initialState = state();
|
||||||
|
const syncDirtyState = () => {
|
||||||
|
const dirty = state() !== initialState;
|
||||||
|
submit.disabled = !dirty;
|
||||||
|
submit.setAttribute('aria-disabled', String(!dirty));
|
||||||
|
};
|
||||||
|
form.addEventListener('input', syncDirtyState);
|
||||||
|
form.addEventListener('change', syncDirtyState);
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
syncDirtyState();
|
||||||
|
if (submit.disabled) event.preventDefault();
|
||||||
|
});
|
||||||
|
syncDirtyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const details of document.querySelectorAll('details[data-scroll-page-end]')) {
|
||||||
|
details.addEventListener('toggle', () => {
|
||||||
|
if (!details.open) return;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: document.documentElement.scrollHeight,
|
||||||
|
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
||||||
for (const button of copyButtons) {
|
for (const button of copyButtons) {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
@@ -195,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) => {
|
||||||
@@ -212,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;
|
||||||
@@ -222,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';
|
||||||
@@ -245,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; }
|
||||||
@@ -234,6 +249,32 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
|||||||
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||||
.admin-actions-grid { margin-top: 18px; }
|
.admin-actions-grid { margin-top: 18px; }
|
||||||
|
|
||||||
|
.pihole-kicker { color: #a4121a; }
|
||||||
|
.button-pihole { border-color: #a4121a; background: #a4121a; color: #fff; }
|
||||||
|
.button-pihole:hover { border-color: #861016; background: #861016; color: #fff; }
|
||||||
|
.button-pihole:disabled, .button-pihole:disabled:hover { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; }
|
||||||
|
.button-pihole:focus-visible { outline: 3px solid rgba(164,18,26,.2); outline-offset: 2px; }
|
||||||
|
.pihole-summary { border-top: 3px solid #a4121a; }
|
||||||
|
.target-row.pihole-target-row { border-left-color: #b9c3ce; }
|
||||||
|
.target-row.pihole-target-row.is-active { border-left-color: #a4121a; }
|
||||||
|
.target-status.pihole-on { background: #a4121a; box-shadow: 0 0 0 3px rgba(164,18,26,.1); }
|
||||||
|
.pihole-status-pill { background: #fff0f1; color: #98131b; }
|
||||||
|
.pihole-switch input:checked + span { background: #a4121a; }
|
||||||
|
.pihole-record-form { gap: 6px; }
|
||||||
|
.pihole-record-form label:not(.switch) { gap: 2px; }
|
||||||
|
.pihole-record-form .form-grid { gap: 8px; align-items: start; }
|
||||||
|
.pihole-record-form .toggle-row { min-height: 24px; }
|
||||||
|
.pihole-record-form input { min-height: 43px; }
|
||||||
|
.pihole-record-save { min-height: 36px; justify-self: start; padding-inline: 11px; font-size: 11px; }
|
||||||
|
.pihole-target-row .edit-inner { gap: 10px; padding-block: 11px; }
|
||||||
|
.pihole-action-grid { grid-template-columns: repeat(2, minmax(0,1fr)); margin-top: 14px; }
|
||||||
|
.pihole-connection-error { margin-bottom: 18px; padding: 12px; border: 1px solid #efc7ce; border-left: 3px solid #a4121a; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||||
|
.pihole-connection-error strong { color: var(--danger); font-size: 11px; }
|
||||||
|
.pihole-connection-error p { margin: 3px 0 0; color: #865764; font-size: 10px; overflow-wrap: anywhere; }
|
||||||
|
.pihole-connect-stage { display: grid; min-height: clamp(390px, calc(100vh - 245px), 640px); place-items: center; }
|
||||||
|
.pihole-connect-panel { width: min(560px, 100%); border-top: 3px solid #a4121a; }
|
||||||
|
.pihole-api-badge { padding: 4px 7px; border-radius: 4px; background: #fff0f1; color: #98131b; font-family: var(--mono); font-size: 9px; font-weight: 800; }
|
||||||
|
|
||||||
.stacked-form { display: grid; gap: 13px; }
|
.stacked-form { display: grid; gap: 13px; }
|
||||||
.wide-form { width: 100%; max-width: 850px; }
|
.wide-form { width: 100%; max-width: 850px; }
|
||||||
.settings-form { display: grid; gap: 12px; }
|
.settings-form { display: grid; gap: 12px; }
|
||||||
@@ -268,6 +309,9 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.switch input:checked + span { background: var(--success); }
|
.switch input:checked + span { background: var(--success); }
|
||||||
.switch input:checked + span::after { transform: translateX(12px); }
|
.switch input:checked + span::after { transform: translateX(12px); }
|
||||||
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
||||||
|
.switch.pihole-switch > span { width: 32px; height: 20px; }
|
||||||
|
.switch.pihole-switch > span::after { top: 4px; left: 4px; width: 12px; height: 12px; }
|
||||||
|
.switch.pihole-switch input:checked + span::after { transform: translateX(12px); }
|
||||||
.switch-disabled { color: var(--muted); opacity: .65; }
|
.switch-disabled { color: var(--muted); opacity: .65; }
|
||||||
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
||||||
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
||||||
@@ -286,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; }
|
||||||
@@ -307,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; }
|
||||||
@@ -320,8 +457,79 @@ 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; }
|
||||||
|
|
||||||
.error-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
.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 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; }
|
||||||
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||||
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
||||||
@@ -349,6 +557,24 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
||||||
.edit-inner { grid-template-columns: 1fr; }
|
.edit-inner { grid-template-columns: 1fr; }
|
||||||
.settings-mode-grid { grid-template-columns: 1fr; }
|
.settings-mode-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) {
|
||||||
@@ -383,14 +609,86 @@ 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; }
|
||||||
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
||||||
.error-item > a { grid-column: 2; }
|
.error-item > a { grid-column: 2; }
|
||||||
|
.pihole-action-grid { grid-template-columns: 1fr; }
|
||||||
|
.pihole-connect-stage { min-height: 0; }
|
||||||
|
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
|
||||||
|
.software-update-banner-actions { grid-column: 1 / -1; }
|
||||||
|
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
|
||||||
|
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
|
||||||
|
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||||
|
.software-version-action { grid-column: 1 / -1; }
|
||||||
|
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||||
|
.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>
|
||||||
@@ -41,12 +41,10 @@
|
|||||||
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Verknüpfung trennen</strong>
|
<strong>Verknüpfung trennen</strong>
|
||||||
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
||||||
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Ziel vollständig löschen</strong>
|
<strong>Ziel vollständig löschen</strong>
|
||||||
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -79,12 +77,10 @@
|
|||||||
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Aus WebUI entfernen</strong>
|
<strong>Aus WebUI entfernen</strong>
|
||||||
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
||||||
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<strong>Ziel vollständig löschen</strong>
|
<strong>Ziel vollständig löschen</strong>
|
||||||
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
|
|
||||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||||
</form></div>
|
</form></div>
|
||||||
</div></details>
|
</div></details>
|
||||||
@@ -110,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>
|
||||||
@@ -120,7 +116,7 @@
|
|||||||
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
|
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
|
||||||
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" 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">Token entschlüsseln</button></form></details>
|
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" 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">Token entschlüsseln</button></form></details>
|
||||||
<div class="panel-divider"></div>
|
<div class="panel-divider"></div>
|
||||||
<details><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
|
<details data-scroll-page-end><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "errors"}}{{template "layout" .}}{{end}}
|
{{define "errors"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
|
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.</p></div>
|
||||||
<form action="/errors/clear" method="post">
|
<form action="/errors/clear" method="post">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||||
@@ -9,11 +9,12 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if eq .ErrorCount 0}}
|
{{if eq .ErrorCount 0}}
|
||||||
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder DNS- oder Pi-hole-Fehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
||||||
{{else}}
|
{{else}}
|
||||||
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
||||||
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
||||||
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
||||||
|
<article><span>Pi-hole</span><strong>{{len .ErrorPiHoles}}</strong></article>
|
||||||
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,6 +29,17 @@
|
|||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ErrorPiHoles}}
|
||||||
|
<section class="panel error-section">
|
||||||
|
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
|
||||||
|
<div class="error-list">
|
||||||
|
{{range .ErrorPiHoles}}
|
||||||
|
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .ErrorZones}}
|
{{if .ErrorZones}}
|
||||||
<section class="panel error-section">
|
<section class="panel error-section">
|
||||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||||
|
|||||||
@@ -5,33 +5,33 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-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}} · Hetzner DynDNS</title>
|
<title>{{.Title}} · DNS Control</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=features-5">
|
<link rel="stylesheet" href="/static/styles.css?v=features-23">
|
||||||
<script src="/static/app.js?v=features-5" defer></script>
|
<script src="/static/app.js?v=features-23" 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>
|
||||||
{{if and (ne .Page "setup") (ne .Page "login")}}
|
{{if and (ne .Page "setup") (ne .Page "login")}}
|
||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
|
<a class="brand" href="/" aria-label="DNS Control Startseite">
|
||||||
<span class="brand-mark">DD</span>
|
<span class="brand-mark">DD</span>
|
||||||
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
|
<span><strong>DNS Control</strong><small>Hetzner + Pi-hole</small></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
||||||
<nav aria-label="Hauptnavigation">
|
<nav aria-label="Hauptnavigation">
|
||||||
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
||||||
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
|
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
|
||||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
|
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
|
||||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</a>
|
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</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}}06{{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>
|
||||||
@@ -40,9 +40,19 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header class="workspace-bar">
|
<header class="workspace-bar">
|
||||||
<div><span>DynDNS</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.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{{end}}
|
||||||
<main id="main" class="app-shell">
|
<main id="main" class="app-shell">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
@@ -66,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}}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{{define "pihole"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<header class="page-heading">
|
||||||
|
<div><span class="page-kicker pihole-kicker">Lokales Netzwerk</span><h1>Pi-hole DNS</h1><p>Lokale A- und AAAA-Auflösungen ähnlich wie deine öffentlichen Domains verwalten.</p></div>
|
||||||
|
{{if .PiHoleView}}
|
||||||
|
<form action="/pihole/{{.PiHoleView.Integration.ID}}/refresh" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .PiHoleView}}
|
||||||
|
{{$view := .PiHoleView}}
|
||||||
|
{{$integration := $view.Integration}}
|
||||||
|
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
|
||||||
|
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
|
||||||
|
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div>
|
||||||
|
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
|
||||||
|
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if $view.FetchError}}
|
||||||
|
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="targets-section" id="local-dns">
|
||||||
|
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
|
||||||
|
{{if $view.Records}}
|
||||||
|
<div class="target-list">
|
||||||
|
{{range $view.Records}}
|
||||||
|
<article class="target-row pihole-target-row {{if .Active}}is-active{{end}}">
|
||||||
|
<div class="target-main"><span class="target-status {{if .Active}}pihole-on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.Hostnames}}</h3><p><span class="family-chip">{{if contains .IP ":"}}AAAA{{else}}A{{end}}</span><span>{{.IP}}</span></p></div></div>
|
||||||
|
<div class="target-meta"><span>Pi-hole DNS</span><span class="status-pill {{if .Active}}pihole-status-pill{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
|
||||||
|
<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||||
|
{{if .Valid}}
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/update" method="post" class="target-form pihole-record-form" data-dirty-form>
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}">
|
||||||
|
<div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> Eintrag aktiv</label></div>
|
||||||
|
<div class="form-grid"><label>IP-Adresse<input name="ip" value="{{.IP}}" inputmode="text" required></label><label>Hostname(n)<input name="hostnames" value="{{.Hostnames}}" required></label></div>
|
||||||
|
<button class="button button-pihole button-compact pihole-record-save" type="submit" data-dirty-submit disabled aria-disabled="true">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="inline-error">Der vorhandene Pi-hole-Wert kann nicht strukturiert bearbeitet werden: <code>{{.Raw}}</code></p>
|
||||||
|
{{end}}
|
||||||
|
<div class="danger-actions">
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/archive" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Nur aus der WebUI entfernen</strong><button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button></form>
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Eintrag vollständig löschen</strong><button class="button button-danger-soft" type="submit">Eintrag löschen</button></form>
|
||||||
|
</div>
|
||||||
|
</div></details>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}<div class="empty-state">Keine verknüpften lokalen DNS-Einträge vorhanden. Lege unten einen neuen Eintrag an.</div>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if $view.ArchivedRecords}}
|
||||||
|
<section class="panel archive-panel">
|
||||||
|
<div class="panel-heading"><div><span class="section-label pihole-kicker">Archiv</span><h2>Aus der WebUI entfernte Einträge</h2></div><span class="record-count">{{len $view.ArchivedRecords}} Einträge</span></div>
|
||||||
|
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
|
||||||
|
<div class="archive-list">
|
||||||
|
{{range $view.ArchivedRecords}}
|
||||||
|
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="action-grid admin-actions-grid pihole-action-grid">
|
||||||
|
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
|
||||||
|
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
|
||||||
|
</article>
|
||||||
|
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
|
||||||
|
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
|
||||||
|
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<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>
|
||||||
|
<details data-scroll-page-end><summary class="text-danger">Pi-hole-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/pihole/{{$integration.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich Zugang und lokale Verknüpfungen. Alle DNS-Einträge bei Pi-hole bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/pihole/{{$integration.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><strong>Pi-hole vollständig leeren</strong><p>Löscht alle lokalen DNS-Einträge bei Pi-hole und entfernt anschließend die Verbindung aus der WebUI.</p><button class="button button-danger-soft" type="submit">Verbindung und Einträge löschen</button></form></div></details>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<section class="pihole-connect-stage" aria-labelledby="pihole-connect-title">
|
||||||
|
<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>
|
||||||
|
<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="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>
|
||||||
|
</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,61 @@
|
|||||||
|
{{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 not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
|
||||||
|
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
|
||||||
|
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="software-version-action">
|
||||||
|
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
|
||||||
|
<form action="/system/update" method="post" data-software-update-form>
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
||||||
|
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||||
|
</form>
|
||||||
|
{{else if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.ComposeSyncRequired}}
|
||||||
|
<span class="status-pill status-warn">Compose aktualisieren</span>
|
||||||
|
{{else if .SoftwareUpdate.UpdateAvailable}}
|
||||||
|
<span class="status-pill status-warn">Agent nicht erreichbar</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
|
||||||
|
{{end}}
|
||||||
|
</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}}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{{define "token"}}{{template "layout" .}}{{end}}
|
{{define "token"}}{{template "layout" .}}{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
|
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>{{.SecretTitle}}</h1><p><strong>{{.SecretScope}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="secret-value">{{.SecretValue}}</code><button class="button button-primary" type="button" data-copy-target="secret-value">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="{{.SecretBackURL}}">Zurückkehren</a></section>
|
||||||
{{end}}
|
{{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