feat: add resilient container restart controls

This commit is contained in:
admin committed 2026-09-07 13:27:27 +02:00
1 parent 1bbb14470c
commit 7bb33e528c
13 files changed
+747 -28

No files matched your search

+44 -5
View File
@@ -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
+209
View File
@@ -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")
}
+79 -2
View File
@@ -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
+55
View File
@@ -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"}
}
+84 -13
View File
@@ -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 {
+86 -3
View File
@@ -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", `<details class="update-credential-editor">`, `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", `<details class="update-credential-editor">`, `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()
+109
View File
@@ -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]');
+16
View File
@@ -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; }
+3 -3
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-28">
<script src="/static/app.js?v=features-28" defer></script>
<link rel="stylesheet" href="/static/styles.css?v=features-29">
<script src="/static/app.js?v=features-29" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -49,7 +49,7 @@
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
<div class="software-update-banner-actions">
<a class="button button-quiet" href="/system">Details</a>
{{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
{{if not .SoftwareUpdate.ComposeSyncRequired}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
</div>
</aside>
{{end}}
+24 -1
View File
@@ -18,7 +18,7 @@
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
</div>
<div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
<form action="/system/update" method="post" data-software-update-form>
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
@@ -33,6 +33,15 @@
</div>
</section>
<section class="panel container-restart-panel">
<span class="container-restart-icon" aria-hidden="true">↻</span>
<div class="container-restart-copy"><span class="section-label">Containersteuerung</span><h2>Anwendungscontainer neu starten</h2><p>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.</p></div>
<form action="/system/restart" method="post" data-container-restart-form data-instance-id="{{.InstanceID}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-quiet" type="submit">Container neu starten</button>
</form>
</section>
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
{{if .SoftwareUpdate.CredentialConfigured}}
<div class="update-access-state">
@@ -54,5 +63,19 @@
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
</section>
<div class="software-update-overlay container-restart-overlay" data-container-restart-overlay data-update-state="restarting" hidden role="dialog" aria-modal="true" aria-labelledby="container-restart-title">
<div class="software-update-dialog">
<div class="software-update-visual" aria-hidden="true">
<span class="software-update-scan"></span>
<div class="software-update-orbit"><span>DC</span><i></i></div>
</div>
<span class="section-label">Container-Neustart</span>
<h2 id="container-restart-title" data-container-restart-title>Neustart wird vorbereitet</h2>
<p data-container-restart-message aria-live="polite">Die Anwendung wird geordnet beendet und automatisch wieder gestartet.</p>
<div class="container-restart-progress" aria-hidden="true"><i></i></div>
<button class="button button-quiet" type="button" data-container-restart-close hidden>Schließen</button>
</div>
</div>
{{template "software_update_overlay" .}}
{{end}}