From bb60c06c0e6df32ef255c977f47908dc04e43b5b Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 29 Aug 2026 11:30:43 +0200 Subject: [PATCH] feat: establish secure update foundation --- AGENTS.md | 18 +- Dockerfile | 35 +- README.md | 63 ++-- UPDATE.md | 32 +- cmd/dyndns-web/main.go | 37 +- cmd/dyndns-web/main_test.go | 24 ++ cmd/update-agent/main.go | 88 ----- compose.yml | 52 +-- internal/appupdate/manager.go | 75 +++- internal/appupdate/manager_test.go | 72 +++- internal/appupdate/version.go | 57 +-- internal/appupdate/version_test.go | 1 + internal/buildinfo/version.go | 18 +- internal/buildinfo/version_test.go | 50 +++ internal/secure/secure.go | 24 +- internal/secure/secure_test.go | 24 ++ internal/store/store.go | 13 + internal/store/store_test.go | 58 +++ internal/updateagent/controller.go | 17 +- internal/updateagent/controller_test.go | 16 +- internal/updateagent/docker.go | 459 +++++++++++++++++++++++- internal/updateagent/docker_test.go | 205 +++++++++++ internal/updateagent/server.go | 119 ++++++ internal/updateagent/server_test.go | 53 +++ internal/updateprotocol/protocol.go | 44 +++ internal/web/server_test.go | 12 +- internal/web/templates/layout.html | 6 +- internal/web/templates/system.html | 11 +- 28 files changed, 1403 insertions(+), 280 deletions(-) create mode 100644 cmd/dyndns-web/main_test.go delete mode 100644 cmd/update-agent/main.go create mode 100644 internal/buildinfo/version_test.go create mode 100644 internal/updateagent/docker_test.go create mode 100644 internal/updateagent/server.go create mode 100644 internal/updateagent/server_test.go diff --git a/AGENTS.md b/AGENTS.md index f56aeca..dde0bdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,13 +44,18 @@ Ersetze die bisherige Version mindestens an diesen Stellen: - `compose.yml` - Image-Beispiel in `README.md` +- Versionshinweis in `UPDATE.md` +- `ARG VERSION` in `Dockerfile` +- Fallback-Version in `internal/buildinfo/version.go` -Danach dürfen diese Dateien die vorherige Version nicht mehr enthalten. Setze `PREVIOUS` auf die tatsächlich vorherige Version: +`MINIMUM_UPDATE_VERSION`, `MinimumUpdateVersion`, `MinimumAgentVersion` und die Vertragsnummern sind Kompatibilitätsgrenzen. Ändere sie nicht bei normalen Releases, sondern nur bei einem bewusst geplanten Vertragsbruch mit passender Migration und Dokumentation. + +Danach dürfen die gepinnten Image-Referenzen nicht mehr auf die vorherige Version zeigen. Historische Hinweise zur v1.4-Update-Basis bleiben bestehen. Setze `PREVIOUS` auf die tatsächlich vorherige Version: ```bash PREVIOUS=v1.2.1 -if grep -n "$PREVIOUS" README.md compose.yml; then - echo "Alte Version $PREVIOUS ist noch eingetragen." >&2 +if grep -n "domain-control-webinterface-prod:$PREVIOUS" README.md compose.yml; then + echo "Alte Image-Version $PREVIOUS ist noch eingetragen." >&2 exit 1 fi docker compose -f compose.yml config --quiet @@ -133,13 +138,13 @@ git ls-remote origin refs/heads/main refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}' ## 5. Container-Image veröffentlichen -Ein `v*`-Tag startet den Gitea-Workflow. Prüfe zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden. +Wenn Repository-Actions aktiviert sind, startet ein `v*`-Tag den Gitea-Workflow. Prüfe dann zuerst in Gitea, ob der Run tatsächlich läuft und erfolgreich endet. Wenn der Workflow erfolgreich ist, darf nicht zusätzlich manuell in dieselben Tags gepusht werden. Sind Repository-Actions deaktiviert, verwende ausschließlich den manuellen Multi-Arch-Fallback. ### Bekannter Gitea-Runner-Zustand -Stand 13.07.2026 ist kein Gitea-Actions-Runner aktiv. Die Runs bleiben auf `queued`. Der übliche Cancel-Endpunkt ist in dieser Gitea-Version nicht vorhanden; das Löschen wartender Runs antwortete mit HTTP 400. Verändere deshalb nicht direkt die Gitea-Datenbank. Prüfe den Zustand bei jedem Release neu. +Stand 29.08.2026 ist kein Gitea-Actions-Runner aktiv. Repository-Actions sind deshalb deaktiviert. Run 7 für `v1.3.0` liegt weiterhin intern auf `queued`, kann im deaktivierten Zustand aber nicht ausgeführt werden; sein Commit-Status wurde nach der bereits abgeschlossenen manuellen Veröffentlichung auf `success` korrigiert. Gitea 1.26.4 bietet keinen API-Endpunkt zum Abbrechen einzelner Runs. Verändere deshalb nicht direkt die Gitea-Datenbank. -Bevor später ein Runner aktiviert wird, müssen die alten wartenden Release-Runs in Gitea bereinigt werden. Andernfalls könnten sie veraltete Images erneut als `stable` veröffentlichen. +Bevor Actions oder ein Runner später aktiviert werden, muss Run 7 in der Gitea-Weboberfläche abgebrochen werden. Andernfalls könnte er das veraltete Image erneut als `stable` veröffentlichen. Aktiviere Actions erst danach und nur zusammen mit einem funktionsfähigen Runner. ### Manueller Multi-Arch-Fallback @@ -185,6 +190,7 @@ Baue und pushe anschließend beide Plattformen in einem gemeinsamen Manifest: docker buildx build \ --builder domain-control-release-builder \ --platform linux/amd64,linux/arm64 \ + --build-arg "VERSION=$VERSION" \ --tag "git.j-mahler.de/admin/domain-control-webinterface-prod:$VERSION" \ --tag "git.j-mahler.de/admin/domain-control-webinterface-prod:stable" \ --label org.opencontainers.image.source=https://git.j-mahler.de/admin/hetzner-api-dyndns-records-webinterface \ diff --git a/Dockerfile b/Dockerfile index 96ec697..18364a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,14 @@ # syntax=docker/dockerfile:1.7 +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=v1.3.0 +ARG VERSION RUN apk add --no-cache build-base WORKDIR /src @@ -10,23 +17,37 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/dyndns-web ./cmd/dyndns-web && \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/update-agent ./cmd/update-agent +RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/domain-control ./cmd/dyndns-web FROM alpine:3.23.2 AS runtime +ARG VERSION +ARG MINIMUM_UPDATE_VERSION +ARG MINIMUM_AGENT_PROTOCOL +ARG RUNTIME_CONTRACT_VERSION +ARG DATABASE_CONTRACT_VERSION +ARG SCHEMA_VERSION + RUN apk add --no-cache ca-certificates && \ addgroup -S -g 10001 dyndns && \ adduser -S -D -H -u 10001 -G dyndns dyndns && \ mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets -COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web -COPY --from=build /out/update-agent /usr/local/bin/update-agent +COPY --from=build /out/domain-control /usr/local/bin/domain-control +RUN ln -s domain-control /usr/local/bin/update-agent + +LABEL org.opencontainers.image.version="${VERSION}" \ + de.j-mahler.domain-control.minimum-update-version="${MINIMUM_UPDATE_VERSION}" \ + de.j-mahler.domain-control.minimum-agent-protocol="${MINIMUM_AGENT_PROTOCOL}" \ + de.j-mahler.domain-control.runtime-contract="${RUNTIME_CONTRACT_VERSION}" \ + de.j-mahler.domain-control.database-contract="${DATABASE_CONTRACT_VERSION}" \ + de.j-mahler.domain-control.schema-version="${SCHEMA_VERSION}" USER dyndns:dyndns WORKDIR /data EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dyndns-web", "healthcheck"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/domain-control", "healthcheck"] -ENTRYPOINT ["/usr/local/bin/dyndns-web"] +ENTRYPOINT ["/usr/local/bin/domain-control"] +CMD ["serve"] diff --git a/README.md b/README.md index d7afae9..0175d5c 100644 --- a/README.md +++ b/README.md @@ -23,54 +23,35 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i ## Das Compose ```yaml -x-domain-control-image: &domain-control-image - image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 - services: dyndns: - <<: *domain-control-image + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0 restart: unless-stopped - 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 + stop_grace_period: 20s + ports: ["9090:8080"] + volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"] + labels: {de.j-mahler.domain-control.role: app} read_only: true - tmpfs: - - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 - security_opt: - - no-new-privileges:true - cap_drop: - - ALL + tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"] + security_opt: [no-new-privileges:true] + cap_drop: [ALL] update-agent: - <<: *domain-control-image + image: *image restart: unless-stopped user: "0:10001" - entrypoint: [/usr/local/bin/update-agent] - healthcheck: - disable: true + 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 + 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 + 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: +volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}} ``` Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden. @@ -80,18 +61,20 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose, Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md). -Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. +Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. 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 die eine gepinnte Image-Zeile unter `x-domain-control-image` ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend: +Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend: ```bash docker compose pull -docker compose up -d +docker compose up -d --force-recreate ``` -Compose ersetzt geänderte Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes; wer später wieder per Compose aktualisiert, setzt dort einfach die gewünschte konkrete Version und führt die beiden Befehle aus. +Compose ersetzt beide Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes. Vor einem späteren manuellen `docker compose up` muss der dort gepinnte Tag deshalb auf die gewünschte Version gesetzt werden. Meldet die WebUI **Compose aktualisieren**, unterstützt der vorhandene Agent den benötigten Vertrag nicht und beide Container werden auf diesem Weg gemeinsam aktualisiert. Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden. @@ -108,7 +91,7 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü - eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung - lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen -Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Der Master-Key liegt getrennt mit Dateimodus `0600`; Datenverzeichnis und SQLite-Datei werden auf `0700` beziehungsweise `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen. +Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Secret- und Datenverzeichnis werden auf `0700`, Master-Key und SQLite-Datei auf `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen. Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`. diff --git a/UPDATE.md b/UPDATE.md index 9cc0d5d..4203201 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -1,6 +1,6 @@ # Domain Control aktualisieren -Die aktuelle Compose-Version ist `v1.3.0`. Datenbank, Einstellungen und +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. @@ -15,22 +15,35 @@ den folgenden Verfahren erhalten. Container wiederherzustellen. Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden -DynDNS-Dienst nicht. +DynDNS-Dienst nicht. Direkte Sprünge über mehrere Versionen sind möglich, wenn +das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese +Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel +geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt. + +Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich +`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach +kann der Agent auch dann weitere kompatible Releases installieren, wenn seine +eigene Versionsnummer älter als die der Web-App ist. Erscheint **Compose +aktualisieren**, benötigt das Ziel einen neueren Agent-Vertrag; dann beide +Container wie unten beschrieben gemeinsam aktualisieren. ## Mit Docker Compose In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: ```yaml -x-domain-control-image: &domain-control-image - image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 +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 +docker compose up -d --force-recreate docker compose ps ``` @@ -39,6 +52,11 @@ Version. Für das Update ist kein `docker compose down` nötig. Insbesondere niemals `docker compose down -v` verwenden, da `-v` die Daten- und 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 @@ -50,7 +68,7 @@ Die erwartete Antwort lautet `ok`. ## Portainer oder Dockge 1. Den bestehenden Stack bearbeiten. -2. Nur den Tag der zentralen Image-Zeile auf `v1.3.0` setzen. +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. @@ -63,7 +81,7 @@ zuvor eingesetzte konkrete Version setzen und erneut ausführen: ```bash docker compose pull -docker compose up -d +docker compose up -d --force-recreate ``` Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go index 31e17c5..ae00724 100644 --- a/cmd/dyndns-web/main.go +++ b/cmd/dyndns-web/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -18,20 +19,52 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/web" ) func main() { - if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + command := commandForInvocation(os.Args) + if command == "healthcheck" { os.Exit(healthcheck()) } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - if err := run(logger); err != nil { + var err error + switch command { + case "serve": + err = run(logger) + case "update-agent": + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err = updateagent.Serve(ctx, updateagent.ServerConfigFromEnvironment(), logger) + default: + err = errors.New("unknown command; expected serve, healthcheck, or update-agent") + } + if err != nil { logger.Error("application stopped", "error", err) os.Exit(1) } } +func commandForInvocation(arguments []string) string { + if len(arguments) == 0 { + return "invalid" + } + // v1.3 Compose files override the agent entrypoint and inherit the image's + // default "serve" command. Keep that one-time manual upgrade path working + // while the image itself still contains only one executable. + if filepath.Base(arguments[0]) == "update-agent" && (len(arguments) == 1 || (len(arguments) == 2 && arguments[1] == "serve")) { + return "update-agent" + } + if len(arguments) == 1 { + return "serve" + } + if len(arguments) == 2 { + return arguments[1] + } + return "invalid" +} + func run(logger *slog.Logger) error { // Credentials and session data must never inherit a permissive process umask. syscall.Umask(0o077) diff --git a/cmd/dyndns-web/main_test.go b/cmd/dyndns-web/main_test.go new file mode 100644 index 0000000..bb60094 --- /dev/null +++ b/cmd/dyndns-web/main_test.go @@ -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) + } + } +} diff --git a/cmd/update-agent/main.go b/cmd/update-agent/main.go deleted file mode 100644 index f8d48de..0000000 --- a/cmd/update-agent/main.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "context" - "errors" - "log/slog" - "net" - "net/http" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent" -) - -func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - if err := run(logger); err != nil { - logger.Error("update agent stopped", "error", err) - os.Exit(1) - } -} - -func run(logger *slog.Logger) error { - socket := env("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock") - dockerSocket := env("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock") - image := env("UPDATE_IMAGE_REPOSITORY", "git.j-mahler.de/admin/domain-control-webinterface-prod") - targetLabel := env("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app") - directory := filepath.Dir(socket) - if err := os.MkdirAll(directory, 0o711); err != nil { - return err - } - if err := os.Chmod(directory, 0o711); err != nil { - return err - } - if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - listener, err := net.Listen("unix", socket) - if err != nil { - return err - } - defer listener.Close() - defer os.Remove(socket) - // The container runs as uid 0 with gid 10001. Group-only access lets the - // unprivileged web process connect without exposing the socket to others. - if err := os.Chmod(socket, 0o660); err != nil { - return err - } - runner := updateagent.NewDockerRunner(dockerSocket, image, targetLabel, logger) - controller := updateagent.NewController(runner, logger) - server := &http.Server{ - Handler: controller.Handler(), - ReadHeaderTimeout: 3 * time.Second, - ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - IdleTimeout: 15 * time.Second, - MaxHeaderBytes: 8 << 10, - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - serverErrors := make(chan error, 1) - go func() { - logger.Info("update agent ready", "socket", socket) - serverErrors <- server.Serve(listener) - }() - select { - case err := <-serverErrors: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return server.Shutdown(shutdownCtx) - } -} - -func env(name, fallback string) string { - if value := strings.TrimSpace(os.Getenv(name)); value != "" { - return value - } - return fallback -} diff --git a/compose.yml b/compose.yml index b2a6145..6c34f4c 100644 --- a/compose.yml +++ b/compose.yml @@ -1,49 +1,29 @@ -x-domain-control-image: &domain-control-image - image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 - services: dyndns: - <<: *domain-control-image + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.0 restart: unless-stopped - 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 + stop_grace_period: 20s + ports: ["9090:8080"] + volumes: ["dyndns-data:/data", "dyndns-secrets:/secrets", "dyndns-update-control:/run/domain-control-update"] + labels: {de.j-mahler.domain-control.role: app} read_only: true - tmpfs: - - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 - security_opt: - - no-new-privileges:true - cap_drop: - - ALL + tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777"] + security_opt: [no-new-privileges:true] + cap_drop: [ALL] update-agent: - <<: *domain-control-image + image: *image restart: unless-stopped user: "0:10001" - entrypoint: - - /usr/local/bin/update-agent - healthcheck: - disable: true + 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 + 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 + 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: +volumes: {dyndns-data: {}, dyndns-secrets: {}, dyndns-update-control: {}} diff --git a/internal/appupdate/manager.go b/internal/appupdate/manager.go index 22d6b28..fd822b2 100644 --- a/internal/appupdate/manager.go +++ b/internal/appupdate/manager.go @@ -32,7 +32,11 @@ type State struct { CredentialConfigured bool UpdateAvailable bool AgentAvailable bool + AgentCompatible bool AgentStatus updateprotocol.Status + AgentVersion string + AgentProtocol int + ComposeSyncRequired bool } type Manager struct { @@ -76,6 +80,7 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin }, }, } + settings = manager.reconcileInstalledVersion(context.Background(), settings) manager.setStoredState(settings) return manager, nil } @@ -192,6 +197,12 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { 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 @@ -203,8 +214,11 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { return err } - request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token} + 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) @@ -217,11 +231,26 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { 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 } @@ -290,11 +319,55 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) { } 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() diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go index cb4696a..b606cb6 100644 --- a/internal/appupdate/manager_test.go +++ b/internal/appupdate/manager_test.go @@ -13,8 +13,10 @@ import ( "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) { @@ -31,7 +33,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`)) + _, _ = w.Write([]byte(`{"tag_name":"v1.4.1","html_url":"https://example.invalid/releases/v1.4.1","draft":false,"prerelease":false}`)) })) defer api.Close() @@ -64,7 +66,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal(err) } state := manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" { + if !state.UpdateAvailable || state.LatestVersion != "v1.4.1" || state.LastError != "" { t.Fatalf("new release was not detected: %#v", state) } settings, err := database.GetSoftwareUpdateSettings(ctx) @@ -80,7 +82,71 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal("unavailable Gitea endpoint was reported as successful") } state = manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" { + 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) + } +} diff --git a/internal/appupdate/version.go b/internal/appupdate/version.go index 0c5e72c..5905157 100644 --- a/internal/appupdate/version.go +++ b/internal/appupdate/version.go @@ -1,66 +1,15 @@ package appupdate -import ( - "fmt" - "strconv" - "strings" - - "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" -) - -type semanticVersion struct { - major int - minor int - patch int -} - -func parseVersion(raw string) (semanticVersion, error) { - if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") { - return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH") - } - parts := strings.Split(strings.TrimPrefix(raw, "v"), ".") - if len(parts) != 3 { - return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH") - } - values := make([]int, 3) - for index, part := range parts { - if part == "" || (len(part) > 1 && part[0] == '0') { - return semanticVersion{}, fmt.Errorf("version contains an invalid number") - } - value, err := strconv.Atoi(part) - if err != nil || value < 0 || value > 1_000_000 { - return semanticVersion{}, fmt.Errorf("version contains an invalid number") - } - values[index] = value - } - return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil -} +import "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" func compareVersions(left, right string) (int, error) { - a, err := parseVersion(left) - if err != nil { - return 0, err - } - b, err := parseVersion(right) - if err != nil { - return 0, err - } - av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch} - for index := range av { - if av[index] < bv[index] { - return -1, nil - } - if av[index] > bv[index] { - return 1, nil - } - } - return 0, nil + return updateprotocol.CompareVersions(left, right) } func validReleaseVersion(version string) bool { if !updateprotocol.ValidVersion(version) { return false } - _, err := parseVersion(version) + _, err := updateprotocol.CompareVersions(version, version) return err == nil } diff --git a/internal/appupdate/version_test.go b/internal/appupdate/version_test.go index 8a51330..aa80ab3 100644 --- a/internal/appupdate/version_test.go +++ b/internal/appupdate/version_test.go @@ -10,6 +10,7 @@ func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) { 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}, } diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index cc20f2b..2decde0 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -1,5 +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.3.0" +var Version = "v1.4.0" diff --git a/internal/buildinfo/version_test.go b/internal/buildinfo/version_test.go new file mode 100644 index 0000000..6e05d51 --- /dev/null +++ b/internal/buildinfo/version_test.go @@ -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) + } + } +} diff --git a/internal/secure/secure.go b/internal/secure/secure.go index 2d9f366..b787b8e 100644 --- a/internal/secure/secure.go +++ b/internal/secure/secure.go @@ -37,6 +37,16 @@ type Cipher struct { } func LoadCipher(path string) (*Cipher, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect master key: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("master key must be a regular file") + } + if err := os.Chmod(path, 0o600); err != nil { + return nil, fmt.Errorf("secure master key: %w", err) + } raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read master key: %w", err) @@ -47,6 +57,17 @@ func LoadCipher(path string) (*Cipher, error) { // LoadOrCreateCipher loads an existing master key or creates one on first start. // The key must live on a persistent volume separate from the application data. func LoadOrCreateCipher(path string) (*Cipher, error) { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, fmt.Errorf("create master key directory: %w", err) + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("master key directory must be a regular directory") + } + if err := os.Chmod(directory, 0o700); err != nil { + return nil, fmt.Errorf("secure master key directory: %w", err) + } cipher, err := LoadCipher(path) if err == nil { return cipher, nil @@ -55,9 +76,6 @@ func LoadOrCreateCipher(path string) (*Cipher, error) { return nil, err } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, fmt.Errorf("create master key directory: %w", err) - } key := make([]byte, chacha20poly1305.KeySize) if _, err := rand.Read(key); err != nil { return nil, fmt.Errorf("create master key: %w", err) diff --git a/internal/secure/secure_test.go b/internal/secure/secure_test.go index eb078ea..efe909f 100644 --- a/internal/secure/secure_test.go +++ b/internal/secure/secure_test.go @@ -56,6 +56,13 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) { if permissions := info.Mode().Perm(); permissions != 0o600 { t.Fatalf("master key permissions = %o, want 600", permissions) } + directoryInfo, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if permissions := directoryInfo.Mode().Perm(); permissions != 0o700 { + t.Fatalf("master key directory permissions = %o, want 700", permissions) + } ciphertext, nonce, err := first.Encrypt("persistent token") if err != nil { t.Fatal(err) @@ -70,6 +77,23 @@ func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) { } } +func TestLoadCipherRejectsSymlinkedMasterKey(t *testing.T) { + t.Parallel() + directory := t.TempDir() + target := filepath.Join(directory, "target") + key := make([]byte, 32) + if err := os.WriteFile(target, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(directory, "master_key") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := LoadCipher(link); err == nil { + t.Fatal("symlinked master key was accepted") + } +} + func TestPasswordHash(t *testing.T) { t.Parallel() hash, err := HashPassword("a secure password") diff --git a/internal/store/store.go b/internal/store/store.go index 6fa9727..9bf4db9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,6 +13,7 @@ import ( _ "github.com/mattn/go-sqlite3" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" ) @@ -128,6 +129,13 @@ func (s *Store) migrate(ctx context.Context) error { 300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil { 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() } @@ -861,6 +869,11 @@ func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) e return err } +func (s *Store) ClearDesiredSoftwareVersion(ctx context.Context, expected string) error { + _, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = '', updated_at = ? WHERE id = 1 AND desired_version = ?`, now(), expected) + return err +} + func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error { _, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9d588cd..dd560fd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,16 +2,60 @@ package store import ( "context" + "database/sql" "errors" "os" "path/filepath" "testing" "time" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" ) +func 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() @@ -280,6 +324,20 @@ func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) { if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" { 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) { diff --git a/internal/updateagent/controller.go b/internal/updateagent/controller.go index fc2cd4b..5f797ae 100644 --- a/internal/updateagent/controller.go +++ b/internal/updateagent/controller.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) @@ -26,7 +27,16 @@ type Controller struct { } func NewController(runner Runner, logger *slog.Logger) *Controller { - return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}} + return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)} +} + +func agentStatus(phase string) updateprotocol.Status { + return updateprotocol.Status{ + Phase: phase, + UpdatedAt: time.Now().UTC(), + AgentVersion: buildinfo.Version, + Protocol: buildinfo.UpdateProtocolVersion, + } } func (c *Controller) Handler() http.Handler { @@ -57,9 +67,10 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) { 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) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 { + 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 } @@ -80,7 +91,7 @@ func (c *Controller) begin(version string) bool { return false } now := time.Now().UTC() - c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now} + c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion} return true } diff --git a/internal/updateagent/controller_test.go b/internal/updateagent/controller_test.go index b36313f..2638668 100644 --- a/internal/updateagent/controller_test.go +++ b/internal/updateagent/controller_test.go @@ -56,9 +56,19 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { 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":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`) + body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`) accepted := httptest.NewRecorder() handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) if accepted.Code != http.StatusAccepted { @@ -66,7 +76,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { } select { case request := <-runner.started: - if request.Version != "v1.2.3" || request.RegistryToken != secret { + if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret { t.Fatalf("runner received an altered request: %#v", request) } case <-time.After(time.Second): @@ -80,7 +90,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { } 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"`) { + 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()) } diff --git a/internal/updateagent/docker.go b/internal/updateagent/docker.go index 0cd8613..37a27db 100644 --- a/internal/updateagent/docker.go +++ b/internal/updateagent/docker.go @@ -13,14 +13,30 @@ import ( "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 @@ -51,10 +67,6 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques if err := r.docker.negotiate(ctx); err != nil { return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err} } - progress(updateprotocol.PhasePulling, "Container-Image wird geladen") - if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil { - return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err} - } target, err := r.docker.findTarget(ctx, r.targetLabel) if err != nil { return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err} @@ -63,11 +75,42 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques 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().Unix()) + backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano()) var oldStopped, oldRenamed bool var newID string defer func() { @@ -76,7 +119,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques } rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil { + if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil { r.logger.Error("software update rollback failed", "error", rollbackErr) resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)} } @@ -91,7 +134,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques return err } oldRenamed = true - newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version) + newID, err = r.docker.createReplacement(ctx, inspected, originalName, replacementConfig) if err != nil { return err } @@ -99,7 +142,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques return err } progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft") - if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil { + if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil { return err } if err := r.docker.removeContainer(ctx, target.ID, false); err != nil { @@ -109,7 +152,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques return nil } -func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error { +func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error { var failures []string if newID != "" { if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) { @@ -127,6 +170,8 @@ func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, if oldStopped { if 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 { @@ -149,6 +194,7 @@ type containerSummary struct { 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"` @@ -158,6 +204,23 @@ type containerInspect struct { 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"` @@ -282,6 +345,28 @@ func (c *dockerClient) inspectContainer(ctx context.Context, id string) (contain 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) @@ -308,13 +393,338 @@ func (c *dockerClient) renameContainer(ctx context.Context, id, name string) err return dockerStatusError(response) } -func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) { - var body map[string]json.RawMessage - if err := json.Unmarshal(inspected.Config, &body); err != nil { - return "", fmt.Errorf("decode container config: %w", err) +func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) { + var config struct { + Image string `json:"Image"` + Labels map[string]string `json:"Labels"` } - imageJSON, _ := json.Marshal(image) - body["Image"] = imageJSON + 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 @@ -383,7 +793,7 @@ func (c *dockerClient) removeContainer(ctx context.Context, id string, force boo return dockerStatusError(response) } -func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error { +func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error { deadline := time.NewTimer(timeout) ticker := time.NewTicker(time.Second) defer deadline.Stop() @@ -404,9 +814,24 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time. 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 nil + return errors.New("replacement container has no healthcheck") } continue } diff --git a/internal/updateagent/docker_test.go b/internal/updateagent/docker_test.go new file mode 100644 index 0000000..58309d6 --- /dev/null +++ b/internal/updateagent/docker_test.go @@ -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 +} diff --git a/internal/updateagent/server.go b/internal/updateagent/server.go new file mode 100644 index 0000000..d1329ff --- /dev/null +++ b/internal/updateagent/server.go @@ -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 +} diff --git a/internal/updateagent/server_test.go b/internal/updateagent/server_test.go new file mode 100644 index 0000000..9ee92cd --- /dev/null +++ b/internal/updateagent/server_test.go @@ -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) + } +} diff --git a/internal/updateprotocol/protocol.go b/internal/updateprotocol/protocol.go index bb3b71b..908a2ab 100644 --- a/internal/updateprotocol/protocol.go +++ b/internal/updateprotocol/protocol.go @@ -1,7 +1,10 @@ package updateprotocol import ( + "fmt" "regexp" + "strconv" + "strings" "time" ) @@ -19,6 +22,7 @@ const ( type Request struct { Version string `json:"version"` + CurrentVersion string `json:"current_version"` RegistryUsername string `json:"registry_username"` RegistryToken string `json:"registry_token"` } @@ -29,6 +33,8 @@ type Status struct { 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 { @@ -38,3 +44,41 @@ func (s Status) Busy() bool { 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 +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 3d182a2..e3867c5 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -390,13 +390,13 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { t.Fatal(err) } admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} - updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true} + 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", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} { + for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-form`, `data-software-update-overlay`} { if !strings.Contains(notification.String(), expected) { t.Errorf("global update notification is missing %q", expected) } @@ -425,6 +425,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { 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) { diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 522ce77..fc4d827 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -6,8 +6,8 @@ {{.Title}} · DNS Control - - + + @@ -49,7 +49,7 @@
Neue Version verfügbar{{.SoftwareUpdate.LatestVersion}} ist bereit
Details - {{if .SoftwareUpdate.AgentAvailable}}
{{end}} + {{if .SoftwareUpdate.AgentCompatible}}
{{end}}
{{end}} diff --git a/internal/web/templates/system.html b/internal/web/templates/system.html index 01307d5..34833ce 100644 --- a/internal/web/templates/system.html +++ b/internal/web/templates/system.html @@ -13,19 +13,22 @@
Verfügbar{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}

{{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}}

-
Letzte Prüfung {{formatTime .SoftwareUpdate.LastCheckedAt}}Update-Agent {{if .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{end}}
+
Letzte Prüfung {{formatTime .SoftwareUpdate.LastCheckedAt}}Update-Agent {{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}
{{if .SoftwareUpdate.LastError}}
!

Prüfung fehlgeschlagen{{.SoftwareUpdate.LastError}}

{{end}} + {{if .SoftwareUpdate.ComposeSyncRequired}}
↻

Compose einmal aktualisierenDer installierte Agent unterstützt die sichere Update-Basis noch nicht.

{{end}}
- {{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}} + {{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
+ {{else if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.ComposeSyncRequired}} + Compose aktualisieren {{else if .SoftwareUpdate.UpdateAvailable}} - Update-Agent nicht erreichbar + Agent nicht erreichbar {{else}} - {{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{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}} {{end}}