diff --git a/README.md b/README.md index f6814df..d86d4cf 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Administratoren können im Menü **Update** nach stabilen Releases suchen und ei 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. +Auf derselben Seite kann ein Administrator den vollständigen Anwendungscontainer geordnet neu starten. Dabei bleiben Daten-, Secret- und Steuer-Volumes unverändert. Der Neustart beendet den Prozess im Container; die in der unterstützten Compose-Konfiguration gesetzte Richtlinie `restart: unless-stopped` startet ihn anschließend automatisch wieder. Die Oberfläche erkennt die neue Prozessinstanz und verbindet sich selbstständig neu. + Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend: ```bash @@ -88,6 +90,7 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü - 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 +- administrativer Container-Neustart mit automatischem Wiederverbinden der Weboberfläche - 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 index 33a78dd..5ff5a5b 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -20,6 +20,17 @@ das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt. +Nach einem Neustart des Docker-Hosts prüft die Web-App den Update-Agenten mit +kurzem Abstand erneut. Der Update-Klick führt zusätzlich immer eine aktuelle +Prüfung durch und wartet bei Bedarf darauf, dass der Container seinen +Start-Healthcheck beendet. Ein separater manueller Neustart der Anwendung ist +dafür nicht mehr erforderlich. + +Über **Container neu starten** auf der Update-Seite lässt sich der vollständige +Anwendungscontainer außerdem gezielt neu starten. Datenbank, Secrets und +Einstellungen bleiben erhalten. Diese Funktion setzt die im mitgelieferten +Compose konfigurierte Richtlinie `restart: unless-stopped` voraus. + Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich `v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach kann der Agent auch dann weitere kompatible Releases installieren, wenn seine diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go index ae00724..f7b7928 100644 --- a/cmd/dyndns-web/main.go +++ b/cmd/dyndns-web/main.go @@ -9,6 +9,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -66,6 +67,7 @@ func commandForInvocation(arguments []string) string { } func run(logger *slog.Logger) error { + startedAt := time.Now() // Credentials and session data must never inherit a permissive process umask. syscall.Umask(0o077) cfg, err := config.Load() @@ -103,7 +105,22 @@ func run(logger *slog.Logger) error { logger.Info("removed AAAA records for disabled IPv6", "count", deleted) } } - serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger) + restartRequested := make(chan struct{}) + var restartOnce sync.Once + requestRestart := func() { + restartOnce.Do(func() { + // Docker applies restart policies only after a container has stayed up + // long enough to be considered successfully started. Keep very early + // UI requests alive until that safety window has passed. + delay := time.Until(startedAt.Add(11 * time.Second)) + if delay > 0 { + time.AfterFunc(delay, func() { close(restartRequested) }) + return + } + close(restartRequested) + }) + } + serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger, requestRestart) if err != nil { return err } @@ -133,6 +150,12 @@ func run(logger *slog.Logger) error { return err } return nil + case <-restartRequested: + logger.Info("container restart requested") + stop() + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + return httpServer.Shutdown(shutdownCtx) case <-appCtx.Done(): logger.Info("shutdown requested") shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) diff --git a/internal/appupdate/manager.go b/internal/appupdate/manager.go index 2d95aa3..e25765e 100644 --- a/internal/appupdate/manager.go +++ b/internal/appupdate/manager.go @@ -22,6 +22,16 @@ import ( var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet") +const ( + agentUnavailableRetryInterval = 2 * time.Second + agentAvailableCheckInterval = time.Minute +) + +type updateAgent interface { + Start(context.Context, updateprotocol.Request) error + Status(context.Context) (updateprotocol.Status, error) +} + type State struct { CurrentVersion string LatestVersion string @@ -46,9 +56,10 @@ type Manager struct { logger *slog.Logger apiURL string interval time.Duration - agent *AgentClient + agent updateAgent httpClient *http.Client checkMu sync.Mutex + requestMu sync.Mutex stateMu sync.RWMutex state State checkSignal chan struct{} @@ -87,8 +98,8 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin } func (m *Manager) Start(ctx context.Context) { + go m.monitorAgent(ctx, agentUnavailableRetryInterval, agentAvailableCheckInterval) go func() { - m.refreshAgentStatus(ctx) initial := time.NewTimer(15 * time.Second) defer initial.Stop() var ticker *time.Ticker @@ -121,7 +132,28 @@ func (m *Manager) backgroundCheck(ctx context.Context) { 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) monitorAgent(ctx context.Context, unavailableInterval, availableInterval time.Duration) { + for { + m.refreshAgentStatus(ctx) + delay := unavailableInterval + if m.State().AgentAvailable { + delay = availableInterval + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + case <-timer.C: + } + } } func (m *Manager) TriggerCheck() { @@ -190,6 +222,9 @@ func (m *Manager) CheckNow(ctx context.Context) error { } func (m *Manager) RequestUpdate(ctx context.Context, version string) error { + m.requestMu.Lock() + defer m.requestMu.Unlock() + version = strings.TrimSpace(version) state := m.State() if !validReleaseVersion(version) || version != state.LatestVersion { @@ -198,12 +233,16 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error { if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 { return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version") } - if !state.AgentAvailable { + status, err := m.UpdateStatus(ctx) + if err != nil { return errors.New("Update-Agent ist nicht erreichbar") } - if !state.AgentCompatible { + if !agentCompatible(status) { return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden") } + if status.Busy() { + return errors.New("ein Software-Update läuft bereits") + } settings, err := m.store.GetSoftwareUpdateSettings(ctx) if err != nil { return err diff --git a/internal/appupdate/manager_test.go b/internal/appupdate/manager_test.go index 5ac3aa3..43cb059 100644 --- a/internal/appupdate/manager_test.go +++ b/internal/appupdate/manager_test.go @@ -3,12 +3,14 @@ package appupdate import ( "context" "encoding/base64" + "errors" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" "time" @@ -19,6 +21,48 @@ import ( "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) +type fakeUpdateAgent struct { + mu sync.Mutex + status updateprotocol.Status + statusErr error + startErr error + statusCalls int + startCalls int + requests []updateprotocol.Request +} + +func (a *fakeUpdateAgent) Status(context.Context) (updateprotocol.Status, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.statusCalls++ + return a.status, a.statusErr +} + +func (a *fakeUpdateAgent) Start(_ context.Context, request updateprotocol.Request) error { + a.mu.Lock() + defer a.mu.Unlock() + a.startCalls++ + a.requests = append(a.requests, request) + if a.startErr == nil { + a.status.Phase = updateprotocol.PhaseQueued + a.status.TargetVersion = request.Version + } + return a.startErr +} + +func (a *fakeUpdateAgent) setStatus(status updateprotocol.Status, err error) { + a.mu.Lock() + a.status = status + a.statusErr = err + a.mu.Unlock() +} + +func (a *fakeUpdateAgent) counts() (status, start int) { + a.mu.Lock() + defer a.mu.Unlock() + return a.statusCalls, a.startCalls +} + func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) { t.Parallel() const credential = " test-password-or-token-that-must-stay-secret " @@ -138,6 +182,116 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) { } } +func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) { + t.Parallel() + agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)} + manager, database := updateReadyManager(t, agent) + if manager.State().AgentAvailable { + t.Fatal("test precondition failed: cached agent state is already available") + } + if err := manager.RequestUpdate(context.Background(), "v1.4.4"); err != nil { + t.Fatalf("live agent was rejected because of stale cached state: %v", err) + } + statusCalls, startCalls := agent.counts() + if statusCalls != 1 || startCalls != 1 { + t.Fatalf("agent was not checked immediately before start: status=%d start=%d", statusCalls, startCalls) + } + settings, err := database.GetSoftwareUpdateSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.DesiredVersion != "v1.4.4" { + t.Fatalf("target version was not persisted: %#v", settings) + } +} + +func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) { + t.Parallel() + tests := []struct { + name string + status updateprotocol.Status + statusErr error + }{ + {name: "unavailable", statusErr: errors.New("offline")}, + {name: "incompatible", status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, AgentVersion: "v1.3.0", Protocol: 1}}, + {name: "busy", status: compatibleAgentStatus(updateprotocol.PhasePulling)}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr} + manager, database := updateReadyManager(t, agent) + if err := manager.RequestUpdate(context.Background(), "v1.4.4"); err == nil { + t.Fatal("unsafe update request was accepted") + } + _, startCalls := agent.counts() + if startCalls != 0 { + t.Fatalf("agent start was called %d times", startCalls) + } + settings, err := database.GetSoftwareUpdateSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.DesiredVersion != "" { + t.Fatalf("rejected update changed desired version: %#v", settings) + } + }) + } +} + +func TestRequestUpdateSerializesParallelClicks(t *testing.T) { + t.Parallel() + agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)} + manager, _ := updateReadyManager(t, agent) + results := make(chan error, 2) + start := make(chan struct{}) + for index := 0; index < 2; index++ { + go func() { + <-start + results <- manager.RequestUpdate(context.Background(), "v1.4.4") + }() + } + close(start) + var successes int + for index := 0; index < 2; index++ { + if err := <-results; err == nil { + successes++ + } + } + _, startCalls := agent.counts() + if successes != 1 || startCalls != 1 { + t.Fatalf("parallel clicks started an unexpected number of updates: successes=%d starts=%d", successes, startCalls) + } +} + +func TestAgentMonitorRecoversAfterDelayedAgentStartup(t *testing.T) { + t.Parallel() + agent := &fakeUpdateAgent{statusErr: errors.New("not listening yet")} + manager := &Manager{agent: agent} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + manager.monitorAgent(ctx, 2*time.Millisecond, 20*time.Millisecond) + close(done) + }() + waitForCondition(t, time.Second, func() bool { + statusCalls, _ := agent.counts() + return statusCalls > 0 + }) + agent.setStatus(compatibleAgentStatus(updateprotocol.PhaseIdle), nil) + waitForCondition(t, time.Second, func() bool { + state := manager.State() + return state.AgentAvailable && state.AgentCompatible + }) + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("agent monitor did not stop after context cancellation") + } +} + func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) { t.Parallel() ctx := context.Background() @@ -176,3 +330,58 @@ func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) { t.Fatalf("completed desired version remained persisted: %#v", settings) } } + +func compatibleAgentStatus(phase string) updateprotocol.Status { + return updateprotocol.Status{Phase: phase, AgentVersion: buildinfo.MinimumAgentVersion, Protocol: buildinfo.MinimumAgentProtocol} +} + +func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) { + t.Helper() + 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, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err != nil { + t.Fatal(err) + } + manager.agent = agent + if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil { + t.Fatal(err) + } + if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.4.4", "https://git.example/releases/v1.4.4", time.Now()); err != nil { + t.Fatal(err) + } + settings, err := database.GetSoftwareUpdateSettings(ctx) + if err != nil { + t.Fatal(err) + } + manager.setStoredState(settings) + return manager, database +} + +func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition did not become true in time") +} diff --git a/internal/updateagent/docker.go b/internal/updateagent/docker.go index f6e5584..f70503e 100644 --- a/internal/updateagent/docker.go +++ b/internal/updateagent/docker.go @@ -64,7 +64,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques if !updateprotocol.ValidVersion(request.Version) { return errors.New("invalid target version") } - if err := r.docker.negotiate(ctx); err != nil { + if err := waitForDockerAPI(ctx, 30*time.Second, time.Second, r.docker.negotiate); err != nil { return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err} } target, err := r.docker.findTarget(ctx, r.targetLabel) @@ -75,7 +75,18 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques if err != nil { return err } - if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" { + if inspected.State.Health == nil { + return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")} + } + switch inspected.State.Health.Status { + case "healthy": + case "starting": + progress(updateprotocol.PhaseQueued, "Laufender Container beendet noch seinen Start-Healthcheck") + inspected, err = r.docker.waitCurrentHealthy(ctx, target.ID, inspected.Image, 100*time.Second, time.Second) + if err != nil { + return &updateError{message: "Die laufende Instanz wurde nach dem Host-Start nicht rechtzeitig healthy; das Update wurde nicht gestartet", err: err} + } + default: return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")} } currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository) @@ -847,6 +858,72 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, exp } } +func (c *dockerClient) waitCurrentHealthy(ctx context.Context, id, expectedImageID string, timeout, pollInterval time.Duration) (containerInspect, error) { + deadline := time.NewTimer(timeout) + ticker := time.NewTicker(pollInterval) + defer deadline.Stop() + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return containerInspect{}, ctx.Err() + case <-deadline.C: + return containerInspect{}, errors.New("current container did not become healthy in time") + case <-ticker.C: + inspected, err := c.inspectContainer(ctx, id) + if err != nil { + return containerInspect{}, err + } + if !inspected.State.Running { + return containerInspect{}, fmt.Errorf("current container stopped with exit code %d", inspected.State.ExitCode) + } + if expectedImageID != "" && inspected.Image != expectedImageID { + return containerInspect{}, fmt.Errorf("current container image changed from %s to %s", expectedImageID, inspected.Image) + } + if inspected.State.Health == nil { + return containerInspect{}, errors.New("current container has no healthcheck") + } + switch inspected.State.Health.Status { + case "healthy": + return inspected, nil + case "starting": + continue + default: + return containerInspect{}, fmt.Errorf("current container health is %q", inspected.State.Health.Status) + } + } + } +} + +func waitForDockerAPI(ctx context.Context, timeout, interval time.Duration, negotiate func(context.Context) error) error { + retryCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var lastErr error + for { + attemptCtx, attemptCancel := context.WithTimeout(retryCtx, 3*time.Second) + lastErr = negotiate(attemptCtx) + attemptCancel() + if lastErr == nil { + return nil + } + timer := time.NewTimer(interval) + select { + case <-retryCtx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + if ctx.Err() != nil { + return ctx.Err() + } + return lastErr + case <-timer.C: + } + } +} + 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 diff --git a/internal/updateagent/docker_test.go b/internal/updateagent/docker_test.go index 58309d6..8b662fe 100644 --- a/internal/updateagent/docker_test.go +++ b/internal/updateagent/docker_test.go @@ -1,13 +1,26 @@ package updateagent import ( + "context" "encoding/json" + "errors" + "io" + "net/http" "strconv" + "strings" + "sync/atomic" "testing" + "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) { t.Parallel() current := compatibleImage("v1.4.0", "v1.4.0") @@ -17,6 +30,40 @@ func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) { } } +func TestWaitForDockerAPIRetriesBootTransient(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + err := waitForDockerAPI(context.Background(), 100*time.Millisecond, time.Millisecond, func(context.Context) error { + if calls.Add(1) < 3 { + return errors.New("Docker is still starting") + } + return nil + }) + if err != nil || calls.Load() != 3 { + t.Fatalf("Docker boot transient was not retried: calls=%d err=%v", calls.Load(), err) + } +} + +func TestWaitCurrentHealthyAllowsStartingButRejectsUnhealthy(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + client := dockerTestClient(func() string { + if calls.Add(1) == 1 { + return "starting" + } + return "healthy" + }) + inspected, err := client.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond) + if err != nil || inspected.State.Health == nil || inspected.State.Health.Status != "healthy" { + t.Fatalf("starting container did not become eligible: inspect=%#v err=%v", inspected, err) + } + + unhealthy := dockerTestClient(func() string { return "unhealthy" }) + if _, err := unhealthy.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond); err == nil || !strings.Contains(err.Error(), "unhealthy") { + t.Fatalf("unhealthy container was not rejected immediately: %v", err) + } +} + func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) { t.Parallel() config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`) @@ -203,3 +250,11 @@ func contains(values []string, expected string) bool { } return false } + +func dockerTestClient(health func() string) *dockerClient { + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + body := `{"Id":"container-id","Image":"sha256:current","Config":{"Labels":{}},"State":{"Running":true,"ExitCode":0,"Health":{"Status":"` + health() + `"}}}` + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + }) + return &dockerClient{client: &http.Client{Transport: transport}, apiPrefix: "/v1.0"} +} diff --git a/internal/web/server.go b/internal/web/server.go index 63b7d84..859676c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -33,16 +33,20 @@ import ( var embeddedAssets embed.FS type Server struct { - config config.Config - 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{} + config config.Config + 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{} + instanceID string + restartContainer func() + restartMu sync.Mutex + restartPending bool } type viewData struct { @@ -79,6 +83,7 @@ type viewData struct { ErrorCount int PiHoleView *piHoleView SoftwareUpdate appupdate.State + InstanceID string } type flashMessage struct { @@ -113,12 +118,16 @@ type schedulerResponse struct { RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"` } -func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, 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, restartContainer func()) (*Server, error) { templates, err := loadTemplates() if err != nil { return nil, err } - 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 + instanceID, err := secure.RandomToken(18) + if err != nil { + return nil, fmt.Errorf("create process instance ID: %w", err) + } + 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), instanceID: instanceID, restartContainer: restartContainer}, nil } func (s *Server) Handler() http.Handler { @@ -170,6 +179,7 @@ func (s *Server) Handler() http.Handler { 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("POST /system/restart", s.adminOnly(s.containerRestart)) mux.Handle("GET /errors", s.adminOnly(s.errorsPage)) mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) mux.Handle("GET /account", s.authenticated(s.accountPage)) @@ -192,6 +202,10 @@ func (s *Server) Handler() http.Handler { func (s *Server) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if s.instanceID != "" { + w.Header().Set("X-Domain-Control-Instance", s.instanceID) + } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) } @@ -1304,7 +1318,7 @@ func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, sess 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()}) + 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(), InstanceID: s.instanceID}) } func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) { @@ -1418,6 +1432,63 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, }) } +func (s *Server) containerRestart(w http.ResponseWriter, r *http.Request, session *model.Session) { + if err := s.parseForm(w, r); err != nil { + return + } + if !s.validSessionCSRF(r, session) { + http.Error(w, "Ungültige Anfrage", http.StatusForbidden) + return + } + if s.restartContainer == nil { + http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable) + return + } + if s.updates != nil { + statusCtx, cancel := context.WithTimeout(r.Context(), time.Second) + updateStatus, statusErr := s.updates.UpdateStatus(statusCtx) + cancel() + if statusErr == nil && updateStatus.Busy() { + http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict) + return + } + } + + s.restartMu.Lock() + if s.restartPending { + s.restartMu.Unlock() + s.writeRestartAccepted(w, r, true) + return + } + if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil { + s.restartMu.Unlock() + s.serverError(w, err) + return + } + s.restartPending = true + restart := s.restartContainer + s.restartMu.Unlock() + + s.writeRestartAccepted(w, r, false) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + restart() +} + +func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) { + w.Header().Set("Cache-Control", "no-store") + 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, "already_pending": alreadyPending, "instance_id": s.instanceID}) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n")) +} + func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { targets, err := s.store.ListTargetsWithErrors(r.Context()) if err != nil { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index aa66787..5c1c6f8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -292,7 +293,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) { `data-dashboard-ipv6`, `data-recent-runs aria-live="polite" aria-busy="true"`, `Aktualisierung läuft`, - `features-28`, + `features-29`, `data-local-time="1788001920"`, `dashboard-zone-item`, `DNS-Zone · example.de`, @@ -413,6 +414,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) { {method: http.MethodGet, path: "/users"}, {method: http.MethodGet, path: "/users/new"}, {method: http.MethodGet, path: "/system"}, + {method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"}, {method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"}, } for _, test := range requests { @@ -523,13 +525,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { t.Errorf("global update notification is missing %q", expected) } } + var staleNotification bytes.Buffer + staleData := accountData + staleData.SoftwareUpdate.AgentAvailable = false + staleData.SoftwareUpdate.AgentCompatible = false + if err := templates["security"].ExecuteTemplate(&staleNotification, "security", staleData); err != nil { + t.Fatal(err) + } + if !strings.Contains(staleNotification.String(), `data-software-update-form`) { + t.Fatal("stale boot-time agent state hides the live-checked update button") + } var page bytes.Buffer - systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState} + systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState, InstanceID: "instance-before-restart"} 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", "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"`} { + 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"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} { if !strings.Contains(page.String(), expected) { t.Errorf("software update page is missing %q", expected) } @@ -565,6 +577,77 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) { } } +func TestContainerRestartRequiresCSRFAndAuditsOneRequest(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() }) + var restartCalls atomic.Int32 + server := &Server{store: database, instanceID: "instance-before-restart", restartContainer: func() { restartCalls.Add(1) }} + session := &model.Session{CSRFToken: "valid-csrf", Username: "admin", Role: "admin"} + + invalidResponse := httptest.NewRecorder() + invalidRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=wrong")) + invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.containerRestart(invalidResponse, invalidRequest, session) + if invalidResponse.Code != http.StatusForbidden || restartCalls.Load() != 0 { + t.Fatalf("invalid CSRF triggered restart: status=%d calls=%d", invalidResponse.Code, restartCalls.Load()) + } + + requestRestart := func() *httptest.ResponseRecorder { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Accept", "application/json") + server.containerRestart(response, request, session) + return response + } + first := requestRestart() + if first.Code != http.StatusAccepted || restartCalls.Load() != 1 { + t.Fatalf("valid restart was not accepted once: status=%d calls=%d body=%s", first.Code, restartCalls.Load(), first.Body.String()) + } + var result map[string]any + if err := json.NewDecoder(first.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result["accepted"] != true || result["instance_id"] != "instance-before-restart" { + t.Fatalf("unexpected restart response: %#v", result) + } + second := requestRestart() + if second.Code != http.StatusAccepted || restartCalls.Load() != 1 { + t.Fatalf("duplicate restart was not idempotent: status=%d calls=%d", second.Code, restartCalls.Load()) + } + events, err := database.RecentAudit(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Kind != "system.container_restart" || !strings.Contains(events[0].Message, "admin") { + t.Fatalf("restart audit is missing or duplicated: %#v", events) + } + + unconfigured := &Server{store: database} + unconfiguredResponse := httptest.NewRecorder() + unconfiguredRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf")) + unconfiguredRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + unconfigured.containerRestart(unconfiguredResponse, unconfiguredRequest, session) + if unconfiguredResponse.Code != http.StatusServiceUnavailable { + t.Fatalf("restart without process callback returned %d", unconfiguredResponse.Code) + } +} + +func TestHealthExposesProcessInstanceWithoutCaching(t *testing.T) { + t.Parallel() + server := &Server{instanceID: "current-process-instance"} + response := httptest.NewRecorder() + server.health(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if response.Code != http.StatusOK || response.Header().Get("X-Domain-Control-Instance") != "current-process-instance" || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("health response does not identify the current process: status=%d headers=%v", response.Code, response.Header()) + } +} + func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) { t.Parallel() templates, err := loadTemplates() diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 748e908..a70e103 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -478,6 +478,115 @@ }); } + const containerRestartForm = document.querySelector('[data-container-restart-form]'); + if (containerRestartForm) { + const overlay = document.querySelector('[data-container-restart-overlay]'); + const title = overlay?.querySelector('[data-container-restart-title]'); + const message = overlay?.querySelector('[data-container-restart-message]'); + const close = overlay?.querySelector('[data-container-restart-close]'); + const submit = containerRestartForm.querySelector('button[type="submit"]'); + let polling = false; + let startedAt = 0; + let expectedInstance = containerRestartForm.dataset.instanceId || ''; + let consecutiveReady = 0; + let sawUnavailable = false; + + const setRestartState = (state, heading, text) => { + if (!overlay) return; + overlay.dataset.updateState = state; + overlay.setAttribute('aria-busy', String(state !== 'complete' && state !== 'failed')); + if (title) title.textContent = heading; + if (message) message.textContent = text; + }; + + const pollContainerRestart = async () => { + if (!polling) return; + if (Date.now() - startedAt > 90 * 1000) { + polling = false; + setRestartState('failed', 'Neustart nicht bestätigt', 'Die neue Container-Instanz wurde nicht rechtzeitig erreichbar. Prüfe den Containerstatus am Docker-Host.'); + if (close) close.hidden = false; + return; + } + try { + const response = await fetch(`/healthz?restart-check=${Date.now()}`, {cache: 'no-store', credentials: 'same-origin'}); + if (!response.ok) throw new Error('healthcheck unavailable'); + const currentInstance = response.headers.get('X-Domain-Control-Instance') || ''; + const isNewInstance = expectedInstance + ? (currentInstance !== '' && currentInstance !== expectedInstance) || (currentInstance === '' && sawUnavailable) + : sawUnavailable; + if (isNewInstance) { + consecutiveReady += 1; + setRestartState('restarting', 'Neue Instanz wird geprüft', 'Der Container ist wieder erreichbar. Die Verbindung wird noch einmal bestätigt.'); + if (consecutiveReady >= 2) { + polling = false; + setRestartState('complete', 'Container neu gestartet', 'Die neue Container-Instanz ist bereit. Die Seite wird neu geladen.'); + window.setTimeout(() => window.location.assign('/system'), 900); + return; + } + } else { + consecutiveReady = 0; + setRestartState('restarting', 'Container wird neu gestartet', 'Die bisherige Instanz wird geordnet beendet.'); + } + } catch { + sawUnavailable = true; + consecutiveReady = 0; + setRestartState('restarting', 'Verbindung wird aufgebaut', 'Der Container ist kurz nicht erreichbar und startet automatisch neu.'); + } + window.setTimeout(pollContainerRestart, 700); + }; + + containerRestartForm.addEventListener('submit', async (event) => { + event.preventDefault(); + if (polling || !overlay) return; + if (!window.confirm('Den vollständigen DynDNS-Container jetzt neu starten? Der Dienst ist kurz nicht erreichbar.')) return; + + const formData = new FormData(containerRestartForm); + const requestBody = new URLSearchParams(); + for (const [name, value] of formData) { + if (typeof value === 'string') requestBody.append(name, value); + } + polling = true; + startedAt = Date.now(); + consecutiveReady = 0; + sawUnavailable = false; + overlay.hidden = false; + if (close) close.hidden = true; + if (submit) submit.disabled = true; + document.body.classList.add('software-update-open'); + setRestartState('restarting', 'Neustart wird vorbereitet', 'Die Anwendung wird geordnet beendet und automatisch wieder gestartet.'); + + let responseReceived = false; + try { + const response = await fetch(containerRestartForm.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'}); + responseReceived = true; + const contentType = response.headers.get('content-type') || ''; + const result = contentType.includes('application/json') + ? await response.json().catch(() => ({})) + : {message: (await response.text()).trim().slice(0, 240)}; + if (!response.ok || !result.accepted) throw new Error(result.message || 'Container-Neustart konnte nicht gestartet werden.'); + expectedInstance = result.instance_id || expectedInstance; + } catch (error) { + if (responseReceived) { + polling = false; + setRestartState('failed', 'Neustart nicht gestartet', error instanceof Error ? error.message : 'Container-Neustart konnte nicht gestartet werden.'); + if (close) close.hidden = false; + if (submit) submit.disabled = false; + return; + } + sawUnavailable = true; + } + window.setTimeout(pollContainerRestart, 350); + }); + + close?.addEventListener('click', () => { + polling = false; + overlay.hidden = true; + close.hidden = true; + document.body.classList.remove('software-update-open'); + if (submit) submit.disabled = false; + }); + } + const countdowns = [...document.querySelectorAll('[data-countdown]')]; const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')]; const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]'); diff --git a/internal/web/static/styles.css b/internal/web/static/styles.css index cf8359a..50d3e62 100644 --- a/internal/web/static/styles.css +++ b/internal/web/static/styles.css @@ -513,6 +513,17 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha .software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); } .software-update-button i { font-style: normal; transition: transform .2s ease; } .software-update-button:hover i { transform: translate(2px,-2px); } +.container-restart-panel { display: grid; width: min(930px,100%); grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; margin-bottom: 12px; } +.container-restart-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #c8d3df; border-radius: 12px; background: linear-gradient(145deg,#f7f9fb,#edf1f5); color: #526174; font-family: var(--mono); font-size: 18px; font-weight: 900; } +.container-restart-copy h2 { margin-bottom: 3px; } +.container-restart-copy p { max-width: 650px; margin: 0; color: var(--muted); font-size: 9px; line-height: 1.55; } +.container-restart-panel form { position: relative; z-index: 1; } +.container-restart-overlay .software-update-dialog { width: min(500px,100%); } +.container-restart-progress { height: 7px; overflow: hidden; margin-top: 18px; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); } +.container-restart-progress i { display: block; width: 38%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); animation: container-restart-progress 1.35s ease-in-out infinite; } +.container-restart-overlay[data-update-state="complete"] .container-restart-progress i { width: 100%; background: #34d399; animation: none; } +.container-restart-overlay[data-update-state="failed"] .container-restart-progress i { width: 100%; background: #e45c70; animation: none; } +.container-restart-overlay [data-container-restart-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; } .update-credential-panel { width: min(930px,100%); } .update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); } .update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; } @@ -613,6 +624,7 @@ body.software-update-open { overflow: hidden; } @keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } } @keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } } @keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } } +@keyframes container-restart-progress { from { transform: translateX(-115%); } to { transform: translateX(305%); } } .error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; } .error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; } @@ -709,6 +721,9 @@ body.software-update-open { overflow: hidden; } .software-version-visual { width: 46px; height: 46px; border-radius: 13px; } .software-version-action { grid-column: 1 / -1; } .software-version-action form, .software-version-action .button { width: 100%; } + .container-restart-panel { grid-template-columns: 42px minmax(0,1fr); } + .container-restart-panel form { grid-column: 1 / -1; } + .container-restart-panel form, .container-restart-panel .button { width: 100%; } .update-access-state { grid-template-columns: 42px minmax(0,1fr); } .update-access-state > .status-pill { grid-column: 2; justify-self: start; } .admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); } @@ -755,6 +770,7 @@ body.software-update-open { overflow: hidden; } .software-update-dialog p { min-height: 30px; margin-bottom: 12px; } .software-update-phases { margin-top: 14px; } .software-update-dialog [data-software-update-close] { margin-top: 14px; } + .container-restart-overlay [data-container-restart-close] { margin-top: 14px; } .onboarding-intro { padding-block: 30px; } .onboarding-copy { margin-top: 28px; } .onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index aa16d99..b0745f3 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -6,8 +6,8 @@ {{.Title}} · DNS Control - - + + @@ -49,7 +49,7 @@
Neue Version verfügbar{{.SoftwareUpdate.LatestVersion}} ist bereit
Details - {{if .SoftwareUpdate.AgentCompatible}}
{{end}} + {{if not .SoftwareUpdate.ComposeSyncRequired}}
{{end}}
{{end}} diff --git a/internal/web/templates/system.html b/internal/web/templates/system.html index edbeb4f..45c14f3 100644 --- a/internal/web/templates/system.html +++ b/internal/web/templates/system.html @@ -18,7 +18,7 @@ {{if .SoftwareUpdate.ComposeSyncRequired}}
↻

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

{{end}}
- {{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}} + {{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
@@ -33,6 +33,15 @@
+
+ +

Anwendungscontainer neu starten

Beendet den vollständigen DynDNS-Container geordnet und startet ihn mit unveränderten Daten, Secrets und Einstellungen neu. Der Dienst ist dabei kurz nicht erreichbar.

+ + + + +
+
{{if .SoftwareUpdate.CredentialConfigured}}
@@ -54,5 +63,19 @@ {{if .SoftwareUpdate.CredentialConfigured}}
{{end}} + + {{template "software_update_overlay" .}} {{end}}