diff --git a/Dockerfile b/Dockerfile index f6745fd..e0fd617 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -ARG VERSION=v1.4.1 +ARG VERSION=v1.4.2 ARG MINIMUM_UPDATE_VERSION=v1.4.0 ARG MINIMUM_AGENT_PROTOCOL=2 ARG RUNTIME_CONTRACT_VERSION=2 diff --git a/README.md b/README.md index c5a4b00..d41f364 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] @@ -61,7 +61,7 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose, Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md). -Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. +Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Gitea-Benutzername und wahlweise Passwort oder Token werden einmalig in der Weboberfläche hinterlegt; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben. `v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an. diff --git a/UPDATE.md b/UPDATE.md index ecf9d0c..9572074 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -1,14 +1,14 @@ # Domain Control aktualisieren -Die aktuelle Compose-Version ist `v1.4.1`. Datenbank, Einstellungen und +Die aktuelle Compose-Version ist `v1.4.2`. 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. +2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder 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 @@ -34,7 +34,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert: ```yaml services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 update-agent: image: *image ``` @@ -68,7 +68,7 @@ Die erwartete Antwort lautet `ok`. ## Portainer oder Dockge 1. Den bestehenden Stack bearbeiten. -2. Nur den Tag der zentralen Image-Zeile auf `v1.4.1` setzen. +2. Nur den Tag der zentralen Image-Zeile auf `v1.4.2` setzen. 3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. 4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. diff --git a/compose.yml b/compose.yml index 9ae6f5d..e8804bb 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: dyndns: - image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1 + image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 restart: unless-stopped stop_grace_period: 20s ports: ["9090:8080"] diff --git a/internal/appupdate/manager.go b/internal/appupdate/manager.go index fd822b2..2d95aa3 100644 --- a/internal/appupdate/manager.go +++ b/internal/appupdate/manager.go @@ -30,6 +30,7 @@ type State struct { LastCheckedAt time.Time LastError string CredentialConfigured bool + CredentialUsername string UpdateAvailable bool AgentAvailable bool AgentCompatible bool @@ -136,15 +137,15 @@ func (m *Manager) State() State { return m.state } -func (m *Manager) SaveCredential(ctx context.Context, username, token string) error { - username, token = strings.TrimSpace(username), strings.TrimSpace(token) +func (m *Manager) SaveCredential(ctx context.Context, username, credential string) error { + username = strings.TrimSpace(username) 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") + if len(credential) < 8 || len(credential) > 1024 { + return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge") } - ciphertext, nonce, err := m.cipher.Encrypt(token) + ciphertext, nonce, err := m.cipher.Encrypt(credential) if err != nil { return fmt.Errorf("Update-Zugang verschlüsseln: %w", err) } @@ -169,11 +170,11 @@ func (m *Manager) CheckNow(ctx context.Context) error { m.setStoredState(settings) return ErrCredentialMissing } - token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + credential, 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) + release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential) if err != nil { return m.recordFailure(ctx, err) } @@ -207,14 +208,14 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { if err != nil { return err } - token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) + credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce) if err != nil || settings.RegistryUsername == "" { return ErrCredentialMissing } if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil { return err } - request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token} + request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential} if err := m.agent.Start(ctx, request); err != nil { cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) m.clearDesiredVersion(cleanupCtx, version) @@ -254,17 +255,17 @@ func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, erro return status, err } -func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) +func (m *Manager) fetchLatest(ctx context.Context, username, credential string) (releaseResponse, error) { + response, err := m.fetchLatestWithAuth(ctx, username, credential, true) if err != nil { - return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig") + return releaseResponse{}, err } - 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") + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + response.Body.Close() + response, err = m.fetchLatestWithAuth(ctx, username, credential, false) + if err != nil { + return releaseResponse{}, err + } } defer response.Body.Close() if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { @@ -284,6 +285,25 @@ func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseRespons return release, nil } +func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil) + if err != nil { + return nil, errors.New("Update-Endpunkt ist ungültig") + } + request.Header.Set("Accept", "application/json") + if basic { + request.SetBasicAuth(username, credential) + } else { + request.Header.Set("Authorization", "token "+credential) + } + request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version) + response, err := m.httpClient.Do(request) + if err != nil { + return nil, errors.New("Git-Server ist momentan nicht erreichbar") + } + return response, nil +} + func (m *Manager) safeReleaseURL(raw string) string { apiURL, apiErr := url.Parse(m.apiURL) releaseURL, releaseErr := url.Parse(raw) @@ -313,6 +333,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) { LastCheckedAt: settings.LastCheckedAt, LastError: settings.LastError, CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0, + CredentialUsername: settings.RegistryUsername, } if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil { state.UpdateAvailable = comparison < 0 diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go index 16b8bc7..71e47f0 100644 --- a/internal/appupdate/manager_test.go +++ b/internal/appupdate/manager_test.go @@ -21,10 +21,11 @@ import ( func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Parallel() - const token = "test-token-that-must-stay-secret" + const credential = " test-password-or-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 { + username, password, ok := r.BasicAuth() + if !ok || username != "registry-user" || password != credential { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -33,7 +34,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tag_name":"v1.4.2","html_url":"https://example.invalid/releases/v1.4.2","draft":false,"prerelease":false}`)) + _, _ = w.Write([]byte(`{"tag_name":"v1.4.3","html_url":"https://example.invalid/releases/v1.4.3","draft":false,"prerelease":false}`)) })) defer api.Close() @@ -59,22 +60,22 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { if err != nil { t.Fatal(err) } - if err := manager.SaveCredential(ctx, "registry-user", token); err != nil { + if err := manager.SaveCredential(ctx, "registry-user", credential); err != nil { t.Fatal(err) } if err := manager.CheckNow(ctx); err != nil { t.Fatal(err) } state := manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError != "" { + if !state.UpdateAvailable || state.LatestVersion != "v1.4.3" || state.LastError != "" || state.CredentialUsername != "registry-user" { 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") + if strings.Contains(string(settings.TokenCiphertext), credential) { + t.Fatal("registry credential was stored as plaintext") } unavailable.Store(true) @@ -82,11 +83,36 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Fatal("unavailable Gitea endpoint was reported as successful") } state = manager.State() - if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError == "" { + if !state.UpdateAvailable || state.LatestVersion != "v1.4.3" || state.LastError == "" { t.Fatalf("temporary outage removed the last known update reminder: %#v", state) } } +func TestManagerFallsBackToTokenAuthentication(t *testing.T) { + t.Parallel() + const credential = "fallback-api-token" + var requests atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Authorization") != "token "+credential { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tag_name":"v2.0.0","html_url":"https://example.invalid/releases/v2.0.0","draft":false,"prerelease":false}`)) + })) + defer api.Close() + + manager := &Manager{apiURL: api.URL, httpClient: api.Client()} + release, err := manager.fetchLatest(context.Background(), "registry-user", credential) + if err != nil { + t.Fatal(err) + } + if release.TagName != "v2.0.0" || requests.Load() != 2 { + t.Fatalf("token fallback was not used: release=%#v requests=%d", release, requests.Load()) + } +} + func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index d5fa39b..7e39b5e 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -18,4 +18,4 @@ const ( // Version is replaced through -ldflags for release images. The source fallback // keeps local builds and tests honest about the currently checked-out release. -var Version = "v1.4.1" +var Version = "v1.4.2" diff --git a/internal/model/model.go b/internal/model/model.go index b63a819..dd95aac 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -91,7 +91,7 @@ type PiHoleCredential struct { } // SoftwareUpdateSettings contains the locally persisted update state. The -// encrypted registry token is returned only to trusted backend components and +// encrypted registry credential is returned only to trusted backend components and // is never passed to templates. type SoftwareUpdateSettings struct { RegistryUsername string diff --git a/internal/updateagent/controller.go b/internal/updateagent/controller.go index 5f797ae..2075367 100644 --- a/internal/updateagent/controller.go +++ b/internal/updateagent/controller.go @@ -69,7 +69,6 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) { request.Version = strings.TrimSpace(request.Version) request.CurrentVersion = strings.TrimSpace(request.CurrentVersion) request.RegistryUsername = strings.TrimSpace(request.RegistryUsername) - request.RegistryToken = strings.TrimSpace(request.RegistryToken) if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 { http.Error(w, "invalid update request", http.StatusBadRequest) return @@ -99,12 +98,12 @@ 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 + registryCredential := 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)) + c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryCredential)) return } c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert") diff --git a/internal/updateagent/controller_test.go b/internal/updateagent/controller_test.go index 2638668..0618895 100644 --- a/internal/updateagent/controller_test.go +++ b/internal/updateagent/controller_test.go @@ -67,7 +67,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code) } - const secret = "registry-token-must-not-leak" + const secret = " registry-password-must-not-be-trimmed " body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`) accepted := httptest.NewRecorder() handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) diff --git a/internal/updateagent/docker.go b/internal/updateagent/docker.go index 37a27db..f6e5584 100644 --- a/internal/updateagent/docker.go +++ b/internal/updateagent/docker.go @@ -304,8 +304,8 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS 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]}) +func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error { + authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]}) headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}} query := url.Values{"fromImage": {repository}, "tag": {tag}} response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true) diff --git a/internal/updateprotocol/protocol.go b/internal/updateprotocol/protocol.go index 908a2ab..42dcdfe 100644 --- a/internal/updateprotocol/protocol.go +++ b/internal/updateprotocol/protocol.go @@ -24,7 +24,9 @@ type Request struct { Version string `json:"version"` CurrentVersion string `json:"current_version"` RegistryUsername string `json:"registry_username"` - RegistryToken string `json:"registry_token"` + // RegistryToken keeps the v2 wire name for compatibility and carries either + // a registry password or an API token. + RegistryToken string `json:"registry_token"` } type Status struct { diff --git a/internal/web/server.go b/internal/web/server.go index e7fde57..63b7d84 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1411,6 +1411,8 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, "phase": status.Phase, "message": status.Message, "target_version": status.TargetVersion, + "started_at": status.StartedAt, + "updated_at": status.UpdatedAt, "current_version": state.CurrentVersion, "latest_version": state.LatestVersion, }) @@ -1888,7 +1890,13 @@ func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) - if err := r.ParseForm(); err != nil { + var err error + if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") { + err = r.ParseMultipartForm(64 << 10) + } else { + err = r.ParseForm() + } + if err != nil { http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest) return err } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 70dfd57..8cc02e1 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "log/slog" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -204,6 +205,31 @@ func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) { } } +func TestParseFormAcceptsMultipartUpdateFields(t *testing.T) { + t.Parallel() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("csrf", "csrf-token"); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("version", "v1.4.2"); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/system/update", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + server := &Server{} + if err := server.parseForm(response, request); err != nil { + t.Fatalf("multipart update form was rejected: status=%d body=%s", response.Code, response.Body.String()) + } + if request.FormValue("csrf") != "csrf-token" || request.FormValue("version") != "v1.4.2" { + t.Fatalf("multipart fields were not parsed: %#v", request.Form) + } +} + func TestLoginErrorRendersAsToast(t *testing.T) { t.Parallel() templates, err := loadTemplates() @@ -242,6 +268,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) { t.Fatalf("load templates: %v", err) } var rendered bytes.Buffer + now := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC) data := viewData{ Title: "Übersicht", Page: "dashboard", @@ -251,7 +278,8 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) { Dashboard: model.Dashboard{ Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true}, SchedulerRunning: true, - RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: time.Now(), Status: "running"}}, + RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: now, Status: "running"}}, + Zones: []model.Zone{{ID: 7, Name: "example.de", LastSyncAt: now}}, }, } if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil { @@ -264,7 +292,10 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) { `data-dashboard-ipv6`, `data-recent-runs aria-live="polite" aria-busy="true"`, `Aktualisierung läuft`, - `features-24`, + `features-27`, + `data-local-time="1788001920"`, + `dashboard-zone-item`, + `DNS-Zone · example.de`, } { if !strings.Contains(body, expected) { t.Errorf("rendered dashboard is missing %q", expected) @@ -480,13 +511,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { t.Fatal(err) } admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true} - updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true} + checkedAt := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC) + updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", LastCheckedAt: checkedAt, UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true, CredentialUsername: "registry-user"} var notification bytes.Buffer accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState} if err := templates["security"].ExecuteTemplate(¬ification, "security", accountData); err != nil { t.Fatal(err) } - for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-form`, `data-software-update-overlay`} { + for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-banner="v2.0.0"`, `data-software-update-form`, `data-software-update-overlay`} { if !strings.Contains(notification.String(), expected) { t.Errorf("global update notification is missing %q", expected) } @@ -497,7 +529,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil { t.Fatal(err) } - for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Update-Zugang ersetzen"} { + for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `
`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`} { if !strings.Contains(page.String(), expected) { t.Errorf("software update page is missing %q", expected) } @@ -507,7 +539,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil { t.Fatal(err) } - for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} { + for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} { if !strings.Contains(freshPage.String(), expected) { t.Errorf("fresh software update page is missing %q", expected) } @@ -515,6 +547,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { if strings.Contains(freshPage.String(), "Update-Kanal") { t.Error("confusing legacy update channel box is still rendered") } + var currentPage bytes.Buffer + currentData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.1", LatestVersion: "v1.4.1", LastCheckedAt: checkedAt, AgentAvailable: true, AgentCompatible: true, CredentialConfigured: true}} + if err := templates["system"].ExecuteTemplate(¤tPage, "system", currentData); err != nil { + t.Fatal(err) + } + if !strings.Contains(currentPage.String(), `StatusAktuell`) || strings.Count(currentPage.String(), `>v1.4.1<`) != 1 { + t.Fatal("an installed current release is still rendered as a second available version") + } var syncPage bytes.Buffer syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}} if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil { @@ -608,7 +648,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi } body := rendered.String() statusAt := strings.Index(body, `A unverändert`) - timeAt := strings.Index(body, `class="target-meta">