Compare commits

..
3 Commits
Author SHA1 Message Date
admin 3383b91e49 chore: release v1.5.0 2026-09-07 15:32:50 +02:00
admin 7bb33e528c feat: add resilient container restart controls 2026-09-07 13:27:27 +02:00
admin 1bbb14470c fix: align domain creation fields 2026-08-30 02:05:27 +02:00
18 changed files with 749 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG VERSION=v1.4.2 ARG VERSION=v1.5.0
ARG MINIMUM_UPDATE_VERSION=v1.4.0 ARG MINIMUM_UPDATE_VERSION=v1.4.0
ARG MINIMUM_AGENT_PROTOCOL=2 ARG MINIMUM_AGENT_PROTOCOL=2
ARG RUNTIME_CONTRACT_VERSION=2 ARG RUNTIME_CONTRACT_VERSION=2
+4 -1
View File
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
```yaml ```yaml
services: services:
dyndns: dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
restart: unless-stopped restart: unless-stopped
stop_grace_period: 20s stop_grace_period: 20s
ports: ["9090:8080"] ports: ["9090:8080"]
@@ -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. 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: 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 ```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 - Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte - 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 - 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 - 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 - 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
+14 -3
View File
@@ -1,6 +1,6 @@
# Domain Control aktualisieren # Domain Control aktualisieren
Die aktuelle Compose-Version ist `v1.4.2`. Datenbank, Einstellungen und Die aktuelle Compose-Version ist `v1.5.0`. Datenbank, Einstellungen und
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
den folgenden Verfahren erhalten. den folgenden Verfahren erhalten.
@@ -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 Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt. 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 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 `v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
@@ -34,7 +45,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
```yaml ```yaml
services: services:
dyndns: dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
update-agent: update-agent:
image: *image image: *image
``` ```
@@ -68,7 +79,7 @@ Die erwartete Antwort lautet `ok`.
## Portainer oder Dockge ## Portainer oder Dockge
1. Den bestehenden Stack bearbeiten. 1. Den bestehenden Stack bearbeiten.
2. Nur den Tag der zentralen Image-Zeile auf `v1.4.2` setzen. 2. Nur den Tag der zentralen Image-Zeile auf `v1.5.0` setzen.
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen. 3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist. 4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
+24 -1
View File
@@ -9,6 +9,7 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -66,6 +67,7 @@ func commandForInvocation(arguments []string) string {
} }
func run(logger *slog.Logger) error { func run(logger *slog.Logger) error {
startedAt := time.Now()
// Credentials and session data must never inherit a permissive process umask. // Credentials and session data must never inherit a permissive process umask.
syscall.Umask(0o077) syscall.Umask(0o077)
cfg, err := config.Load() cfg, err := config.Load()
@@ -103,7 +105,22 @@ func run(logger *slog.Logger) error {
logger.Info("removed AAAA records for disabled IPv6", "count", deleted) 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 { if err != nil {
return err return err
} }
@@ -133,6 +150,12 @@ func run(logger *slog.Logger) error {
return err return err
} }
return nil 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(): case <-appCtx.Done():
logger.Info("shutdown requested") logger.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
dyndns: dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.2 image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
restart: unless-stopped restart: unless-stopped
stop_grace_period: 20s stop_grace_period: 20s
ports: ["9090:8080"] ports: ["9090:8080"]
+43 -4
View File
@@ -22,6 +22,16 @@ import (
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet") 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 { type State struct {
CurrentVersion string CurrentVersion string
LatestVersion string LatestVersion string
@@ -46,9 +56,10 @@ type Manager struct {
logger *slog.Logger logger *slog.Logger
apiURL string apiURL string
interval time.Duration interval time.Duration
agent *AgentClient agent updateAgent
httpClient *http.Client httpClient *http.Client
checkMu sync.Mutex checkMu sync.Mutex
requestMu sync.Mutex
stateMu sync.RWMutex stateMu sync.RWMutex
state State state State
checkSignal chan struct{} 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) { func (m *Manager) Start(ctx context.Context) {
go m.monitorAgent(ctx, agentUnavailableRetryInterval, agentAvailableCheckInterval)
go func() { go func() {
m.refreshAgentStatus(ctx)
initial := time.NewTimer(15 * time.Second) initial := time.NewTimer(15 * time.Second)
defer initial.Stop() defer initial.Stop()
var ticker *time.Ticker 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) { 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.logger.Warn("software update check failed", "error", err)
} }
}
func (m *Manager) monitorAgent(ctx context.Context, unavailableInterval, availableInterval time.Duration) {
for {
m.refreshAgentStatus(ctx) 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() { 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 { func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
m.requestMu.Lock()
defer m.requestMu.Unlock()
version = strings.TrimSpace(version) version = strings.TrimSpace(version)
state := m.State() state := m.State()
if !validReleaseVersion(version) || version != state.LatestVersion { 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 { 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") 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") 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") 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) settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil { if err != nil {
return err return err
+212 -3
View File
@@ -3,12 +3,14 @@ package appupdate
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -19,6 +21,48 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" "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) { func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel() t.Parallel()
const credential = " test-password-or-token-that-must-stay-secret " const credential = " test-password-or-token-that-must-stay-secret "
@@ -34,7 +78,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.4.3","html_url":"https://example.invalid/releases/v1.4.3","draft":false,"prerelease":false}`)) _, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`))
})) }))
defer api.Close() defer api.Close()
@@ -67,7 +111,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
state := manager.State() state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.4.3" || state.LastError != "" || state.CredentialUsername != "registry-user" { if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" {
t.Fatalf("new release was not detected: %#v", state) t.Fatalf("new release was not detected: %#v", state)
} }
settings, err := database.GetSoftwareUpdateSettings(ctx) settings, err := database.GetSoftwareUpdateSettings(ctx)
@@ -83,7 +127,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal("unavailable Gitea endpoint was reported as successful") t.Fatal("unavailable Gitea endpoint was reported as successful")
} }
state = manager.State() state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.4.3" || state.LastError == "" { if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state) t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
} }
} }
@@ -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.5.1"); 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.5.1" {
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.5.1"); 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.5.1")
}()
}
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) { func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@@ -176,3 +330,58 @@ func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
t.Fatalf("completed desired version remained persisted: %#v", settings) 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.5.1", "https://git.example/releases/v1.5.1", 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")
}
+1 -1
View File
@@ -18,4 +18,4 @@ const (
// Version is replaced through -ldflags for release images. The source fallback // Version is replaced through -ldflags for release images. The source fallback
// keeps local builds and tests honest about the currently checked-out release. // keeps local builds and tests honest about the currently checked-out release.
var Version = "v1.4.2" var Version = "v1.5.0"
+79 -2
View File
@@ -64,7 +64,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
if !updateprotocol.ValidVersion(request.Version) { if !updateprotocol.ValidVersion(request.Version) {
return errors.New("invalid target 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} return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
} }
target, err := r.docker.findTarget(ctx, r.targetLabel) 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 { if err != nil {
return err 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")} 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) 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) { func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
if versioned { if versioned {
path = c.apiPrefix + path path = c.apiPrefix + path
+55
View File
@@ -1,13 +1,26 @@
package updateagent package updateagent
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"io"
"net/http"
"strconv" "strconv"
"strings"
"sync/atomic"
"testing" "testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo" "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) { func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
t.Parallel() t.Parallel()
current := compatibleImage("v1.4.0", "v1.4.0") 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) { func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
t.Parallel() t.Parallel()
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`) 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 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"}
}
+74 -3
View File
@@ -43,6 +43,10 @@ type Server struct {
limiter *loginLimiter limiter *loginLimiter
dummyHash string dummyHash string
authSlots chan struct{} authSlots chan struct{}
instanceID string
restartContainer func()
restartMu sync.Mutex
restartPending bool
} }
type viewData struct { type viewData struct {
@@ -79,6 +83,7 @@ type viewData struct {
ErrorCount int ErrorCount int
PiHoleView *piHoleView PiHoleView *piHoleView
SoftwareUpdate appupdate.State SoftwareUpdate appupdate.State
InstanceID string
} }
type flashMessage struct { type flashMessage struct {
@@ -113,12 +118,16 @@ type schedulerResponse struct {
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"` 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() templates, err := loadTemplates()
if err != nil { if err != nil {
return nil, err 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 { 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/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck)) mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart)) 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("GET /errors", s.adminOnly(s.errorsPage))
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
mux.Handle("GET /account", s.authenticated(s.accountPage)) 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) { func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") 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.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n")) _, _ = 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) statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
_, _ = s.updates.UpdateStatus(statusCtx) _, _ = s.updates.UpdateStatus(statusCtx)
cancel() 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) { 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) { func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
targets, err := s.store.ListTargetsWithErrors(r.Context()) targets, err := s.store.ListTargetsWithErrors(r.Context())
if err != nil { if err != nil {
+86 -3
View File
@@ -14,6 +14,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -292,7 +293,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
`data-dashboard-ipv6`, `data-dashboard-ipv6`,
`data-recent-runs aria-live="polite" aria-busy="true"`, `data-recent-runs aria-live="polite" aria-busy="true"`,
`Aktualisierung läuft`, `Aktualisierung läuft`,
`features-27`, `features-29`,
`data-local-time="1788001920"`, `data-local-time="1788001920"`,
`dashboard-zone-item`, `dashboard-zone-item`,
`DNS-Zone · example.de`, `DNS-Zone · example.de`,
@@ -413,6 +414,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
{method: http.MethodGet, path: "/users"}, {method: http.MethodGet, path: "/users"},
{method: http.MethodGet, path: "/users/new"}, {method: http.MethodGet, path: "/users/new"},
{method: http.MethodGet, path: "/system"}, {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"}, {method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
} }
for _, test := range requests { for _, test := range requests {
@@ -523,13 +525,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Errorf("global update notification is missing %q", expected) 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 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 { if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
t.Fatal(err) 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) { if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", 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) { func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
t.Parallel() t.Parallel()
templates, err := loadTemplates() 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 countdowns = [...document.querySelectorAll('[data-countdown]')];
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')]; const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]'); const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
+17
View File
@@ -209,6 +209,7 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } .zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.zone-card:hover { border-color: #b6c4d4; } .zone-card:hover { border-color: #b6c4d4; }
.zone-card-top { display: flex; align-items: center; justify-content: space-between; } .zone-card-top { display: flex; align-items: center; justify-content: space-between; }
.domain-service-label { display: inline-flex; min-height: 22px; align-items: center; padding: 0 7px; border: 1px solid #c7d6e8; border-radius: 5px; background: #f1f6fd; color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .03em; }
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; } .zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; } .zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; } .zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
@@ -512,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 { 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 i { font-style: normal; transition: transform .2s ease; }
.software-update-button:hover i { transform: translate(2px,-2px); } .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 { width: min(930px,100%); }
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); } .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; } .update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
@@ -612,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-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-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 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 { 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; } .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; }
@@ -708,6 +721,9 @@ body.software-update-open { overflow: hidden; }
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; } .software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
.software-version-action { grid-column: 1 / -1; } .software-version-action { grid-column: 1 / -1; }
.software-version-action form, .software-version-action .button { width: 100%; } .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 { grid-template-columns: 42px minmax(0,1fr); }
.update-access-state > .status-pill { grid-column: 2; justify-self: start; } .update-access-state > .status-pill { grid-column: 2; justify-self: start; }
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); } .admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
@@ -754,6 +770,7 @@ body.software-update-open { overflow: hidden; }
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; } .software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
.software-update-phases { margin-top: 14px; } .software-update-phases { margin-top: 14px; }
.software-update-dialog [data-software-update-close] { 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-intro { padding-block: 30px; }
.onboarding-copy { margin-top: 28px; } .onboarding-copy { margin-top: 28px; }
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; } .onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
+1 -1
View File
@@ -4,7 +4,7 @@
<section class="form-panel compact-panel"> <section class="form-panel compact-panel">
<form action="/domains" method="post" class="stacked-form wide-form"> <form action="/domains" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div> <div class="form-grid domain-create-identity-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"><small class="field-spacer" aria-hidden="true">&nbsp;</small></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label> <label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div> <div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div> <div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
+1 -1
View File
@@ -8,7 +8,7 @@
{{if .Zones}} {{if .Zones}}
{{range .Zones}} {{range .Zones}}
<article class="zone-card"> <article class="zone-card">
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div> <div class="zone-card-top"><span class="domain-service-label">DynDNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
<h2>{{.Name}}</h2> <h2>{{.Name}}</h2>
<dl><div><dt>Letzter Import</dt><dd><time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl> <dl><div><dt>Letzter Import</dt><dd><time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}} {{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
+3 -3
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title> <title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-27"> <link rel="stylesheet" href="/static/styles.css?v=features-29">
<script src="/static/app.js?v=features-27" defer></script> <script src="/static/app.js?v=features-29" defer></script>
</head> </head>
<body class="page-{{.Page}}"> <body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a> <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><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
<div class="software-update-banner-actions"> <div class="software-update-banner-actions">
<a class="button button-quiet" href="/system">Details</a> <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> </div>
</aside> </aside>
{{end}} {{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}} {{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>
<div class="software-version-action"> <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> <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}}"> <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> <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> </div>
</section> </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"> <section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
{{if .SoftwareUpdate.CredentialConfigured}} {{if .SoftwareUpdate.CredentialConfigured}}
<div class="update-access-state"> <div class="update-access-state">
@@ -54,5 +63,19 @@
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}} {{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
</section> </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" .}} {{template "software_update_overlay" .}}
{{end}} {{end}}