diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 71a4f2f..d887e40 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -43,3 +43,5 @@ jobs: org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.version=${{ gitea.ref_name }} provenance: false + build-args: | + VERSION=${{ gitea.ref_name }} diff --git a/AGENTS.md b/AGENTS.md index 6e8fcf7..f56aeca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewu ```bash docker run --rm \ -v "$PWD":/src \ - -w /src golang:1.25.12-alpine \ + -w /src golang:1.25.13-alpine \ gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go ``` @@ -83,7 +83,7 @@ docker run --rm \ -v "$PWD":/src \ -v /tmp/dyndns-go-mod:/go/pkg/mod \ -v /tmp/dyndns-go-build:/root/.cache/go-build \ - -w /src golang:1.25.12-alpine \ + -w /src golang:1.25.13-alpine \ sh -c 'apk add --no-cache build-base >/dev/null && go vet ./... && go test -race -count=1 ./...' ``` @@ -94,7 +94,7 @@ docker run --rm \ -v "$PWD":/src \ -v /tmp/dyndns-go-mod:/go/pkg/mod \ -v /tmp/dyndns-go-build:/root/.cache/go-build \ - -w /src golang:1.25.12-alpine \ + -w /src golang:1.25.13-alpine \ sh -c 'apk add --no-cache build-base >/dev/null && go run golang.org/x/vuln/cmd/govulncheck@latest ./...' ``` diff --git a/Dockerfile b/Dockerfile index a33b30c..96ec697 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ # syntax=docker/dockerfile:1.7 -FROM golang:1.25.12-alpine AS build +FROM golang:1.25.13-alpine AS build + +ARG VERSION=v1.3.0 RUN apk add --no-cache build-base WORKDIR /src @@ -8,7 +10,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dyndns-web ./cmd/dyndns-web +RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/dyndns-web ./cmd/dyndns-web && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/update-agent ./cmd/update-agent FROM alpine:3.23.2 AS runtime @@ -18,6 +21,7 @@ RUN apk add --no-cache ca-certificates && \ 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 USER dyndns:dyndns WORKDIR /data diff --git a/README.md b/README.md index f4655cf..d7afae9 100644 --- a/README.md +++ b/README.md @@ -23,36 +23,76 @@ 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: - image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2 + <<: *domain-control-image 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 + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 security_opt: - no-new-privileges:true cap_drop: - ALL + update-agent: + <<: *domain-control-image + restart: unless-stopped + user: "0:10001" + entrypoint: [/usr/local/bin/update-agent] + healthcheck: + disable: true + network_mode: none + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dyndns-update-control:/run/domain-control-update + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 32 + mem_limit: 64m + volumes: dyndns-data: dyndns-secrets: + dyndns-update-control: ``` Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden. ## Aktualisieren -In `compose.yml` die Versionsnummer beim Image ändern, anschließend das Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile: +Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose, +Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md). + +Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. + +Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten. + +Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur die eine gepinnte Image-Zeile unter `x-domain-control-image` ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend: ```bash docker compose pull docker compose up -d ``` +Compose ersetzt geänderte Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes; wer später wieder per Compose aktualisiert, setzt dort einfach die gewünschte konkrete Version und führt die beiden Befehle aus. + Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden. ## Funktionen @@ -63,6 +103,8 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü - lokales Archiv statt Löschen von Hetzner-Records - frei einstellbares Update-Intervall mit Countdown - Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten +- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte +- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback - eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung - lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen diff --git a/UPDATE.md b/UPDATE.md new file mode 100644 index 0000000..9cc0d5d --- /dev/null +++ b/UPDATE.md @@ -0,0 +1,71 @@ +# Domain Control aktualisieren + +Die aktuelle Compose-Version ist `v1.3.0`. Datenbank, Einstellungen und +verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei +den folgenden Verfahren erhalten. + +## Direkt in der Weboberfläche + +1. Als Administrator anmelden und **Update** öffnen. +2. Falls noch nicht geschehen, Gitea-Benutzername und einen Token mit + Lesezugriff auf Repository und Container-Paket hinterlegen. +3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen. +4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen + sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen + Container wiederherzustellen. + +Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden +DynDNS-Dienst nicht. + +## Mit Docker Compose + +In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: + +```yaml +x-domain-control-image: &domain-control-image + image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 +``` + +Danach im Ordner der Compose-Datei ausführen: + +```bash +docker compose pull +docker compose up -d +docker compose ps +``` + +Die Web-App und der Update-Agent verwenden durch den YAML-Anker dieselbe +Version. Für das Update ist kein `docker compose down` nötig. Insbesondere +niemals `docker compose down -v` verwenden, da `-v` die Daten- und +Secret-Volumes löschen würde. + +Der Healthcheck lässt sich anschließend direkt prüfen: + +```bash +curl --fail http://127.0.0.1:9090/healthz +``` + +Die erwartete Antwort lautet `ok`. + +## Portainer oder Dockge + +1. Den bestehenden Stack bearbeiten. +2. Nur den Tag der zentralen Image-Zeile auf `v1.3.0` setzen. +3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. +4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. + +Den Stack nicht mitsamt seinen Volumes löschen oder neu anlegen. + +## Rollback + +Falls ein manuelles Rollback nötig ist, die zentrale Image-Zeile wieder auf die +zuvor eingesetzte konkrete Version setzen und erneut ausführen: + +```bash +docker compose pull +docker compose up -d +``` + +Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und +`dyndns-secrets` gemeinsam gesichert werden. Nur zusammen enthalten sie sowohl +die Datenbank als auch den zur Entschlüsselung benötigten Master-Key. diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go index 530f351..31e17c5 100644 --- a/cmd/dyndns-web/main.go +++ b/cmd/dyndns-web/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve" @@ -53,6 +54,10 @@ func run(logger *slog.Logger) error { } provider := hetzner.New(cfg.HCloudBaseURL) updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger) + softwareUpdates, err := appupdate.New(database, cipher, cfg.UpdateAPIURL, cfg.UpdateAgentSock, cfg.UpdateInterval, logger) + if err != nil { + return err + } settings, err := database.GetSettings(ctx) if err != nil { return err @@ -65,7 +70,7 @@ func run(logger *slog.Logger) error { logger.Info("removed AAAA records for disabled IPv6", "count", deleted) } } - serverUI, err := web.New(cfg, database, cipher, updater, logger) + serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger) if err != nil { return err } @@ -73,6 +78,8 @@ func run(logger *slog.Logger) error { appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() updater.Start(appCtx) + softwareUpdates.Start(appCtx) + go pruneStore(appCtx, database, logger) httpServer := &http.Server{ Addr: cfg.Address, Handler: serverUI.Handler(), @@ -80,6 +87,7 @@ func run(logger *slog.Logger) error { ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 16 << 10, } serverErrors := make(chan error, 1) go func() { @@ -100,6 +108,24 @@ func run(logger *slog.Logger) error { } } +func pruneStore(ctx context.Context, database *store.Store, logger *slog.Logger) { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pruneCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + err := database.Prune(pruneCtx) + cancel() + if err != nil && !errors.Is(err, context.Canceled) { + logger.Warn("prune old local data", "error", err) + } + } + } +} + func healthcheck() int { url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL")) if url == "" { diff --git a/cmd/update-agent/main.go b/cmd/update-agent/main.go new file mode 100644 index 0000000..f8d48de --- /dev/null +++ b/cmd/update-agent/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + if err := run(logger); err != nil { + logger.Error("update agent stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + socket := env("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock") + dockerSocket := env("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock") + image := env("UPDATE_IMAGE_REPOSITORY", "git.j-mahler.de/admin/domain-control-webinterface-prod") + targetLabel := env("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app") + directory := filepath.Dir(socket) + if err := os.MkdirAll(directory, 0o711); err != nil { + return err + } + if err := os.Chmod(directory, 0o711); err != nil { + return err + } + if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(socket) + // The container runs as uid 0 with gid 10001. Group-only access lets the + // unprivileged web process connect without exposing the socket to others. + if err := os.Chmod(socket, 0o660); err != nil { + return err + } + runner := updateagent.NewDockerRunner(dockerSocket, image, targetLabel, logger) + controller := updateagent.NewController(runner, logger) + server := &http.Server{ + Handler: controller.Handler(), + ReadHeaderTimeout: 3 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 15 * time.Second, + MaxHeaderBytes: 8 << 10, + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + serverErrors := make(chan error, 1) + go func() { + logger.Info("update agent ready", "socket", socket) + serverErrors <- server.Serve(listener) + }() + select { + case err := <-serverErrors: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + } +} + +func env(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} diff --git a/compose.yml b/compose.yml index a6a9bd3..b2a6145 100644 --- a/compose.yml +++ b/compose.yml @@ -1,12 +1,18 @@ +x-domain-control-image: &domain-control-image + image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0 + services: dyndns: - image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2 + <<: *domain-control-image 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 read_only: true tmpfs: - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 @@ -15,6 +21,29 @@ services: cap_drop: - ALL + update-agent: + <<: *domain-control-image + restart: unless-stopped + user: "0:10001" + entrypoint: + - /usr/local/bin/update-agent + healthcheck: + disable: true + network_mode: none + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dyndns-update-control:/run/domain-control-update + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 32 + mem_limit: 64m + volumes: dyndns-data: dyndns-secrets: + dyndns-update-control: diff --git a/go.mod b/go.mod index 28de4fa..7e51998 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface go 1.25.0 +toolchain go1.25.13 + require ( github.com/mattn/go-sqlite3 v1.14.47 golang.org/x/crypto v0.54.0 diff --git a/internal/appupdate/agent_client.go b/internal/appupdate/agent_client.go new file mode 100644 index 0000000..c2a2682 --- /dev/null +++ b/internal/appupdate/agent_client.go @@ -0,0 +1,84 @@ +package appupdate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +type AgentClient struct { + socket string +} + +func NewAgentClient(socket string) *AgentClient { + return &AgentClient{socket: socket} +} + +func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error { + encoded, err := json.Marshal(request) + if err != nil { + return err + } + response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded)) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusAccepted { + return nil + } + if response.StatusCode == http.StatusConflict { + return errors.New("ein Software-Update läuft bereits") + } + return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode) +} + +func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) { + response, err := c.do(ctx, http.MethodGet, "/v1/status", nil) + if err != nil { + return updateprotocol.Status{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode) + } + var status updateprotocol.Status + if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil { + return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err) + } + return status, nil +} + +func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + if c == nil || c.socket == "" { + return nil, errors.New("Update-Agent ist nicht konfiguriert") + } + transport := &http.Transport{ + DisableKeepAlives: true, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + dialer := net.Dialer{Timeout: time.Second} + return dialer.DialContext(ctx, "unix", c.socket) + }, + } + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body) + if err != nil { + return nil, err + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := client.Do(request) + if err != nil { + return nil, errors.New("Update-Agent ist nicht erreichbar") + } + return response, nil +} diff --git a/internal/appupdate/manager.go b/internal/appupdate/manager.go new file mode 100644 index 0000000..22d6b28 --- /dev/null +++ b/internal/appupdate/manager.go @@ -0,0 +1,302 @@ +package appupdate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet") + +type State struct { + CurrentVersion string + LatestVersion string + ReleaseURL string + DesiredVersion string + LastCheckedAt time.Time + LastError string + CredentialConfigured bool + UpdateAvailable bool + AgentAvailable bool + AgentStatus updateprotocol.Status +} + +type Manager struct { + store *store.Store + cipher *secure.Cipher + logger *slog.Logger + apiURL string + interval time.Duration + agent *AgentClient + httpClient *http.Client + checkMu sync.Mutex + stateMu sync.RWMutex + state State + checkSignal chan struct{} +} + +type releaseResponse struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) { + settings, err := database.GetSoftwareUpdateSettings(context.Background()) + if err != nil { + return nil, err + } + manager := &Manager{ + store: database, + cipher: cipher, + logger: logger, + apiURL: apiURL, + interval: interval, + agent: NewAgentClient(agentSocket), + checkSignal: make(chan struct{}, 1), + httpClient: &http.Client{ + Timeout: 12 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } + manager.setStoredState(settings) + return manager, nil +} + +func (m *Manager) Start(ctx context.Context) { + go func() { + m.refreshAgentStatus(ctx) + initial := time.NewTimer(15 * time.Second) + defer initial.Stop() + var ticker *time.Ticker + var tick <-chan time.Time + for { + select { + case <-ctx.Done(): + if ticker != nil { + ticker.Stop() + } + return + case <-initial.C: + if ticker == nil { + ticker = time.NewTicker(m.interval) + tick = ticker.C + } + m.backgroundCheck(ctx) + case <-tick: + m.backgroundCheck(ctx) + case <-m.checkSignal: + m.backgroundCheck(ctx) + } + } + }() +} + +func (m *Manager) backgroundCheck(ctx context.Context) { + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) { + m.logger.Warn("software update check failed", "error", err) + } + m.refreshAgentStatus(ctx) +} + +func (m *Manager) TriggerCheck() { + select { + case m.checkSignal <- struct{}{}: + default: + } +} + +func (m *Manager) State() State { + m.stateMu.RLock() + defer m.stateMu.RUnlock() + return m.state +} + +func (m *Manager) SaveCredential(ctx context.Context, username, token string) error { + username, token = strings.TrimSpace(username), strings.TrimSpace(token) + if username == "" || len(username) > 128 { + return errors.New("Gitea-Benutzername fehlt oder ist zu lang") + } + if len(token) < 8 || len(token) > 1024 { + return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge") + } + ciphertext, nonce, err := m.cipher.Encrypt(token) + if err != nil { + return fmt.Errorf("Update-Zugang verschlüsseln: %w", err) + } + if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil { + return err + } + settings, err := m.store.GetSoftwareUpdateSettings(ctx) + if err == nil { + m.setStoredState(settings) + } + return err +} + +func (m *Manager) CheckNow(ctx context.Context) error { + m.checkMu.Lock() + defer m.checkMu.Unlock() + settings, err := m.store.GetSoftwareUpdateSettings(ctx) + if err != nil { + return err + } + if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 { + m.setStoredState(settings) + return ErrCredentialMissing + } + token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + if err != nil { + return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")) + } + release, err := m.fetchLatest(ctx, token) + if err != nil { + return m.recordFailure(ctx, err) + } + checkedAt := time.Now().UTC() + if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil { + return err + } + settings, err = m.store.GetSoftwareUpdateSettings(ctx) + if err == nil { + m.setStoredState(settings) + } + return err +} + +func (m *Manager) RequestUpdate(ctx context.Context, version string) error { + version = strings.TrimSpace(version) + state := m.State() + if !validReleaseVersion(version) || version != state.LatestVersion { + return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release") + } + if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 { + return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version") + } + settings, err := m.store.GetSoftwareUpdateSettings(ctx) + if err != nil { + return err + } + token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + if err != nil || settings.RegistryUsername == "" { + return ErrCredentialMissing + } + if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { + return err + } + request := updateprotocol.Request{Version: version, RegistryUsername: settings.RegistryUsername, RegistryToken: token} + if err := m.agent.Start(ctx, request); err != nil { + return err + } + settings, err = m.store.GetSoftwareUpdateSettings(ctx) + if err == nil { + m.setStoredState(settings) + } + return err +} + +func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) { + status, err := m.agent.Status(ctx) + m.stateMu.Lock() + m.state.AgentAvailable = err == nil + if err == nil { + m.state.AgentStatus = status + } + m.stateMu.Unlock() + return status, err +} + +func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) + if err != nil { + return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig") + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "token "+token) + request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version) + response, err := m.httpClient.Do(request) + if err != nil { + return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar") + } + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt") + } + if response.StatusCode != http.StatusOK { + return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode) + } + var release releaseResponse + if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil { + return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig") + } + if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) { + return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release") + } + release.HTMLURL = m.safeReleaseURL(release.HTMLURL) + return release, nil +} + +func (m *Manager) safeReleaseURL(raw string) string { + apiURL, apiErr := url.Parse(m.apiURL) + releaseURL, releaseErr := url.Parse(raw) + if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) { + return "" + } + return releaseURL.String() +} + +func (m *Manager) recordFailure(ctx context.Context, checkErr error) error { + checkedAt := time.Now().UTC() + if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil { + return err + } + if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil { + m.setStoredState(settings) + } + return checkErr +} + +func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) { + state := State{ + CurrentVersion: buildinfo.Version, + LatestVersion: settings.LatestVersion, + ReleaseURL: settings.ReleaseURL, + DesiredVersion: settings.DesiredVersion, + LastCheckedAt: settings.LastCheckedAt, + LastError: settings.LastError, + CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0, + } + if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil { + state.UpdateAvailable = comparison < 0 + } + m.stateMu.Lock() + state.AgentAvailable = m.state.AgentAvailable + state.AgentStatus = m.state.AgentStatus + m.state = state + m.stateMu.Unlock() +} + +func (m *Manager) refreshAgentStatus(ctx context.Context) { + statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _, _ = m.UpdateStatus(statusCtx) +} diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go new file mode 100644 index 0000000..cb4696a --- /dev/null +++ b/internal/appupdate/manager_test.go @@ -0,0 +1,86 @@ +package appupdate + +import ( + "context" + "encoding/base64" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" +) + +func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { + t.Parallel() + const token = "test-token-that-must-stay-secret" + var unavailable atomic.Bool + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token "+token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if unavailable.Load() { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`)) + })) + defer api.Close() + + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + keyFile := filepath.Join(t.TempDir(), "master-key") + key := make([]byte, 32) + for index := range key { + key[index] = byte(index + 1) + } + if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil { + t.Fatal(err) + } + cipher, err := secure.LoadCipher(keyFile) + if err != nil { + t.Fatal(err) + } + manager, err := New(database, cipher, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err != nil { + t.Fatal(err) + } + if err := manager.SaveCredential(ctx, "registry-user", token); err != nil { + t.Fatal(err) + } + if err := manager.CheckNow(ctx); err != nil { + t.Fatal(err) + } + state := manager.State() + if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" { + t.Fatalf("new release was not detected: %#v", state) + } + settings, err := database.GetSoftwareUpdateSettings(ctx) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(settings.TokenCiphertext), token) { + t.Fatal("registry token was stored as plaintext") + } + + unavailable.Store(true) + if err := manager.CheckNow(ctx); err == nil { + t.Fatal("unavailable Gitea endpoint was reported as successful") + } + state = manager.State() + if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" { + t.Fatalf("temporary outage removed the last known update reminder: %#v", state) + } +} diff --git a/internal/appupdate/version.go b/internal/appupdate/version.go new file mode 100644 index 0000000..0c5e72c --- /dev/null +++ b/internal/appupdate/version.go @@ -0,0 +1,66 @@ +package appupdate + +import ( + "fmt" + "strconv" + "strings" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +type semanticVersion struct { + major int + minor int + patch int +} + +func parseVersion(raw string) (semanticVersion, error) { + if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") { + return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH") + } + parts := strings.Split(strings.TrimPrefix(raw, "v"), ".") + if len(parts) != 3 { + return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH") + } + values := make([]int, 3) + for index, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return semanticVersion{}, fmt.Errorf("version contains an invalid number") + } + value, err := strconv.Atoi(part) + if err != nil || value < 0 || value > 1_000_000 { + return semanticVersion{}, fmt.Errorf("version contains an invalid number") + } + values[index] = value + } + return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil +} + +func compareVersions(left, right string) (int, error) { + a, err := parseVersion(left) + if err != nil { + return 0, err + } + b, err := parseVersion(right) + if err != nil { + return 0, err + } + av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch} + for index := range av { + if av[index] < bv[index] { + return -1, nil + } + if av[index] > bv[index] { + return 1, nil + } + } + return 0, nil +} + +func validReleaseVersion(version string) bool { + if !updateprotocol.ValidVersion(version) { + return false + } + _, err := parseVersion(version) + return err == nil +} diff --git a/internal/appupdate/version_test.go b/internal/appupdate/version_test.go new file mode 100644 index 0000000..8a51330 --- /dev/null +++ b/internal/appupdate/version_test.go @@ -0,0 +1,27 @@ +package appupdate + +import "testing" + +func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) { + t.Parallel() + tests := []struct { + left string + right string + want int + }{ + {left: "v1.2.2", right: "v1.2.3", want: -1}, + {left: "v2.0.0", right: "v1.99.99", want: 1}, + {left: "v1.2.2", right: "v1.2.2", want: 0}, + } + for _, test := range tests { + got, err := compareVersions(test.left, test.right) + if err != nil || got != test.want { + t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want) + } + } + for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} { + if validReleaseVersion(invalid) { + t.Errorf("invalid release version accepted: %q", invalid) + } + } +} diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go new file mode 100644 index 0000000..cc20f2b --- /dev/null +++ b/internal/buildinfo/version.go @@ -0,0 +1,5 @@ +package buildinfo + +// Version is replaced through -ldflags for release images. The source fallback +// keeps local builds and tests honest about the currently checked-out release. +var Version = "v1.3.0" diff --git a/internal/config/config.go b/internal/config/config.go index 987b0b6..f2fd7a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,10 @@ package config import ( + "errors" "fmt" "net" + "net/url" "os" "path/filepath" "strconv" @@ -27,6 +29,9 @@ type Config struct { HCloudBaseURL string Mode string ShutdownTimeout time.Duration + UpdateAPIURL string + UpdateAgentSock string + UpdateInterval time.Duration } func Load() (Config, error) { @@ -51,6 +56,14 @@ func Load() (Config, error) { } keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key") + updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour) + if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour { + return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h") + } + updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest") + if err := validateUpdateURL(updateAPIURL); err != nil { + return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err) + } cookieSecure := mode == "https" if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" { @@ -71,9 +84,28 @@ func Load() (Config, error) { HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"), Mode: mode, ShutdownTimeout: shutdownTimeout, + UpdateAPIURL: updateAPIURL, + UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"), + UpdateInterval: updateInterval, }, nil } +func validateUpdateURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("must be an absolute HTTPS URL without embedded credentials") + } + if parsed.Scheme == "https" { + return nil + } + host := parsed.Hostname() + address := net.ParseIP(host) + if host == "localhost" || (address != nil && address.IsLoopback()) { + return nil + } + return errors.New("HTTP is allowed only for loopback test endpoints") +} + func value(name, fallback string) string { if v := strings.TrimSpace(os.Getenv(name)); v != "" { return v diff --git a/internal/model/model.go b/internal/model/model.go index 54dd17f..b63a819 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -90,6 +90,21 @@ type PiHoleCredential struct { Nonce []byte } +// SoftwareUpdateSettings contains the locally persisted update state. The +// encrypted registry token is returned only to trusted backend components and +// is never passed to templates. +type SoftwareUpdateSettings struct { + RegistryUsername string + TokenCiphertext []byte + TokenNonce []byte + LatestVersion string + ReleaseURL string + DesiredVersion string + LastCheckedAt time.Time + LastError string + UpdatedAt time.Time +} + // PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or // merely retained at Pi-hole after being removed from the active WebUI list. type PiHoleRecordLink struct { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 5fd751a..3a21b68 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -21,24 +21,42 @@ var ( ErrDisabled = errors.New("the updater is disabled") ) +const defaultRetryInitial = 5 * time.Second + +type ipResolver interface { + ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result + ValidateEndpoint(ctx context.Context, raw string) error +} + type Scheduler struct { store *store.Store provider *hetzner.Client - resolver *ipresolve.Resolver + resolver ipResolver cipher *secure.Cipher logger *slog.Logger running atomic.Bool nextRun atomic.Int64 wake chan struct{} + retryMin time.Duration + retryMax time.Duration } func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler { - return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)} + return &Scheduler{ + store: database, + provider: provider, + resolver: resolver, + cipher: cipher, + logger: logger, + wake: make(chan struct{}, 1), + retryMin: defaultRetryInitial, + } } func (s *Scheduler) Start(ctx context.Context) { go func() { defer s.nextRun.Store(0) + consecutiveFailures := 0 for { settings, err := s.store.GetSettings(ctx) if err != nil { @@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) { } continue } + wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second + if wait < time.Minute { + wait = time.Minute + } + retryAfter := time.Duration(0) if settings.UpdaterEnabled { - if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) { - s.logger.Warn("scheduled update run failed", "error", runErr) + _, runErr := s.Run(ctx) + if runErr == nil { + consecutiveFailures = 0 + } else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) { + consecutiveFailures++ + retryAfter = s.retryDelay(consecutiveFailures, wait) + s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter) } } else { + consecutiveFailures = 0 s.nextRun.Store(0) select { case <-ctx.Done(): @@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) { } continue } - wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second - if wait < time.Minute { - wait = time.Minute + if retryAfter > 0 { + wait = retryAfter } s.nextRun.Store(time.Now().UTC().Add(wait).Unix()) timer := time.NewTimer(wait) @@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) { timer.Stop() return case <-s.wake: + consecutiveFailures = 0 if !timer.Stop() { select { case <-timer.C: @@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) { }() } +func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration { + minimum, maximum := s.retryMin, scheduledWait + if minimum <= 0 { + minimum = defaultRetryInitial + } + if s.retryMax > 0 && s.retryMax < maximum { + maximum = s.retryMax + } + if maximum < minimum { + maximum = minimum + } + wait := minimum + for attempt := 1; attempt < failures && wait < maximum; attempt++ { + if wait > maximum/2 { + return maximum + } + wait *= 2 + } + if wait > maximum { + return maximum + } + return wait +} + func (s *Scheduler) Trigger() { s.nextRun.Store(0) select { @@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) { if err != nil { return "", err } - ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled) - if ipResult.IPv4 == "" && ipResult.IPv6 == "" { - message := "Keine öffentliche IP ermittelt" - if ipResult.IPv4Err != nil { - message += ": " + ipResult.IPv4Err.Error() - } - return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message)) - } - targets, err := s.store.ListManagedTargets(ctx) if err != nil { - return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err) + return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err) } + needsIPv6 := false + for _, target := range targets { + if target.Target.UpdateIPv6 { + needsIPv6 = true + break + } + } + ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6) + var resolutionErr error + if ipResult.IPv4Err != nil { + resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err)) + } + if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil { + resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err)) + } + if ipResult.IPv4 == "" && ipResult.IPv6 == "" { + message := "Keine öffentliche IP ermittelt" + if resolutionErr == nil { + resolutionErr = errors.New(message) + } else { + message += ": " + resolutionErr.Error() + } + return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr) + } + if len(targets) == 0 { summary := "Keine aktiven DynDNS-Ziele" - return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil) + status := "ok" + if resolutionErr != nil { + status = "partial" + summary += "; eine IP-Familie war nicht verfügbar" + } + return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr) } grouped := make(map[int64][]store.ManagedTarget) @@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) { status = "partial" } summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed) - if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) { + if resolutionErr != nil { summary += "; eine IP-Familie war nicht verfügbar" if status == "ok" { status = "partial" } } - return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil) + var runErr error + if failed > 0 { + runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed) + } + runErr = errors.Join(runErr, resolutionErr) + return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr) } func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) { diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index bfd18ff..5e603a6 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -5,11 +5,13 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -20,6 +22,24 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) +type retryResolver struct { + calls atomic.Int32 + recovered chan struct{} +} + +func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result { + call := r.calls.Add(1) + if call == 1 { + return ipresolve.Result{IPv4Err: errors.New("network is not ready")} + } + if call == 2 { + close(r.recovered) + } + return ipresolve.Result{IPv4: "198.51.100.42"} +} + +func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil } + func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) { t.Parallel() ctx := context.Background() @@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) { } } +func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) { + t.Parallel() + database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + settings, err := database.GetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil { + t.Fatal(err) + } + + resolver := &retryResolver{recovered: make(chan struct{})} + schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + schedule.resolver = resolver + schedule.retryMin = 5 * time.Millisecond + schedule.retryMax = 20 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + schedule.Start(ctx) + + select { + case <-resolver.recovered: + case <-time.After(2 * time.Second): + t.Fatal("scheduler did not retry the failed startup resolution") + } + + deadline := time.Now().Add(time.Second) + for { + settings, err = database.GetSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.LastIPv4 == "198.51.100.42" { + break + } + if time.Now().After(deadline) { + t.Fatalf("successful retry did not persist IPv4: %#v", settings) + } + time.Sleep(5 * time.Millisecond) + } + + runs, err := database.RecentRuns(context.Background(), 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" { + t.Fatalf("unexpected startup retry history: %#v", runs) + } +} + +func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) { + t.Parallel() + schedule := &Scheduler{retryMin: 5 * time.Second} + tests := []struct { + failures int + want time.Duration + }{ + {failures: 1, want: 5 * time.Second}, + {failures: 2, want: 10 * time.Second}, + {failures: 3, want: 20 * time.Second}, + {failures: 4, want: 40 * time.Second}, + {failures: 5, want: time.Minute}, + {failures: 20, want: time.Minute}, + } + for _, test := range tests { + if got := schedule.retryDelay(test.failures, time.Minute); got != test.want { + t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want) + } + } +} + func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) { t.Parallel() schedule := &Scheduler{} diff --git a/internal/secure/secure.go b/internal/secure/secure.go index 95194c6..2d9f366 100644 --- a/internal/secure/secure.go +++ b/internal/secure/secure.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" "golang.org/x/crypto/argon2" "golang.org/x/crypto/chacha20poly1305" @@ -21,6 +22,14 @@ const ( argonTime = 3 argonThreads = 2 argonKeyLen = 32 + + PasswordMinLength = 15 + PasswordMaxLength = 128 + + // PasswordTimingHash is a non-secret, precomputed Argon2id hash used only + // to equalize failed-login work when a username does not exist. Keeping it + // static avoids allocating the full Argon2 workspace during every startup. + PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q" ) type Cipher struct { @@ -129,8 +138,30 @@ func HashToken(token string) string { } func HashPassword(password string) (string, error) { - if len(password) < 12 { - return "", errors.New("password must contain at least 12 characters") + return hashPassword(password, PasswordMinLength) +} + +// HashProvisionedPassword hashes the initial password chosen by an +// administrator when creating another account. Product policy deliberately +// leaves its length to that administrator while still rejecting empty, +// overlong, or invalid UTF-8 input. +func HashProvisionedPassword(password string) (string, error) { + return hashPassword(password, 1) +} + +func hashPassword(password string, minimumLength int) (string, error) { + if !utf8.ValidString(password) { + return "", errors.New("Passwort muss gültiger UTF-8-Text sein") + } + length := utf8.RuneCountInString(password) + if length < minimumLength { + if minimumLength == 1 { + return "", errors.New("Passwort darf nicht leer sein") + } + return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength) + } + if length > PasswordMaxLength { + return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength) } salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { @@ -142,6 +173,9 @@ func HashPassword(password string) (string, error) { } func VerifyPassword(encoded, password string) bool { + if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength { + return false + } parts := strings.Split(encoded, "$") if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" { return false @@ -152,12 +186,18 @@ func VerifyPassword(encoded, password string) bool { if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { return false } + if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads { + return false + } + if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) { + return false + } salt, err := base64.RawStdEncoding.DecodeString(parts[3]) - if err != nil { + if err != nil || len(salt) != 16 { return false } want, err := base64.RawStdEncoding.DecodeString(parts[4]) - if err != nil { + if err != nil || len(want) != argonKeyLen { return false } got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want))) diff --git a/internal/secure/secure_test.go b/internal/secure/secure_test.go index 28f142d..eb078ea 100644 --- a/internal/secure/secure_test.go +++ b/internal/secure/secure_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "os" "path/filepath" + "strings" "testing" ) @@ -81,7 +82,34 @@ func TestPasswordHash(t *testing.T) { if VerifyPassword(hash, "wrong password") { t.Fatal("wrong password verified") } - if _, err := HashPassword("short"); err == nil { + if VerifyPassword(strings.Replace(hash, "m=65536", "m=4294967295", 1), "a secure password") { + t.Fatal("password hash with unsafe Argon2 parameters was accepted") + } + if _, err := HashPassword(strings.Repeat("a", PasswordMinLength-1)); err == nil { t.Fatal("short password was accepted") } + if _, err := HashPassword(strings.Repeat("a", PasswordMaxLength+1)); err == nil { + t.Fatal("overlong password was accepted") + } +} + +func TestPasswordTimingHashMatchesCurrentArgonParameters(t *testing.T) { + t.Parallel() + if !VerifyPassword(PasswordTimingHash, "login timing normalization only") { + t.Fatal("precomputed login timing hash no longer matches the configured Argon2 parameters") + } +} + +func TestProvisionedPasswordHasNoMinimumLengthBeyondNonEmpty(t *testing.T) { + t.Parallel() + hash, err := HashProvisionedPassword("x") + if err != nil || !VerifyPassword(hash, "x") { + t.Fatalf("short provisioned password was not hashed correctly: %v", err) + } + if _, err := HashProvisionedPassword(""); err == nil { + t.Fatal("empty provisioned password was accepted") + } + if _, err := HashProvisionedPassword(strings.Repeat("x", PasswordMaxLength+1)); err == nil { + t.Fatal("overlong provisioned password was accepted") + } } diff --git a/internal/store/store.go b/internal/store/store.go index 021a632..6fa9727 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -21,7 +21,12 @@ type Store struct { db *sql.DB } -var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured") +var ( + ErrAdminAlreadyConfigured = errors.New("an administrator is already configured") + ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited") + ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted") + ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured") +) type ManagedTarget struct { Target model.Target @@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error { if err := s.applyMigrationV10(ctx, tx); err != nil { return err } + if err := s.applyMigrationV11(ctx, tx); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`, 300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil { return err @@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error { return nil } +func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil { + return err + } + if count != 0 { + return nil + } + if _, err := tx.ExecContext(ctx, schemaV11); err != nil { + return fmt.Errorf("apply schema v11: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil { + return err + } + return nil +} + func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil { @@ -453,6 +478,22 @@ const schemaV10 = ` ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)); ` +const schemaV11 = ` +CREATE TABLE software_update_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + registry_username TEXT NOT NULL DEFAULT '', + token_ciphertext BLOB NOT NULL DEFAULT X'', + token_nonce BLOB NOT NULL DEFAULT X'', + latest_version TEXT NOT NULL DEFAULT '', + release_url TEXT NOT NULL DEFAULT '', + desired_version TEXT NOT NULL DEFAULT '', + last_checked_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL +); +INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now')); +` + func (s *Store) HasAdmin(ctx context.Context) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count) @@ -460,8 +501,20 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) { } func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error { - _, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now()) - return err + result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) +SELECT ?, ?, ?, 'admin', 1, ?, ? +WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now()) + if err != nil { + return err + } + created, err := result.RowsAffected() + if err != nil { + return err + } + if created != 1 { + return ErrAdminAlreadyConfigured + } + return nil } func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) { @@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s return nil } +func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected != 1 { + return sql.ErrNoRows + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) { return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id)) } @@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH } func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error { - result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var currentRole string + var currentActive int + if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(¤tRole, ¤tActive); err != nil { + return err + } + if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) { + var activeAdmins int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil { + return err + } + if activeAdmins <= 1 { + return ErrLastActiveAdmin + } + } + + result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID) if err != nil { return err } if count, _ := result.RowsAffected(); count != 1 { return sql.ErrNoRows } - return nil + // Administrators receive full access from their role. Removing stale member + // grants prevents hidden permissions from resurfacing after a later demotion. + if role == "admin" { + if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil { + return err + } + } + if currentRole != role || intBool(currentActive) != active { + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { + return err + } + } + return tx.Commit() } func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error { - return s.UpdatePassword(ctx, userID, passwordHash) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected != 1 { + return sql.ErrNoRows + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { + return err + } + return tx.Commit() } func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error { @@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection return err } defer func() { _ = tx.Rollback() }() + var role string + if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil { + return err + } + if role == "admin" { + return ErrAdminAccessImmutable + } if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil { return err } @@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled return err } +func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) { + var settings model.SoftwareUpdateSettings + var checkedAt, updatedAt int64 + err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan( + &settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt, + ) + settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt) + return settings, err +} + +func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error { + _, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now()) + return err +} + +func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now()) + return err +} + +// RecordSoftwareUpdateFailure intentionally retains the last known release so +// a temporary Gitea outage never removes an already visible update reminder. +func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now()) + return err +} + +func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error { + _, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now()) + return err +} + func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error { - _, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status) + _, err := s.db.ExecContext(ctx, `UPDATE settings SET +last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), +last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6), +last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status) return err } @@ -1095,8 +1257,18 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error) } func (s *Store) DisableIPv6ForTargets(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`) - return err + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil { + return err + } + return tx.Commit() } func (s *Store) ArchiveTarget(ctx context.Context, id int64) error { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ea5f930..9d588cd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -38,6 +38,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) { } } +func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil { + t.Fatal(err) + } + if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) { + t.Fatalf("second first-run administrator was not rejected: %v", err) + } + if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil { + t.Fatal("rejected second administrator was persisted") + } +} + func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) { t.Parallel() ctx := context.Background() @@ -168,6 +188,100 @@ func TestInactiveUserSessionIsRejected(t *testing.T) { } } +func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hash, err := secure.HashPassword("a secure administrator password") + if err != nil { + t.Fatal(err) + } + if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil { + t.Fatal(err) + } + admin, _, err := database.GetUserByUsername(ctx, "admin") + if err != nil { + t.Fatal(err) + } + if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) { + t.Fatalf("last active administrator could be removed: %v", err) + } + if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) { + t.Fatalf("administrator accepted editable individual grants: %v", err) + } + + member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member") + if err != nil { + t.Fatal(err) + } + credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test") + if err != nil { + t.Fatal(err) + } + zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example") + if err != nil { + t.Fatal(err) + } + if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil { + t.Fatal(err) + } + memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil { + t.Fatal(err) + } + if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil { + t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err) + } + grants, err := database.ListAccessGrants(ctx, member.ID) + if err != nil { + t.Fatal(err) + } + if len(grants) != 0 { + t.Fatalf("promoted administrator retained hidden member grants: %#v", grants) + } + if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) { + t.Fatalf("promoted administrator accepted individual grants: %v", err) + } +} + +func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil { + t.Fatal(err) + } + checkedAt := time.Now().UTC().Truncate(time.Second) + if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil { + t.Fatal(err) + } + if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil { + t.Fatal(err) + } + if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil { + t.Fatal(err) + } + settings, err := database.GetSoftwareUpdateSettings(ctx) + if err != nil { + t.Fatal(err) + } + if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" { + t.Fatalf("software update state was not persisted safely: %#v", settings) + } +} + func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) { t.Parallel() ctx := context.Background() @@ -268,6 +382,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) { if err != nil || len(targets) != 1 || targets[0].ID != target.ID { t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err) } + if err := database.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil { + t.Fatal(err) + } if err := database.DisableIPv6ForTargets(ctx); err != nil { t.Fatal(err) } @@ -275,6 +392,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) { if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" { t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err) } + settings, err := database.GetSettings(ctx) + if err != nil || settings.LastIPv6 != "" { + t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err) + } if err := database.DeleteTarget(ctx, target.ID); err != nil { t.Fatal(err) } @@ -335,6 +456,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) { if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil { t.Fatal(err) } + if err := database.UpdateRunState(ctx, "", "", "error"); err != nil { + t.Fatal(err) + } + settings, err = database.GetSettings(ctx) + if err != nil || settings.LastIPv4 != "198.51.100.1" { + t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err) + } if count, err := database.CountErrors(ctx); err != nil || count != 3 { t.Fatalf("unexpected error count: count=%d err=%v", count, err) } diff --git a/internal/updateagent/controller.go b/internal/updateagent/controller.go new file mode 100644 index 0000000..fc2cd4b --- /dev/null +++ b/internal/updateagent/controller.go @@ -0,0 +1,133 @@ +package updateagent + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +type Runner interface { + Update(context.Context, updateprotocol.Request, func(phase, message string)) error +} + +type Controller struct { + runner Runner + logger *slog.Logger + mu sync.RWMutex + status updateprotocol.Status +} + +func NewController(runner Runner, logger *slog.Logger) *Controller { + return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}} +} + +func (c *Controller) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/status", c.statusHandler) + mux.HandleFunc("POST /v1/update", c.updateHandler) + return mux +} + +func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(c.Status()) +} + +func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + defer r.Body.Close() + var request updateprotocol.Request + decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid update request", http.StatusBadRequest) + return + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + http.Error(w, "invalid update request", http.StatusBadRequest) + return + } + request.Version = strings.TrimSpace(request.Version) + request.RegistryUsername = strings.TrimSpace(request.RegistryUsername) + request.RegistryToken = strings.TrimSpace(request.RegistryToken) + if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 { + http.Error(w, "invalid update request", http.StatusBadRequest) + return + } + if !c.begin(request.Version) { + http.Error(w, "update already running", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("{\"accepted\":true}\n")) + go c.run(request) +} + +func (c *Controller) begin(version string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.status.Busy() { + return false + } + now := time.Now().UTC() + c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now} + return true +} + +func (c *Controller) run(request updateprotocol.Request) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + err := c.runner.Update(ctx, request, c.setPhase) + registryToken := request.RegistryToken + request.RegistryToken = "" + if err != nil { + message := publicAgentError(err) + c.setPhase(updateprotocol.PhaseFailed, message) + c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken)) + return + } + c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert") + c.logger.Info("software update completed", "version", request.Version) +} + +func redactSecret(err error, secret string) error { + if err == nil || secret == "" || !strings.Contains(err.Error(), secret) { + return err + } + return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]")) +} + +func (c *Controller) setPhase(phase, message string) { + c.mu.Lock() + c.status.Phase = phase + c.status.Message = message + c.status.UpdatedAt = time.Now().UTC() + c.mu.Unlock() +} + +func (c *Controller) Status() updateprotocol.Status { + c.mu.RLock() + defer c.mu.RUnlock() + return c.status +} + +func publicAgentError(err error) string { + if err == nil { + return "Update fehlgeschlagen" + } + var public interface{ PublicMessage() string } + if errors.As(err, &public) { + return public.PublicMessage() + } + return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt" +} diff --git a/internal/updateagent/controller_test.go b/internal/updateagent/controller_test.go new file mode 100644 index 0000000..b36313f --- /dev/null +++ b/internal/updateagent/controller_test.go @@ -0,0 +1,95 @@ +package updateagent + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) { + t.Parallel() + const secret = "sensitive-registry-token" + redacted := redactSecret(errors.New("registry rejected "+secret), secret) + if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") { + t.Fatalf("credential was not redacted: %q", redacted) + } +} + +type blockingRunner struct { + started chan updateprotocol.Request + release chan struct{} +} + +func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error { + progress(updateprotocol.PhasePulling, "image is being pulled") + r.started <- request + select { + case <-ctx.Done(): + return ctx.Err() + case <-r.release: + return nil + } +} + +func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { + t.Parallel() + runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})} + controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil))) + handler := controller.Handler() + + invalid := httptest.NewRecorder() + handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`))) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid version accepted: status=%d", invalid.Code) + } + trailing := httptest.NewRecorder() + handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`))) + if trailing.Code != http.StatusBadRequest { + t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code) + } + + const secret = "registry-token-must-not-leak" + body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`) + accepted := httptest.NewRecorder() + handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) + if accepted.Code != http.StatusAccepted { + t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String()) + } + select { + case request := <-runner.started: + if request.Version != "v1.2.3" || request.RegistryToken != secret { + t.Fatalf("runner received an altered request: %#v", request) + } + case <-time.After(time.Second): + t.Fatal("update runner did not start") + } + + busy := httptest.NewRecorder() + handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) + if busy.Code != http.StatusConflict { + t.Fatalf("parallel update was not rejected: status=%d", busy.Code) + } + status := httptest.NewRecorder() + handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil)) + if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) { + t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String()) + } + + close(runner.release) + deadline := time.Now().Add(time.Second) + for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if controller.Status().Phase != updateprotocol.PhaseComplete { + t.Fatalf("update did not complete: %#v", controller.Status()) + } +} diff --git a/internal/updateagent/docker.go b/internal/updateagent/docker.go new file mode 100644 index 0000000..0cd8613 --- /dev/null +++ b/internal/updateagent/docker.go @@ -0,0 +1,452 @@ +package updateagent + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" +) + +const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod" + +type DockerRunner struct { + docker *dockerClient + imageRepository string + targetLabel string + logger *slog.Logger +} + +type updateError struct { + message string + err error +} + +func (e *updateError) Error() string { return e.err.Error() } +func (e *updateError) Unwrap() error { return e.err } +func (e *updateError) PublicMessage() string { return e.message } + +func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner { + if imageRepository == "" { + imageRepository = defaultImageRepository + } + return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger} +} + +func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) { + if !updateprotocol.ValidVersion(request.Version) { + return errors.New("invalid target version") + } + if err := r.docker.negotiate(ctx); err != nil { + return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err} + } + progress(updateprotocol.PhasePulling, "Container-Image wird geladen") + if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil { + return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err} + } + target, err := r.docker.findTarget(ctx, r.targetLabel) + if err != nil { + return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err} + } + inspected, err := r.docker.inspectContainer(ctx, target.ID) + if err != nil { + return err + } + originalName := strings.TrimPrefix(inspected.Name, "/") + if originalName == "" { + return errors.New("target container has no name") + } + backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix()) + var oldStopped, oldRenamed bool + var newID string + defer func() { + if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") { + return + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil { + r.logger.Error("software update rollback failed", "error", rollbackErr) + resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)} + } + }() + + progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet") + if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil { + return err + } + oldStopped = true + if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil { + return err + } + oldRenamed = true + newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version) + if err != nil { + return err + } + if err := r.docker.startContainer(ctx, newID); err != nil { + return err + } + progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft") + if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil { + return err + } + if err := r.docker.removeContainer(ctx, target.ID, false); err != nil { + r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err) + } + oldStopped, oldRenamed, newID = false, false, "" + return nil +} + +func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error { + var failures []string + if newID != "" { + if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) { + failures = append(failures, "stop replacement: "+err.Error()) + } + if err := r.docker.removeContainer(ctx, newID, true); err != nil { + failures = append(failures, "remove replacement: "+err.Error()) + } + } + if oldRenamed { + if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil { + failures = append(failures, "restore name: "+err.Error()) + } + } + if oldStopped { + if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) { + failures = append(failures, "restart previous container: "+err.Error()) + } + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "; ")) + } + return nil +} + +type dockerClient struct { + socket string + client *http.Client + apiPrefix string +} + +type containerSummary struct { + ID string `json:"Id"` + Names []string `json:"Names"` + State string `json:"State"` +} + +type containerInspect struct { + ID string `json:"Id"` + Name string `json:"Name"` + Config json.RawMessage `json:"Config"` + HostConfig json.RawMessage `json:"HostConfig"` + NetworkSettings struct { + Networks map[string]endpointSettings `json:"Networks"` + } `json:"NetworkSettings"` + State containerState `json:"State"` +} + +type endpointSettings struct { + IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"` + Links []string `json:"Links,omitempty"` + Aliases []string `json:"Aliases,omitempty"` + MacAddress string `json:"MacAddress,omitempty"` + DriverOpts map[string]string `json:"DriverOpts,omitempty"` + GwPriority int `json:"GwPriority,omitempty"` +} + +type containerState struct { + Running bool `json:"Running"` + ExitCode int `json:"ExitCode"` + Health *struct { + Status string `json:"Status"` + } `json:"Health"` +} + +func newDockerClient(socket string) *dockerClient { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket) + }, + MaxIdleConns: 2, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 30 * time.Second, + } + return &dockerClient{socket: socket, client: &http.Client{Transport: transport}} +} + +func (c *dockerClient) negotiate(ctx context.Context) error { + response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return dockerStatusError(response) + } + var version struct { + APIVersion string `json:"ApiVersion"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil { + return fmt.Errorf("decode Docker API version: %w", err) + } + for _, character := range version.APIVersion { + if (character < '0' || character > '9') && character != '.' { + return errors.New("Docker returned an invalid API version") + } + } + if version.APIVersion == "" { + return errors.New("Docker returned an empty API version") + } + c.apiPrefix = "/v" + version.APIVersion + return nil +} + +func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) { + filters, _ := json.Marshal(map[string][]string{"label": {label}}) + query := url.Values{"all": {"1"}, "filters": {string(filters)}} + response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true) + if err != nil { + return containerSummary{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return containerSummary{}, dockerStatusError(response) + } + var containers []containerSummary + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil { + return containerSummary{}, err + } + var running []containerSummary + for _, container := range containers { + if container.State == "running" { + running = append(running, container) + } + } + if len(running) != 1 { + return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running)) + } + return running[0], nil +} + +func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error { + authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]}) + headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}} + query := url.Values{"fromImage": {repository}, "tag": {tag}} + response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return dockerStatusError(response) + } + scanner := bufio.NewScanner(response.Body) + scanner.Buffer(make([]byte, 64<<10), 1<<20) + for scanner.Scan() { + var event struct { + Error string `json:"error"` + } + if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Error != "" { + return errors.New(event.Error) + } + } + return scanner.Err() +} + +func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) { + response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true) + if err != nil { + return containerInspect{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return containerInspect{}, dockerStatusError(response) + } + var inspected containerInspect + if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil { + return containerInspect{}, err + } + return inspected, nil +} + +func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error { + query := url.Values{"t": {fmt.Sprint(seconds)}} + response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified { + return nil + } + return dockerStatusError(response) +} + +func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error { + query := url.Values{"name": {name}} + response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusNoContent { + return nil + } + return dockerStatusError(response) +} + +func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) { + var body map[string]json.RawMessage + if err := json.Unmarshal(inspected.Config, &body); err != nil { + return "", fmt.Errorf("decode container config: %w", err) + } + imageJSON, _ := json.Marshal(image) + body["Image"] = imageJSON + body["HostConfig"] = inspected.HostConfig + endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks)) + oldIDPrefix := inspected.ID + if len(oldIDPrefix) > 12 { + oldIDPrefix = oldIDPrefix[:12] + } + for network, endpoint := range inspected.NetworkSettings.Networks { + aliases := endpoint.Aliases[:0] + for _, alias := range endpoint.Aliases { + if alias != inspected.ID && alias != oldIDPrefix { + aliases = append(aliases, alias) + } + } + endpoint.Aliases = aliases + endpoints[network] = endpoint + } + networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints}) + body["NetworkingConfig"] = networkingJSON + encoded, err := json.Marshal(body) + if err != nil { + return "", err + } + query := url.Values{"name": {name}} + response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + return "", dockerStatusError(response) + } + var created struct { + ID string `json:"Id"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil { + return "", err + } + if created.ID == "" { + return "", errors.New("Docker created a replacement without an ID") + } + return created.ID, nil +} + +func (c *dockerClient) startContainer(ctx context.Context, id string) error { + response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified { + return nil + } + return dockerStatusError(response) +} + +func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error { + query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}} + response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound { + return nil + } + return dockerStatusError(response) +} + +func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error { + deadline := time.NewTimer(timeout) + ticker := time.NewTicker(time.Second) + defer deadline.Stop() + defer ticker.Stop() + consecutiveHealthy := 0 + startedAt := time.Now() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("replacement container did not become healthy in time") + case <-ticker.C: + inspected, err := c.inspectContainer(ctx, id) + if err != nil { + return err + } + if !inspected.State.Running { + return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode) + } + if inspected.State.Health == nil { + if time.Since(startedAt) >= 8*time.Second { + return nil + } + continue + } + if inspected.State.Health.Status == "healthy" { + consecutiveHealthy++ + if consecutiveHealthy >= 3 { + return nil + } + } else { + consecutiveHealthy = 0 + } + } + } +} + +func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) { + if versioned { + path = c.apiPrefix + path + } + request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body) + if err != nil { + return nil, err + } + for name, values := range headers { + for _, value := range values { + request.Header.Add(name, value) + } + } + return c.client.Do(request) +} + +func dockerStatusError(response *http.Response) error { + message, _ := io.ReadAll(io.LimitReader(response.Body, 8<<10)) + trimmed := strings.TrimSpace(string(message)) + if trimmed == "" { + trimmed = http.StatusText(response.StatusCode) + } + return fmt.Errorf("Docker API HTTP %d: %s", response.StatusCode, trimmed) +} + +func isDockerNotModified(err error) bool { + return err != nil && strings.Contains(err.Error(), "HTTP 304") +} diff --git a/internal/updateprotocol/protocol.go b/internal/updateprotocol/protocol.go new file mode 100644 index 0000000..bb3b71b --- /dev/null +++ b/internal/updateprotocol/protocol.go @@ -0,0 +1,40 @@ +package updateprotocol + +import ( + "regexp" + "time" +) + +var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`) + +const ( + PhaseIdle = "idle" + PhaseQueued = "queued" + PhasePulling = "pulling" + PhaseRestarting = "restarting" + PhaseVerifying = "verifying" + PhaseComplete = "complete" + PhaseFailed = "failed" +) + +type Request struct { + Version string `json:"version"` + RegistryUsername string `json:"registry_username"` + RegistryToken string `json:"registry_token"` +} + +type Status struct { + Phase string `json:"phase"` + TargetVersion string `json:"target_version,omitempty"` + Message string `json:"message,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +func (s Status) Busy() bool { + return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying +} + +func ValidVersion(version string) bool { + return len(version) <= 64 && releaseVersionPattern.MatchString(version) +} diff --git a/internal/web/pihole.go b/internal/web/pihole.go index 08d4b5d..86ad56f 100644 --- a/internal/web/pihole.go +++ b/internal/web/pihole.go @@ -15,7 +15,6 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" - "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) @@ -213,7 +212,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session return } hash, err := s.store.PasswordHash(r.Context(), session.UserID) - if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) { + if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) { s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error") return } diff --git a/internal/web/server.go b/internal/web/server.go index 7c890a7..7a06bda 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -21,6 +21,7 @@ import ( "golang.org/x/net/idna" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" @@ -36,9 +37,12 @@ type Server struct { store *store.Store cipher *secure.Cipher scheduler *scheduler.Scheduler + updates *appupdate.Manager logger *slog.Logger templates map[string]*template.Template limiter *loginLimiter + dummyHash string + authSlots chan struct{} } type viewData struct { @@ -74,6 +78,7 @@ type viewData struct { ErrorPiHoles []model.PiHoleIntegration ErrorCount int PiHoleView *piHoleView + SoftwareUpdate appupdate.State } type flashMessage struct { @@ -91,12 +96,12 @@ type accessZone struct { ManagedTargetIDs map[int64]bool } -func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) { +func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) { templates, err := loadTemplates() if err != nil { return nil, err } - return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil + return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil } func (s *Server) Handler() http.Handler { @@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /settings", s.adminOnly(s.settingsPage)) mux.Handle("POST /settings", s.adminOnly(s.settingsSave)) mux.Handle("POST /run", s.adminOnly(s.runNow)) + mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage)) + mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave)) + mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck)) + mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart)) mux.Handle("GET /errors", s.adminOnly(s.errorsPage)) mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) - mux.Handle("GET /security", s.authenticated(s.securityPage)) + mux.Handle("GET /account", s.authenticated(s.accountPage)) + mux.Handle("POST /account/password", s.authenticated(s.passwordChange)) + mux.Handle("GET /security", s.authenticated(s.securityRedirect)) mux.Handle("POST /security/password", s.authenticated(s.passwordChange)) - mux.Handle("GET /users", s.adminOnly(s.usersPage)) + mux.Handle("GET /access", s.adminOnly(s.usersPage)) + mux.Handle("GET /users", s.adminOnly(s.usersRedirect)) mux.Handle("GET /users/new", s.adminOnly(s.userNewPage)) mux.Handle("POST /users", s.adminOnly(s.userCreate)) mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail)) @@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset)) mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI)) mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI)) + mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI)) return s.securityHeaders(mux) } @@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) { return } if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil { + if errors.Is(err, store.ErrAdminAlreadyConfigured) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") { http.Redirect(w, r, "/login", http.StatusSeeOther) return @@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { } username, usernameErr := normalizeUsername(r.FormValue("username")) user, hash, err := s.store.GetUserByUsername(r.Context(), username) - if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) { + lookupOK := usernameErr == nil && err == nil + if !lookupOK { + hash = s.dummyHash + } + passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password")) + if !lookupOK || !user.Active || !passwordOK { s.limiter.Fail(clientIP) _ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung") - s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{}) + s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{}) return } s.limiter.Reset(clientIP) @@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { s.redirectNotice(w, r, "/", "Angemeldet.") } +func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool { + if s.authSlots == nil { + return secure.VerifyPassword(hash, password) + } + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case s.authSlots <- struct{}{}: + defer func() { <-s.authSlots }() + return secure.VerifyPassword(hash, password) + case <-ctx.Done(): + return false + case <-timer.C: + return false + } +} + func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) @@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo return } hash, err := s.store.PasswordHash(r.Context(), session.UserID) - if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) { + if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) { s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error") return } @@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S s.redirectNotice(w, r, "/", "Aktualisierung gestartet.") } +func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) { + if s.updates == nil { + http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond) + _, _ = s.updates.UpdateStatus(statusCtx) + cancel() + s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()}) +} + +func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) { + if s.updates == nil { + http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + hash, err := s.store.PasswordHash(r.Context(), session.UserID) + if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) { + s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error") + return + } + if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil { + s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error") + return + } + _ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username) + s.updates.TriggerCheck() + s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.") +} + +func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) { + if s.updates == nil { + http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + err := s.updates.CheckNow(checkCtx) + cancel() + if err != nil { + if errors.Is(err, appupdate.ErrCredentialMissing) { + s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info") + return + } + s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error") + return + } + s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.") +} + +func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) { + if s.updates == nil { + http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + version := strings.TrimSpace(r.FormValue("version")) + startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + err := s.updates.RequestUpdate(startCtx, version) + cancel() + if err != nil { + if strings.Contains(r.Header.Get("Accept"), "application/json") { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)}) + return + } + s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error") + return + } + _ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username) + if strings.Contains(r.Header.Get("Accept"), "application/json") { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version}) + return + } + s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info") +} + +func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { + if s.updates == nil { + http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + status, statusErr := s.updates.UpdateStatus(statusCtx) + cancel() + state := s.updates.State() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent_available": statusErr == nil, + "phase": status.Phase, + "message": status.Message, + "target_version": status.TargetVersion, + "current_version": state.CurrentVersion, + "latest_version": state.LatestVersion, + }) +} + func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { targets, err := s.store.ListTargetsWithErrors(r.Context()) if err != nil { @@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.") } -func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) { - s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) +func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) { + s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) +} + +func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) { + http.Redirect(w, r, "/account", http.StatusPermanentRedirect) } func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) { @@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session return } hash, err := s.store.PasswordHash(r.Context(), session.UserID) - if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) { - s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error") + if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) { + s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error") return } if r.FormValue("new_password") != r.FormValue("confirm_password") { - s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error") + s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error") return } newHash, err := secure.HashPassword(r.FormValue("new_password")) if err != nil { - s.redirectNotice(w, r, "/security", err.Error(), "error") + s.redirectNotice(w, r, "/account", err.Error(), "error") return } - if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil { + if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil { s.serverError(w, err) return } - _ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID) _ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username) - s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.") + s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.") } func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { @@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S "next_run_unix": unixSeconds(s.scheduler.NextRunAt()), "running": s.scheduler.IsRunning(), "enabled": settings.UpdaterEnabled, + "last_ipv4": settings.LastIPv4, }) } @@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode s.serverError(w, err) return } - s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()}) + s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()}) +} + +func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) { + http.Redirect(w, r, "/access", http.StatusPermanentRedirect) } func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) { - s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()}) + s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()}) } func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) { @@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form) return } - hash, err := secure.HashPassword(r.FormValue("password")) + hash, err := secure.HashProvisionedPassword(r.FormValue("password")) if err != nil { s.renderUserNewError(w, session, err.Error(), form) return @@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod } accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants}) } - s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()}) + s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()}) } func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) { @@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session * s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error") return } - if managedUser.IsAdmin() && (role != "admin" || !active) { - count, countErr := s.store.CountActiveAdmins(r.Context()) - if countErr != nil { - s.serverError(w, countErr) - return - } - if count <= 1 { + if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil { + if errors.Is(err, store.ErrLastActiveAdmin) { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error") return } - } - if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil { s.serverError(w, err) return } - if role != managedUser.Role || active != managedUser.Active { - _ = s.store.DeleteUserSessions(r.Context(), userID) - } _ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.") } @@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi s.serverError(w, err) return } - _ = s.store.DeleteUserSessions(r.Context(), userID) _ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.") } @@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) { if settings, err := s.store.GetSettings(context.Background()); err == nil { data.UpdaterEnabled = settings.UpdaterEnabled } + if data.IsAdmin && s.updates != nil { + data.SoftwareUpdate = s.updates.State() + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") @@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string, } func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) { - s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) + s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) } func (s *Server) serverError(w http.ResponseWriter, err error) { @@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { } func loadTemplates() (map[string]*template.Template, error) { - pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"} + pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"} functions := template.FuncMap{ "formatTime": func(value time.Time) string { if value.IsZero() { @@ -2043,15 +2202,30 @@ func remoteIP(r *http.Request) string { } type loginLimiter struct { - mu sync.Mutex - attempts map[string][]time.Time + mu sync.Mutex + attempts map[string][]time.Time + lastCleanup time.Time +} + +const ( + loginAttemptLimit = 5 + loginAttemptWindow = 15 * time.Minute + loginLimiterCleanupEvery = time.Minute + loginLimiterMaxTrackedIPs = 4096 +) + +func newLoginLimiter() *loginLimiter { + return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()} } -func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} } func (l *loginLimiter) Allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() - cutoff := time.Now().Add(-15 * time.Minute) + now := time.Now() + if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery { + l.cleanupLocked(now) + } + cutoff := now.Add(-loginAttemptWindow) values := l.attempts[ip] kept := values[:0] for _, value := range values { @@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool { kept = append(kept, value) } } - l.attempts[ip] = kept - return len(kept) < 5 + if len(kept) == 0 { + delete(l.attempts, ip) + } else { + l.attempts[ip] = kept + } + if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs { + return false + } + return len(kept) < loginAttemptLimit } func (l *loginLimiter) Fail(ip string) { l.mu.Lock() defer l.mu.Unlock() + if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs { + return + } l.attempts[ip] = append(l.attempts[ip], time.Now()) } func (l *loginLimiter) Reset(ip string) { @@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) { defer l.mu.Unlock() delete(l.attempts, ip) } + +func (l *loginLimiter) cleanupLocked(now time.Time) { + cutoff := now.Add(-loginAttemptWindow) + for ip, values := range l.attempts { + kept := values[:0] + for _, value := range values { + if value.After(cutoff) { + kept = append(kept, value) + } + } + if len(kept) == 0 { + delete(l.attempts, ip) + } else { + l.attempts[ip] = kept + } + } + l.lastCleanup = now +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 0aca317..3d182a2 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -9,10 +9,12 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" + "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole" @@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) { if strings.Contains(body, `class="flash"`) { t.Error("legacy in-flow flash message is still rendered") } + for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} { + if strings.Contains(body, removed) { + t.Errorf("login still renders removed security marketing copy %q", removed) + } + } +} + +func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) { + t.Parallel() + limiter := newLoginLimiter() + limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)} + limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second) + if !limiter.Allow("fresh") { + t.Fatal("fresh client was rejected after expired entries should have been pruned") + } + if _, exists := limiter.attempts["expired"]; exists { + t.Fatal("expired login limiter entry was not pruned") + } + + now := time.Now() + for index := 0; index < loginLimiterMaxTrackedIPs; index++ { + limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now} + } + limiter.lastCleanup = now + if limiter.Allow("overflow") { + t.Fatal("untracked client was accepted after the bounded limiter reached capacity") + } +} + +func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hash, err := secure.HashPassword("a sufficiently secure test password") + if err != nil { + t.Fatal(err) + } + if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil { + t.Fatal(err) + } + admin, _, err := database.GetUserByUsername(ctx, "admin") + if err != nil { + t.Fatal(err) + } + member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member") + if err != nil { + t.Fatal(err) + } + const rawToken = "member-session-token" + if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + server := &Server{config: config.Config{}, store: database} + handler := server.Handler() + requests := []struct { + method string + path string + body string + }{ + {method: http.MethodGet, path: "/access"}, + {method: http.MethodGet, path: "/users"}, + {method: http.MethodGet, path: "/users/new"}, + {method: http.MethodGet, path: "/system"}, + {method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"}, + } + for _, test := range requests { + request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken}) + if test.body != "" { + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code) + } + } + + templates, err := loadTemplates() + if err != nil { + t.Fatal(err) + } + var rendered bytes.Buffer + data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false} + if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil { + t.Fatal(err) + } + body := rendered.String() + if !strings.Contains(body, `href="/account"`) { + t.Error("member navigation does not expose the personal account") + } + for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} { + if strings.Contains(body, forbidden) { + t.Errorf("member UI exposes administrator control %q", forbidden) + } + } +} + +func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) { + t.Parallel() + templates, err := loadTemplates() + if err != nil { + t.Fatal(err) + } + admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} + var rendered bytes.Buffer + data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}} + if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil { + t.Fatal(err) + } + body := rendered.String() + for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} { + if !strings.Contains(body, expected) { + t.Errorf("immutable administrator access UI is missing %q", expected) + } + } + for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} { + if strings.Contains(body, forbidden) { + t.Errorf("administrator detail renders editable permission input %q", forbidden) + } + } +} + +func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) { + t.Parallel() + templates, err := loadTemplates() + if err != nil { + t.Fatal(err) + } + admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} + var rendered bytes.Buffer + data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}} + if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil { + t.Fatal(err) + } + body := rendered.String() + navStart, navEnd := strings.Index(body, "
{{end}} + {{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}} + + {{end}}