Compare commits

..
4 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
admin 26a960c6bd fix: stabilize software updates 2026-08-29 18:38:38 +02:00
28 changed files with 1219 additions and 158 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=v1.4.1
ARG VERSION=v1.5.0
ARG MINIMUM_UPDATE_VERSION=v1.4.0
ARG MINIMUM_AGENT_PROTOCOL=2
ARG RUNTIME_CONTRACT_VERSION=2
+5 -2
View File
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
```yaml
services:
dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
restart: unless-stopped
stop_grace_period: 20s
ports: ["9090:8080"]
@@ -61,12 +61,14 @@ Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden
Eine vollständige Schritt-für-Schritt-Anleitung für WebUI, Docker Compose,
Portainer/Dockge und Rollbacks steht in [`UPDATE.md`](UPDATE.md).
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Der Gitea-Benutzername und Token werden einmalig in der Weboberfläche hinterlegt; der Token wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben.
Administratoren können im Menü **Update** nach stabilen Releases suchen und eine neue Version direkt installieren. Dabei dürfen auch mehrere Versionen bis hin zu einem kompatiblen Major-Release übersprungen werden. Vor dem Neustart prüft der Agent Versionsgrenze, Agent-Protokoll, Datenbankvertrag, Laufzeitkonfiguration und Healthcheck des Ziel-Images. Gitea-Benutzername und wahlweise Passwort oder Token werden einmalig in der Weboberfläche hinterlegt; das Geheimnis wird mit dem Master-Key verschlüsselt gespeichert und weder an Templates noch an den Browser zurückgegeben.
`v1.4.0` ist die sichere Basis für diesen Update-Weg. Eine ältere Installation wird einmalig mit Docker Compose auf `v1.4.0` gebracht; danach kann der Agent auch spätere kompatible Releases direkt installieren. Web-App und Agent dürfen dabei unterschiedliche Versionsnummern haben: Maßgeblich ist das ausdrücklich ausgewiesene Protokoll. Verlangt ein Ziel-Image einen neueren Vertrag, bleibt der laufende Container unverändert und die Oberfläche fordert ein Compose-Update an.
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
Auf derselben Seite kann ein Administrator den vollständigen Anwendungscontainer geordnet neu starten. Dabei bleiben Daten-, Secret- und Steuer-Volumes unverändert. Der Neustart beendet den Prozess im Container; die in der unterstützten Compose-Konfiguration gesetzte Richtlinie `restart: unless-stopped` startet ihn anschließend automatisch wieder. Die Oberfläche erkennt die neue Prozessinstanz und verbindet sich selbstständig neu.
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
```bash
@@ -88,6 +90,7 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
- administrativer Container-Neustart mit automatischem Wiederverbinden der Weboberfläche
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
+16 -5
View File
@@ -1,14 +1,14 @@
# Domain Control aktualisieren
Die aktuelle Compose-Version ist `v1.4.1`. 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
den folgenden Verfahren erhalten.
## Direkt in der Weboberfläche
1. Als Administrator anmelden und **Update** öffnen.
2. Falls noch nicht geschehen, Gitea-Benutzername und einen Token mit
Lesezugriff auf Repository und Container-Paket hinterlegen.
2. Falls noch nicht geschehen, Gitea-Benutzername und das Passwort oder einen
Token mit Lesezugriff auf Repository und Container-Paket hinterlegen.
3. **Nach Update suchen** und anschließend **Jetzt aktualisieren** wählen.
4. Warten, bis Image-Download, Containerwechsel und Healthcheck abgeschlossen
sind. Bei einem Fehler versucht der Update-Agent automatisch, den vorherigen
@@ -20,6 +20,17 @@ das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
Nach einem Neustart des Docker-Hosts prüft die Web-App den Update-Agenten mit
kurzem Abstand erneut. Der Update-Klick führt zusätzlich immer eine aktuelle
Prüfung durch und wartet bei Bedarf darauf, dass der Container seinen
Start-Healthcheck beendet. Ein separater manueller Neustart der Anwendung ist
dafür nicht mehr erforderlich.
Über **Container neu starten** auf der Update-Seite lässt sich der vollständige
Anwendungscontainer außerdem gezielt neu starten. Datenbank, Secrets und
Einstellungen bleiben erhalten. Diese Funktion setzt die im mitgelieferten
Compose konfigurierte Richtlinie `restart: unless-stopped` voraus.
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
@@ -34,7 +45,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
```yaml
services:
dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
update-agent:
image: *image
```
@@ -68,7 +79,7 @@ Die erwartete Antwort lautet `ok`.
## Portainer oder Dockge
1. Den bestehenden Stack bearbeiten.
2. Nur den Tag der zentralen Image-Zeile auf `v1.4.1` setzen.
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.0` setzen.
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
+24 -1
View File
@@ -9,6 +9,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -66,6 +67,7 @@ func commandForInvocation(arguments []string) string {
}
func run(logger *slog.Logger) error {
startedAt := time.Now()
// Credentials and session data must never inherit a permissive process umask.
syscall.Umask(0o077)
cfg, err := config.Load()
@@ -103,7 +105,22 @@ func run(logger *slog.Logger) error {
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
}
}
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger)
restartRequested := make(chan struct{})
var restartOnce sync.Once
requestRestart := func() {
restartOnce.Do(func() {
// Docker applies restart policies only after a container has stayed up
// long enough to be considered successfully started. Keep very early
// UI requests alive until that safety window has passed.
delay := time.Until(startedAt.Add(11 * time.Second))
if delay > 0 {
time.AfterFunc(delay, func() { close(restartRequested) })
return
}
close(restartRequested)
})
}
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger, requestRestart)
if err != nil {
return err
}
@@ -133,6 +150,12 @@ func run(logger *slog.Logger) error {
return err
}
return nil
case <-restartRequested:
logger.Info("container restart requested")
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return httpServer.Shutdown(shutdownCtx)
case <-appCtx.Done():
logger.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
+1 -1
View File
@@ -1,6 +1,6 @@
services:
dyndns:
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.1
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
restart: unless-stopped
stop_grace_period: 20s
ports: ["9090:8080"]
+81 -21
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
@@ -30,6 +40,7 @@ type State struct {
LastCheckedAt time.Time
LastError string
CredentialConfigured bool
CredentialUsername string
UpdateAvailable bool
AgentAvailable bool
AgentCompatible bool
@@ -45,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{}
@@ -86,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
@@ -120,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)
}
}
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() {
@@ -136,15 +169,15 @@ func (m *Manager) State() State {
return m.state
}
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
func (m *Manager) SaveCredential(ctx context.Context, username, credential string) error {
username = strings.TrimSpace(username)
if username == "" || len(username) > 128 {
return errors.New("Gitea-Benutzername fehlt oder ist zu lang")
}
if len(token) < 8 || len(token) > 1024 {
return errors.New("Gitea-Token fehlt oder hat eine ungültige Länge")
if len(credential) < 8 || len(credential) > 1024 {
return errors.New("Gitea-Passwort oder -Token fehlt oder hat eine ungültige Länge")
}
ciphertext, nonce, err := m.cipher.Encrypt(token)
ciphertext, nonce, err := m.cipher.Encrypt(credential)
if err != nil {
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
}
@@ -169,11 +202,11 @@ func (m *Manager) CheckNow(ctx context.Context) error {
m.setStoredState(settings)
return ErrCredentialMissing
}
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil {
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
}
release, err := m.fetchLatest(ctx, token)
release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential)
if err != nil {
return m.recordFailure(ctx, err)
}
@@ -189,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 {
@@ -197,24 +233,28 @@ 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
}
token, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
if err != nil || settings.RegistryUsername == "" {
return ErrCredentialMissing
}
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
return err
}
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential}
if err := m.agent.Start(ctx, request); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
m.clearDesiredVersion(cleanupCtx, version)
@@ -254,17 +294,17 @@ func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, erro
return status, err
}
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
func (m *Manager) fetchLatest(ctx context.Context, username, credential string) (releaseResponse, error) {
response, err := m.fetchLatestWithAuth(ctx, username, credential, true)
if err != nil {
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
return releaseResponse{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "token "+token)
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
response, err := m.httpClient.Do(request)
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
response.Body.Close()
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
if err != nil {
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
return releaseResponse{}, err
}
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
@@ -284,6 +324,25 @@ func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseRespons
return release, nil
}
func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
if err != nil {
return nil, errors.New("Update-Endpunkt ist ungültig")
}
request.Header.Set("Accept", "application/json")
if basic {
request.SetBasicAuth(username, credential)
} else {
request.Header.Set("Authorization", "token "+credential)
}
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
response, err := m.httpClient.Do(request)
if err != nil {
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
}
return response, nil
}
func (m *Manager) safeReleaseURL(raw string) string {
apiURL, apiErr := url.Parse(m.apiURL)
releaseURL, releaseErr := url.Parse(raw)
@@ -313,6 +372,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
LastCheckedAt: settings.LastCheckedAt,
LastError: settings.LastError,
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
CredentialUsername: settings.RegistryUsername,
}
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
state.UpdateAvailable = comparison < 0
+243 -8
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,12 +21,55 @@ 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 token = "test-token-that-must-stay-secret"
const credential = " test-password-or-token-that-must-stay-secret "
var unavailable atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token "+token {
username, password, ok := r.BasicAuth()
if !ok || username != "registry-user" || password != credential {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
@@ -33,7 +78,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.4.2","html_url":"https://example.invalid/releases/v1.4.2","draft":false,"prerelease":false}`))
_, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`))
}))
defer api.Close()
@@ -59,22 +104,22 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
if err := manager.SaveCredential(ctx, "registry-user", credential); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError != "" {
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" {
t.Fatalf("new release was not detected: %#v", state)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(settings.TokenCiphertext), token) {
t.Fatal("registry token was stored as plaintext")
if strings.Contains(string(settings.TokenCiphertext), credential) {
t.Fatal("registry credential was stored as plaintext")
}
unavailable.Store(true)
@@ -82,11 +127,36 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.4.2" || state.LastError == "" {
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
func TestManagerFallsBackToTokenAuthentication(t *testing.T) {
t.Parallel()
const credential = "fallback-api-token"
var requests atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Authorization") != "token "+credential {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v2.0.0","html_url":"https://example.invalid/releases/v2.0.0","draft":false,"prerelease":false}`))
}))
defer api.Close()
manager := &Manager{apiURL: api.URL, httpClient: api.Client()}
release, err := manager.fetchLatest(context.Background(), "registry-user", credential)
if err != nil {
t.Fatal(err)
}
if release.TagName != "v2.0.0" || requests.Load() != 2 {
t.Fatalf("token fallback was not used: release=%#v requests=%d", release, requests.Load())
}
}
func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -112,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) {
t.Parallel()
ctx := context.Background()
@@ -150,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.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
// keeps local builds and tests honest about the currently checked-out release.
var Version = "v1.4.1"
var Version = "v1.5.0"
+1 -1
View File
@@ -91,7 +91,7 @@ type PiHoleCredential struct {
}
// SoftwareUpdateSettings contains the locally persisted update state. The
// encrypted registry token is returned only to trusted backend components and
// encrypted registry credential is returned only to trusted backend components and
// is never passed to templates.
type SoftwareUpdateSettings struct {
RegistryUsername string
+2 -3
View File
@@ -69,7 +69,6 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
request.Version = strings.TrimSpace(request.Version)
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
@@ -99,12 +98,12 @@ func (c *Controller) run(request updateprotocol.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
err := c.runner.Update(ctx, request, c.setPhase)
registryToken := request.RegistryToken
registryCredential := request.RegistryToken
request.RegistryToken = ""
if err != nil {
message := publicAgentError(err)
c.setPhase(updateprotocol.PhaseFailed, message)
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryCredential))
return
}
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
+1 -1
View File
@@ -67,7 +67,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
}
const secret = "registry-token-must-not-leak"
const secret = " registry-password-must-not-be-trimmed "
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
+81 -4
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)
@@ -304,8 +315,8 @@ func (c *dockerClient) findTarget(ctx context.Context, label string) (containerS
return running[0], nil
}
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, token string) error {
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": token, "serveraddress": strings.Split(repository, "/")[0]})
func (c *dockerClient) pullImage(ctx context.Context, repository, tag, username, credential string) error {
authJSON, _ := json.Marshal(map[string]string{"username": username, "password": credential, "serveraddress": strings.Split(repository, "/")[0]})
headers := http.Header{"X-Registry-Auth": {base64.URLEncoding.EncodeToString(authJSON)}}
query := url.Values{"fromImage": {repository}, "tag": {tag}}
response, err := c.request(ctx, http.MethodPost, "/images/create?"+query.Encode(), nil, headers, true)
@@ -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"}
}
+2
View File
@@ -24,6 +24,8 @@ type Request struct {
Version string `json:"version"`
CurrentVersion string `json:"current_version"`
RegistryUsername string `json:"registry_username"`
// RegistryToken keeps the v2 wire name for compatibility and carries either
// a registry password or an API token.
RegistryToken string `json:"registry_token"`
}
+83 -4
View File
@@ -43,6 +43,10 @@ type Server struct {
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) {
@@ -1411,11 +1425,70 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
"phase": status.Phase,
"message": status.Message,
"target_version": status.TargetVersion,
"started_at": status.StartedAt,
"updated_at": status.UpdatedAt,
"current_version": state.CurrentVersion,
"latest_version": state.LatestVersion,
})
}
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 {
@@ -1888,7 +1961,13 @@ func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool
func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
if err := r.ParseForm(); err != nil {
var err error
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
err = r.ParseMultipartForm(64 << 10)
} else {
err = r.ParseForm()
}
if err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return err
}
+131 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -13,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -204,6 +206,31 @@ func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
}
}
func TestParseFormAcceptsMultipartUpdateFields(t *testing.T) {
t.Parallel()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("csrf", "csrf-token"); err != nil {
t.Fatal(err)
}
if err := writer.WriteField("version", "v1.4.2"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/system/update", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
server := &Server{}
if err := server.parseForm(response, request); err != nil {
t.Fatalf("multipart update form was rejected: status=%d body=%s", response.Code, response.Body.String())
}
if request.FormValue("csrf") != "csrf-token" || request.FormValue("version") != "v1.4.2" {
t.Fatalf("multipart fields were not parsed: %#v", request.Form)
}
}
func TestLoginErrorRendersAsToast(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
@@ -242,6 +269,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
now := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
data := viewData{
Title: "Übersicht",
Page: "dashboard",
@@ -251,7 +279,8 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
Dashboard: model.Dashboard{
Settings: model.Settings{UpdaterEnabled: true, IPv6Enabled: true},
SchedulerRunning: true,
RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: time.Now(), Status: "running"}},
RecentRuns: []model.UpdateRun{{ID: 1, StartedAt: now, Status: "running"}},
Zones: []model.Zone{{ID: 7, Name: "example.de", LastSyncAt: now}},
},
}
if err := templates["dashboard"].ExecuteTemplate(&rendered, "dashboard", data); err != nil {
@@ -264,7 +293,10 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
`data-dashboard-ipv6`,
`data-recent-runs aria-live="polite" aria-busy="true"`,
`Aktualisierung läuft`,
`features-24`,
`features-29`,
`data-local-time="1788001920"`,
`dashboard-zone-item`,
`DNS-Zone · example.de`,
} {
if !strings.Contains(body, expected) {
t.Errorf("rendered dashboard is missing %q", expected)
@@ -382,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 {
@@ -480,24 +513,35 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true}
checkedAt := time.Date(2026, time.August, 29, 11, 12, 0, 0, time.UTC)
updateState := appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", LastCheckedAt: checkedAt, UpdateAvailable: true, AgentAvailable: true, AgentCompatible: true, AgentVersion: "v1.4.0", AgentProtocol: 2, CredentialConfigured: true, CredentialUsername: "registry-user"}
var notification bytes.Buffer
accountData := viewData{Title: "Mein Konto", Page: "account", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
if err := templates["security"].ExecuteTemplate(&notification, "security", accountData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
for _, expected := range []string{"Neue Version verfügbar", "v2.0.0 ist bereit", `data-software-update-banner="v2.0.0"`, `data-software-update-form`, `data-software-update-overlay`} {
if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected)
}
}
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", "Update-Zugang ersetzen"} {
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<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)
}
@@ -507,7 +551,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Nach Update suchen", "Installiert", "Verfügbar", "Zugang fehlt", "Update-Zugang einrichten"} {
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} {
if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected)
}
@@ -515,6 +559,14 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered")
}
var currentPage bytes.Buffer
currentData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.1", LatestVersion: "v1.4.1", LastCheckedAt: checkedAt, AgentAvailable: true, AgentCompatible: true, CredentialConfigured: true}}
if err := templates["system"].ExecuteTemplate(&currentPage, "system", currentData); err != nil {
t.Fatal(err)
}
if !strings.Contains(currentPage.String(), `Status</span><strong>Aktuell</strong>`) || strings.Count(currentPage.String(), `>v1.4.1<`) != 1 {
t.Fatal("an installed current release is still rendered as a second available version")
}
var syncPage bytes.Buffer
syncData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.4.0", LatestVersion: "v2.0.0", UpdateAvailable: true, AgentAvailable: true, ComposeSyncRequired: true, CredentialConfigured: true}}
if err := templates["system"].ExecuteTemplate(&syncPage, "system", syncData); err != nil {
@@ -525,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()
@@ -608,7 +731,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
}
body := rendered.String()
statusAt := strings.Index(body, `A unverändert`)
timeAt := strings.Index(body, `class="target-meta"><time>`)
timeAt := strings.Index(body, `class="target-meta"><time data-local-time=`)
if timeAt < 0 || statusAt < timeAt {
t.Fatalf("target time must be rendered before the status pill: %s", body)
}
+250 -9
View File
@@ -1,4 +1,32 @@
(() => {
const localTimeFormatter = new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
for (const node of document.querySelectorAll('[data-local-time]')) {
const unixTime = Number(node.dataset.localTime || 0);
if (!Number.isFinite(unixTime) || unixTime <= 0) continue;
node.textContent = localTimeFormatter.format(new Date(unixTime * 1000)).replace(',', ' ·');
}
const updateBanner = document.querySelector('[data-software-update-banner]');
if (updateBanner) {
const version = updateBanner.dataset.softwareUpdateBanner || 'unknown';
const storageKey = 'domain-control:update-banner-seen-version';
try {
if (window.localStorage.getItem(storageKey) !== version) {
window.localStorage.setItem(storageKey, version);
window.requestAnimationFrame(() => updateBanner.classList.add('should-animate'));
}
} catch {
// A blocked storage API must not make the banner flicker on every page.
}
}
const toast = document.querySelector('[data-toast]');
if (toast) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -255,22 +283,101 @@
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
if (softwareUpdateForms.length > 0) {
const overlay = document.querySelector('[data-software-update-overlay]');
const title = overlay?.querySelector('[data-software-update-title]');
const message = overlay?.querySelector('[data-software-update-message]');
const progress = overlay?.querySelector('[data-software-update-progress]');
const progressTrack = overlay?.querySelector('[data-software-update-progress-track]');
const progressLabel = overlay?.querySelector('[data-software-update-progress-label]');
const percent = overlay?.querySelector('[data-software-update-percent]');
const close = overlay?.querySelector('[data-software-update-close]');
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
const phaseOrder = ['pulling', 'restarting', 'verifying'];
const phasePresentation = {
queued: {title: 'Update wird vorbereitet', label: 'Vorbereitung', start: 4, end: 9, timeConstant: 10000},
pulling: {title: 'Image wird sicher geladen', label: 'Image-Download', start: 12, end: 42, timeConstant: 22000},
restarting: {title: 'Container wird gewechselt', label: 'Containerwechsel', start: 44, end: 51, timeConstant: 6000},
reconnecting: {title: 'Verbindung wird aufgebaut', label: 'Neustart', start: 52, end: 57, timeConstant: 15000},
verifying: {title: 'Neue Instanz wird geprüft', label: 'Healthcheck', start: 58, end: 98, timeConstant: 35000},
complete: {title: 'Update erfolgreich', label: 'Abgeschlossen', start: 100, end: 100, timeConstant: 1},
failed: {title: 'Update nicht abgeschlossen', label: 'Angehalten'},
};
let polling = false;
let targetVersion = '';
let startedAt = 0;
let lastKnownPhase = 'pulling';
let currentPhase = '';
let phaseStartedAt = 0;
let displayedProgress = 0;
let progressTimer;
const progressTickInterval = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 1000 : 500;
const setUpdatePhase = (phase, text) => {
const formatElapsed = (milliseconds) => {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
};
const stopProgressTimer = () => {
if (progressTimer) window.clearTimeout(progressTimer);
progressTimer = undefined;
};
const renderProgress = (value) => {
const bounded = Math.max(0, Math.min(100, value));
displayedProgress = Math.max(displayedProgress, bounded);
if (progress) progress.style.width = `${displayedProgress.toFixed(2)}%`;
if (percent) percent.textContent = `${Math.floor(displayedProgress)}%`;
progressTrack?.setAttribute('aria-valuenow', String(Math.floor(displayedProgress)));
};
const phaseTime = (raw) => {
const now = Date.now();
const parsed = Date.parse(String(raw || ''));
if (Number.isFinite(parsed) && parsed >= now - (12 * 60 * 1000) && parsed <= now + 60000) {
return Math.min(parsed, now);
}
return now;
};
const tickProgress = () => {
progressTimer = undefined;
const presentation = phasePresentation[currentPhase];
if (!presentation || typeof presentation.end !== 'number' || presentation.end <= presentation.start) return;
const elapsed = Math.max(0, Date.now() - phaseStartedAt);
const distance = presentation.end - presentation.start;
const next = presentation.start + distance * (1 - Math.exp(-elapsed / presentation.timeConstant));
renderProgress(next);
if (progressLabel) {
progressLabel.textContent = currentPhase === 'verifying' || currentPhase === 'reconnecting'
? `${presentation.label} · ${formatElapsed(elapsed)}`
: presentation.label;
}
progressTimer = window.setTimeout(tickProgress, progressTickInterval);
};
const setUpdatePhase = (phase, text, updatedAt = '') => {
if (!overlay) return;
const phaseChanged = currentPhase !== phase;
if (phaseChanged) {
currentPhase = phase;
phaseStartedAt = phaseTime(updatedAt);
stopProgressTimer();
} else if (updatedAt) {
phaseStartedAt = Math.min(phaseStartedAt, phaseTime(updatedAt));
}
overlay.dataset.updateState = phase;
overlay.setAttribute('aria-busy', String(phase !== 'complete' && phase !== 'failed'));
const presentation = phasePresentation[phase] || phasePresentation.queued;
if (title) title.textContent = presentation.title;
if (progressLabel) progressLabel.textContent = presentation.label;
if (typeof presentation.start === 'number') renderProgress(presentation.start);
if (phaseChanged && presentation.end > presentation.start) tickProgress();
if (message && text) message.textContent = text;
const currentIndex = phaseOrder.indexOf(phase);
if (phaseOrder.includes(phase)) lastKnownPhase = phase;
const visualPhase = phase === 'reconnecting' || phase === 'failed' ? lastKnownPhase : phase;
const currentIndex = phaseOrder.indexOf(visualPhase);
for (const node of phaseNodes) {
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
node.classList.toggle('is-active', nodeIndex === currentIndex);
node.classList.toggle('is-active', nodeIndex === currentIndex && phase !== 'complete' && phase !== 'failed');
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
}
};
@@ -292,11 +399,11 @@
queued: 'Update ist eingereiht.',
pulling: 'Das neue Container-Image wird geladen. Der laufende Dienst bleibt dabei online.',
restarting: 'Der Dienst wechselt kontrolliert auf die neue Version.',
verifying: 'Die neue Instanz durchläuft den Healthcheck.',
verifying: 'Die neue Instanz durchläuft den Healthcheck. Dieser Schritt kann etwas dauern.',
complete: `Version ${state.target_version || targetVersion} ist bereit.`,
failed: state.message || 'Update fehlgeschlagen. Die bisherige Version bleibt aktiv.',
};
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.');
setUpdatePhase(phase, messages[phase] || state.message || 'Update läuft.', state.updated_at);
if (phase === 'complete') {
polling = false;
window.setTimeout(() => window.location.assign('/system'), 1400);
@@ -308,7 +415,11 @@
return;
}
} catch {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
const recoveryPhase = currentPhase === 'verifying' ? 'verifying' : 'reconnecting';
const recoveryMessage = recoveryPhase === 'verifying'
? 'Der Healthcheck läuft weiter – die Statusverbindung wird automatisch wiederhergestellt.'
: 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.';
setUpdatePhase(recoveryPhase, recoveryMessage);
}
window.setTimeout(pollSoftwareUpdate, 1500);
};
@@ -318,17 +429,37 @@
event.preventDefault();
if (polling || !overlay) return;
const formData = new FormData(form);
const requestBody = new URLSearchParams();
for (const [name, value] of formData) {
if (typeof value === 'string') requestBody.append(name, value);
}
targetVersion = String(formData.get('version') || '');
stopProgressTimer();
currentPhase = '';
phaseStartedAt = 0;
displayedProgress = 0;
lastKnownPhase = 'pulling';
if (progress) progress.style.width = '0%';
if (percent) percent.textContent = '0%';
progressTrack?.setAttribute('aria-valuenow', '0');
startedAt = Date.now();
overlay.hidden = false;
if (close) close.hidden = true;
document.body.classList.add('software-update-open');
setUpdatePhase('queued', `Update auf ${targetVersion} wird vorbereitet.`);
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.setAttribute('disabled', '');
try {
const response = await fetch(form.action, {method: 'POST', body: formData, headers: {'Accept': 'application/json'}});
const result = await response.json().catch(() => ({}));
const response = await fetch(form.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
const contentType = response.headers.get('content-type') || '';
let result = {};
if (contentType.includes('application/json')) {
result = await response.json().catch(() => ({}));
} else {
const responseText = (await response.text()).trim();
if (responseText && !responseText.startsWith('<')) result.message = responseText.slice(0, 240);
}
if (!response.ok || !result.accepted) throw new Error(result.message || 'Update konnte nicht gestartet werden.');
polling = true;
startedAt = Date.now();
window.setTimeout(pollSoftwareUpdate, 400);
} catch (error) {
setUpdatePhase('failed', error instanceof Error ? error.message : 'Update konnte nicht gestartet werden.');
@@ -342,10 +473,120 @@
overlay.hidden = true;
document.body.classList.remove('software-update-open');
close.hidden = true;
stopProgressTimer();
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
});
}
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]');
+145 -33
View File
@@ -76,7 +76,8 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
.workspace-bar strong { color: var(--text); font-weight: 700; }
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
.software-update-banner { display: grid; width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); grid-template-columns: 38px minmax(0,1fr) auto; gap: 12px; align-items: center; margin: 14px auto -8px; padding: 11px 12px; border: 1px solid #274975; border-radius: 12px; background: radial-gradient(circle at 12% 0, rgba(59,130,246,.24), transparent 38%), linear-gradient(110deg, #122038, #172b48); color: #edf5ff; box-shadow: 0 14px 30px rgba(15,23,42,.12); }
.software-update-banner.should-animate { animation: software-banner-in .45s cubic-bezier(.2,.8,.3,1) both; }
.software-update-banner-icon { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(147,197,253,.25); border-radius: 10px; background: rgba(37,99,235,.18); color: #93c5fd; font-size: 18px; }
.software-update-banner div > span, .software-update-banner div > strong { display: block; }
.software-update-banner div > span { color: #8fa6c2; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
@@ -171,13 +172,32 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.status-pill.status-warn { background: var(--warning-soft); }
.status-pill.status-bad { background: var(--danger-soft); }
.domain-list { display: grid; }
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
.domain-list > a:first-child { border-top: 0; }
.domain-list strong, .domain-list small { display: block; }
.domain-list strong { font-size: 12px; }
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.dashboard-zones-panel { overflow: hidden; background: radial-gradient(circle at 100% 0,rgba(37,99,235,.055),transparent 35%),var(--surface); }
.dashboard-zone-count { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
.dashboard-zone-list { display: grid; gap: 8px; }
.dashboard-zone-item { display: grid; min-width: 0; grid-template-columns: 44px minmax(0,1fr) auto; gap: 11px; align-items: center; padding: 11px; border: 1px solid #e1e7ee; border-radius: 10px; background: linear-gradient(135deg,#fff,#f8faff); transition: border-color .18s, box-shadow .18s, transform .18s; }
.dashboard-zone-item:hover { border-color: #b8cae4; color: inherit; box-shadow: 0 10px 24px rgba(31,64,111,.08); transform: translateY(-1px); }
.dashboard-zone-glyph { position: relative; display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #bfd0ea; border-radius: 12px; background: linear-gradient(145deg,#edf4ff,#dfeaff); color: #315eaa; font-family: var(--mono); }
.dashboard-zone-glyph::before, .dashboard-zone-glyph::after { position: absolute; border: 1px solid rgba(49,94,170,.2); border-radius: 50%; content: ""; }
.dashboard-zone-glyph::before { width: 26px; height: 12px; }
.dashboard-zone-glyph::after { width: 12px; height: 26px; }
.dashboard-zone-glyph b { position: relative; z-index: 1; padding: 2px 3px; border-radius: 3px; background: #e7effd; font-size: 7px; }
.dashboard-zone-glyph i { position: absolute; right: 5px; bottom: 5px; width: 5px; height: 5px; border: 1px solid #fff; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 2px rgba(15,118,110,.12); }
.dashboard-zone-glyph.is-error { border-color: #ecc3ca; background: linear-gradient(145deg,#fff5f6,#ffe9ed); color: var(--danger); }
.dashboard-zone-glyph.is-error i { background: var(--danger); box-shadow: 0 0 0 2px rgba(180,35,60,.11); }
.dashboard-zone-copy, .dashboard-zone-copy > small, .dashboard-zone-copy > strong { display: block; min-width: 0; }
.dashboard-domain-label { margin-bottom: 1px; color: #7a899b; font-family: var(--mono); font-size: 7px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.dashboard-zone-copy > strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-zone-branch { position: relative; display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 5px; align-items: center; margin-top: 6px; padding-top: 5px; border-top: 1px solid #ebeff5; }
.dashboard-zone-branch > i { position: relative; width: 8px; height: 8px; border: 1px solid #8eacd4; border-radius: 3px; background: #edf4ff; }
.dashboard-zone-branch > i::before { position: absolute; bottom: 6px; left: 3px; width: 1px; height: 9px; background: #c8d6e8; content: ""; }
.dashboard-zone-branch span, .dashboard-zone-branch b, .dashboard-zone-branch small { display: block; min-width: 0; }
.dashboard-zone-branch b { color: #536780; font-size: 8px; }
.dashboard-zone-branch small { overflow: hidden; margin-top: 1px; color: #8795a6; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-zone-state { display: grid; gap: 8px; justify-items: end; }
.dashboard-zone-state > i { color: #8799af; font-size: 14px; font-style: normal; transition: color .18s, transform .18s; }
.dashboard-zone-item:hover .dashboard-zone-state > i { color: var(--accent); transform: translateX(3px); }
.empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.audit-panel { margin-top: 12px; }
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
@@ -189,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:hover { border-color: #b6c4d4; }
.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 dl { display: grid; gap: 5px; margin-bottom: 14px; }
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
@@ -492,42 +513,118 @@ 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; }
.update-access-state-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #a9d8ce; border-radius: 12px; background: linear-gradient(145deg,#e9faf6,#d9f3ed); color: var(--success); box-shadow: 0 8px 18px rgba(15,118,110,.08); font-family: var(--mono); font-size: 15px; font-weight: 900; }
.update-access-state .section-label { margin-bottom: 2px; color: #598b82; }
.update-access-state h2 { color: #174f49; }
.update-access-state p { margin: 3px 0 0; color: var(--muted); font-size: 9px; }
.update-access-state p strong { color: #315f5a; font-family: var(--mono); }
.update-access-state .status-pill { gap: 6px; }
.update-access-state .status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #1d9b87; box-shadow: 0 0 0 3px rgba(29,155,135,.11); }
.update-credential-editor { border-top: 1px solid #d8e9e5; background: rgba(255,255,255,.72); }
.update-credential-editor summary { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 18px; color: #315f5a; list-style: none; }
.update-credential-editor summary::-webkit-details-marker { display: none; }
.update-credential-editor summary span, .update-credential-editor summary strong, .update-credential-editor summary small { display: block; }
.update-credential-editor summary strong { font-size: 10px; }
.update-credential-editor summary small { margin-top: 1px; color: var(--muted); font-size: 8px; font-weight: 500; }
.update-credential-editor summary > i { color: #6d918b; font-size: 16px; font-style: normal; transition: transform .18s; }
.update-credential-editor[open] summary > i { transform: rotate(180deg); }
.update-credential-editor[open] summary { border-bottom: 1px solid #e4eeec; }
.update-credential-editor .stacked-form { padding: 18px; }
.update-inline-warning { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; margin-top: 12px; padding: 9px; border: 1px solid #ead18e; border-radius: 8px; background: var(--warning-soft); }
.update-inline-warning > span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: rgba(154,103,0,.1); color: var(--warning); font-weight: 900; }
.update-inline-warning p { margin: 0; }
.update-inline-warning strong, .update-inline-warning small { display: block; }
.update-inline-warning strong { color: #74540b; font-size: 9px; }
.update-inline-warning small { margin-top: 2px; color: #887347; font-size: 8px; overflow-wrap: anywhere; }
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(8,15,28,.72); backdrop-filter: blur(10px); animation: update-overlay-in .25s ease both; }
.software-update-dialog { width: min(520px,100%); padding: 30px; border: 1px solid rgba(255,255,255,.14); border-radius: 18px; background: #111c2e; color: #f8fafc; box-shadow: 0 30px 80px rgba(0,0,0,.32); text-align: center; }
.software-update-dialog .section-label { color: #7f94af; }
.software-update-dialog h2 { margin: 8px 0 9px; color: #fff; font-size: 24px; }
.software-update-dialog p { min-height: 38px; margin: 0 auto 20px; color: #9babc0; font-size: 11px; line-height: 1.55; }
.software-update-orbit { position: relative; display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 20px; border: 1px solid #344862; border-radius: 18px; background: #18283f; color: #b7d1ff; font-family: var(--mono); font-size: 11px; font-weight: 900; }
.software-update-orbit i { position: absolute; inset: -8px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.25); border-radius: 22px; animation: update-orbit 1.15s linear infinite; }
.software-update-progress { height: 5px; overflow: hidden; border-radius: 99px; background: #24344b; }
.software-update-progress i { display: block; width: 8%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#60a5fa,#2dd4bf); box-shadow: 0 0 14px rgba(96,165,250,.45); transition: width .55s cubic-bezier(.2,.8,.3,1), background .2s; }
.software-update-overlay[data-update-state="pulling"] .software-update-progress i { width: 38%; }
.software-update-overlay[data-update-state="restarting"] .software-update-progress i { width: 65%; }
.software-update-overlay[data-update-state="reconnecting"] .software-update-progress i { width: 73%; }
.software-update-overlay[data-update-state="verifying"] .software-update-progress i { width: 88%; }
.software-update-overlay[data-update-state="complete"] .software-update-progress i { width: 100%; }
.software-update-overlay[data-update-state="failed"] .software-update-progress i { width: 100%; background: #e45c70; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit i { border-color: #34d399; animation: none; }
.software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: #fb7185; animation: none; }
.software-update-phases { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 18px 0 0; padding: 0; list-style: none; }
.software-update-phases li { display: grid; gap: 4px; justify-items: center; color: #61738b; font-size: 8px; font-weight: 700; }
.software-update-phases li span { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid #2c3e57; border-radius: 8px; font-family: var(--mono); font-size: 7px; }
.software-update-phases li.is-active { color: #b9d4ff; }
.software-update-phases li.is-active span { border-color: #4b83d5; background: rgba(59,130,246,.16); color: #9ec5ff; }
.software-update-phases li.is-complete { color: #6fc5b6; }
.software-update-phases li.is-complete span { border-color: #327e73; background: rgba(15,118,110,.16); color: #75d4c4; }
.software-update-overlay { position: fixed; z-index: 2200; inset: 0; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at 50% 35%, rgba(37,99,235,.18), transparent 38%), rgba(5,10,20,.82); backdrop-filter: blur(14px) saturate(1.1); animation: update-overlay-in .3s ease both; }
.software-update-dialog { position: relative; isolation: isolate; width: min(560px,100%); overflow: hidden; padding: 28px 32px 30px; border: 1px solid rgba(147,197,253,.2); border-radius: 24px; background: radial-gradient(circle at 50% -18%, rgba(59,130,246,.26), transparent 42%), linear-gradient(155deg,#111d31 0%,#0d1727 100%); color: #f8fafc; box-shadow: 0 38px 100px rgba(0,0,0,.46), 0 0 0 1px rgba(255,255,255,.025) inset; text-align: center; animation: update-dialog-in .46s cubic-bezier(.2,.9,.25,1) both; }
.software-update-dialog::before { position: absolute; z-index: -2; inset: 0; background-image: linear-gradient(rgba(147,197,253,.035) 1px, transparent 1px), linear-gradient(90deg,rgba(147,197,253,.035) 1px,transparent 1px); background-size: 28px 28px; mask-image: linear-gradient(to bottom,rgba(0,0,0,.8),transparent 72%); content: ""; animation: update-grid-drift 10s linear infinite; }
.software-update-dialog::after { position: absolute; z-index: -1; top: -105px; left: 50%; width: 330px; height: 250px; border-radius: 50%; background: rgba(37,99,235,.19); filter: blur(58px); content: ""; transform: translateX(-50%); transition: background .35s; }
.software-update-dialog .section-label { position: relative; color: #7f94af; }
.software-update-dialog h2 { position: relative; margin: 7px 0 8px; color: #fff; font-size: 25px; letter-spacing: -.025em; }
.software-update-dialog p { position: relative; min-height: 36px; margin: 0 auto 17px; color: #a8b8cc; font-size: 11px; line-height: 1.55; }
.software-update-visual { position: relative; display: grid; width: 166px; height: 112px; place-items: center; margin: -2px auto 13px; }
.software-update-visual::before, .software-update-visual::after { position: absolute; border: 1px solid rgba(96,165,250,.18); border-radius: 50%; content: ""; }
.software-update-visual::before { width: 136px; height: 78px; transform: rotate(-9deg); animation: update-ring-left 4.8s linear infinite; }
.software-update-visual::after { width: 104px; height: 104px; border-color: rgba(45,212,191,.13); transform: rotate(21deg); animation: update-ring-right 3.8s linear infinite reverse; }
.software-update-scan { position: absolute; z-index: 0; width: 150px; height: 1px; overflow: visible; background: linear-gradient(90deg,transparent,#60a5fa,transparent); box-shadow: 0 0 16px rgba(96,165,250,.6); opacity: .7; animation: update-scan 2.2s ease-in-out infinite; }
.software-update-orbit { position: relative; z-index: 2; display: grid; width: 68px; height: 68px; place-items: center; border: 1px solid #3c5f8c; border-radius: 21px; background: linear-gradient(145deg,#1c3455,#14243b); color: #c7dcff; box-shadow: 0 16px 34px rgba(0,0,0,.3), 0 0 30px rgba(59,130,246,.15), inset 0 1px rgba(255,255,255,.08); font-family: var(--mono); font-size: 11px; font-weight: 900; animation: update-core-float 2.8s ease-in-out infinite; }
.software-update-orbit::before { position: absolute; top: 7px; right: 7px; width: 5px; height: 5px; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 9px #60a5fa; content: ""; animation: update-status-blink 1.1s ease-in-out infinite; }
.software-update-orbit span::after { content: ""; }
.software-update-orbit i { position: absolute; inset: -10px; border: 1px solid transparent; border-top-color: #60a5fa; border-right-color: rgba(96,165,250,.28); border-radius: 26px; animation: update-orbit 1.25s linear infinite; }
.update-signal { position: absolute; z-index: 1; width: 5px; height: 5px; border-radius: 50%; background: #93c5fd; box-shadow: 0 0 10px rgba(147,197,253,.85); }
.signal-a { animation: update-signal-a 2.4s ease-in-out infinite; }
.signal-b { animation: update-signal-b 3s ease-in-out -.7s infinite; }
.signal-c { width: 4px; height: 4px; background: #5eead4; animation: update-signal-c 2.7s ease-in-out -1.3s infinite; }
.software-update-progress-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; color: #71839a; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.software-update-progress-meta strong { color: #a9c9ff; font-size: 9px; font-variant-numeric: tabular-nums; transition: color .25s; }
.software-update-progress { position: relative; height: 7px; overflow: hidden; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
.software-update-progress i { display: block; width: 4%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); transition: width 1s linear, background .25s; }
.software-update-progress > span { position: absolute; inset: 0; background: linear-gradient(105deg,transparent 34%,rgba(255,255,255,.3) 48%,transparent 62%); transform: translateX(-100%); animation: update-progress-shine 1.65s ease-in-out infinite; }
.software-update-overlay[data-update-state="verifying"] .software-update-progress > span { animation-duration: 3.2s; }
.software-update-overlay[data-update-state="failed"] .software-update-progress i { background: #e45c70; }
.software-update-overlay[data-update-state="complete"] .software-update-dialog { border-color: rgba(52,211,153,.35); }
.software-update-overlay[data-update-state="complete"] .software-update-dialog::after { background: rgba(16,185,129,.2); }
.software-update-overlay[data-update-state="failed"] .software-update-dialog::after { background: rgba(225,29,72,.18); }
.software-update-overlay[data-update-state="complete"] .software-update-orbit, .software-update-overlay[data-update-state="failed"] .software-update-orbit { animation: update-result-pop .5s cubic-bezier(.2,.9,.25,1) both; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit { border-color: #34d399; color: #6ee7b7; box-shadow: 0 0 36px rgba(52,211,153,.22); }
.software-update-overlay[data-update-state="failed"] .software-update-orbit { border-color: #fb7185; color: #fda4af; box-shadow: 0 0 36px rgba(251,113,133,.18); }
.software-update-overlay[data-update-state="complete"] .software-update-orbit i, .software-update-overlay[data-update-state="failed"] .software-update-orbit i { border-color: currentColor; animation: none; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit span, .software-update-overlay[data-update-state="failed"] .software-update-orbit span { font-size: 0; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit span::after { content: "✓"; font-family: inherit; font-size: 22px; }
.software-update-overlay[data-update-state="failed"] .software-update-orbit span::after { content: "!"; font-family: inherit; font-size: 22px; }
.software-update-overlay[data-update-state="complete"] .software-update-orbit::before { background: #34d399; box-shadow: 0 0 9px #34d399; animation: none; }
.software-update-overlay[data-update-state="failed"] .software-update-orbit::before { background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: none; }
.software-update-overlay[data-update-state="complete"] .software-update-progress-meta strong { color: #6ee7b7; }
.software-update-overlay[data-update-state="failed"] .software-update-progress-meta strong { color: #fda4af; }
.software-update-phases { position: relative; display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; margin: 20px 0 0; padding: 0; list-style: none; }
.software-update-phases::before { position: absolute; top: 16px; right: 16%; left: 16%; height: 1px; background: #263a55; content: ""; }
.software-update-phases li { position: relative; z-index: 1; display: grid; gap: 2px; justify-items: center; color: #61738b; }
.software-update-phases li > span { display: grid; width: 32px; height: 32px; place-items: center; margin-bottom: 3px; border: 1px solid #2c3e57; border-radius: 10px; background: #111d2e; font-family: var(--mono); }
.software-update-phases li > span b { font-size: 7px; }
.software-update-phases li > strong { color: inherit; font-size: 8px; font-weight: 750; }
.software-update-phases li > small { color: #4d5e73; font-family: var(--mono); font-size: 7px; text-transform: uppercase; }
.software-update-phases li.is-active { color: #c5dcff; }
.software-update-phases li.is-active > span { border-color: #4b83d5; background: #172a46; color: #a9ccff; box-shadow: 0 0 0 4px rgba(59,130,246,.08), 0 8px 18px rgba(0,0,0,.18); animation: update-phase-pulse 1.4s ease-in-out infinite; }
.software-update-phases li.is-active > small { color: #7094c7; }
.software-update-phases li.is-complete { color: #76cbbb; }
.software-update-phases li.is-complete > span { border-color: #327e73; background: #12302f; color: #75d4c4; }
.software-update-phases li.is-complete > span b { display: none; }
.software-update-phases li.is-complete > span::after { content: "✓"; font-family: var(--mono); font-size: 10px; font-weight: 900; }
.software-update-phases li.is-complete > small { color: #527f78; }
.software-update-dialog [data-software-update-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
body.software-update-open { overflow: hidden; }
@keyframes software-banner-in { from { opacity: 0; transform: translateY(-9px); } to { opacity: 1; transform: translateY(0); } }
@keyframes update-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes update-dialog-in { from { opacity: 0; transform: translateY(16px) scale(.965); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes update-grid-drift { to { background-position: 28px 28px, 28px 28px; } }
@keyframes update-orbit { to { transform: rotate(360deg); } }
@keyframes update-ring-left { to { transform: rotate(351deg); } }
@keyframes update-ring-right { to { transform: rotate(381deg); } }
@keyframes update-core-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes update-status-blink { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
@keyframes update-scan { 0%,100% { opacity: .12; transform: translateY(-34px) scaleX(.55); } 50% { opacity: .75; transform: translateY(34px) scaleX(1); } }
@keyframes update-signal-a { 0%,100% { opacity: 0; transform: translate(-68px,23px) scale(.5); } 45% { opacity: 1; } 70% { opacity: .65; transform: translate(50px,-34px) scale(1); } }
@keyframes update-signal-b { 0%,100% { opacity: 0; transform: translate(60px,28px) scale(.5); } 48% { opacity: .9; } 72% { opacity: .5; transform: translate(-55px,-28px) scale(1); } }
@keyframes update-signal-c { 0%,100% { opacity: 0; transform: translate(-44px,-42px); } 50% { opacity: 1; } 75% { opacity: .4; transform: translate(65px,18px); } }
@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; }
@@ -624,6 +721,11 @@ 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); }
.admin-full-access-card > .status-pill { grid-column: 2; }
.auth-shell { align-items: start; padding: 12px; }
@@ -645,7 +747,11 @@ body.software-update-open { overflow: hidden; }
.software-version-cell { min-width: 0; padding-right: 12px; }
.software-version-cell + .software-version-cell { padding-left: 12px; }
.software-version-cell strong { font-size: 21px; }
.software-update-dialog { padding: 24px 18px; }
.software-update-dialog { padding: 22px 18px 24px; border-radius: 19px; }
.software-update-visual { width: 148px; height: 96px; margin-bottom: 10px; transform: scale(.9); }
.software-update-dialog h2 { font-size: 22px; }
.software-update-phases li > strong { font-size: 7px; }
.software-update-phases li > small { display: none; }
.onboarding-card { padding: 18px; }
.onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 28px; }
@@ -659,6 +765,12 @@ body.software-update-open { overflow: hidden; }
}
@media (max-height: 700px) {
.software-update-dialog { padding-block: 20px 22px; }
.software-update-visual { height: 78px; margin-bottom: 8px; transform: scale(.82); }
.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; }
+13 -6
View File
@@ -32,19 +32,26 @@
{{if .Dashboard.RecentRuns}}
<div class="data-list">
{{range .Dashboard.RecentRuns}}
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small>{{formatTime .StartedAt}}</small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{if .Summary}}{{.Summary}}{{else if eq .Status "running"}}Aktualisierung läuft{{else}}Update-Lauf{{end}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time></small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
{{end}}
</div>
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
</div>
</article>
{{end}}
<article class="panel">
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
<article class="panel dashboard-zones-panel">
<div class="panel-heading"><div><span class="section-label">DNS-Landschaft</span><h2>Domains & Zonen</h2></div><div class="panel-links"><span class="dashboard-zone-count">{{len .Dashboard.Zones}} Zonen</span><a href="/domains">Verwalten</a></div></div>
{{if .Dashboard.Zones}}
<div class="domain-list">
<div class="dashboard-zone-list">
{{range .Dashboard.Zones}}
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
<a class="dashboard-zone-item" href="/domains/{{.ID}}">
<span class="dashboard-zone-glyph {{if .LastError}}is-error{{end}}" aria-hidden="true"><b>DNS</b><i></i></span>
<span class="dashboard-zone-copy">
<small class="dashboard-domain-label">Domain</small><strong>{{.Name}}</strong>
<span class="dashboard-zone-branch"><i aria-hidden="true"></i><span><b>DNS-Zone · {{.Name}}</b><small>{{if .LastError}}Synchronisierung prüfen{{else}}Hetzner · synchronisiert <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time>{{end}}</small></span></span>
</span>
<span class="dashboard-zone-state"><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span><i aria-hidden="true">→</i></span>
</a>
{{end}}
</div>
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
@@ -54,7 +61,7 @@
{{if and .IsAdmin .Dashboard.RecentEvents}}
<section class="panel audit-panel">
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time data-local-time="{{unixSeconds .CreatedAt}}">{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
</section>
{{end}}
{{end}}
+3 -3
View File
@@ -7,7 +7,7 @@
<section class="zone-summary" aria-label="Zonenstatus">
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds .Zone.LastSyncAt}}">{{formatTime .Zone.LastSyncAt}}</time></strong></div>
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
</section>
@@ -27,7 +27,7 @@
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
<article class="target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
<div class="target-meta"><time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -97,7 +97,7 @@
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
<div class="archive-list">
{{range .ArchivedTargets}}
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
{{end}}
</div>
</section>
+1 -1
View File
@@ -4,7 +4,7 @@
<section class="form-panel compact-panel">
<form action="/domains" method="post" class="stacked-form wide-form">
<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>
<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>
+2 -2
View File
@@ -8,9 +8,9 @@
{{if .Zones}}
{{range .Zones}}
<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>
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</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}}
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
</article>
+4 -4
View File
@@ -23,7 +23,7 @@
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
<div class="error-list">
{{range .ErrorTargets}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich <time data-local-time="{{unixSeconds .LastUpdatedAt}}">{{formatTime .LastUpdatedAt}}</time></small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
@@ -34,7 +34,7 @@
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
<div class="error-list">
{{range .ErrorPiHoles}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/pihole#local-dns">Öffnen →</a></article>
{{end}}
</div>
</section>
@@ -45,7 +45,7 @@
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
<div class="error-list">
{{range .ErrorZones}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch <time data-local-time="{{unixSeconds .LastSyncAt}}">{{formatTime .LastSyncAt}}</time></small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
@@ -56,7 +56,7 @@
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
<div class="error-list run-error-list">
{{range .ErrorRuns}}
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small><time data-local-time="{{unixSeconds .StartedAt}}">{{formatTime .StartedAt}}</time> · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
{{end}}
</div>
</section>
+28 -14
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-24">
<script src="/static/app.js?v=features-24" 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>
@@ -44,12 +44,12 @@
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
</header>
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<aside class="software-update-banner" aria-label="Software-Update verfügbar">
<aside class="software-update-banner" data-software-update-banner="{{.SoftwareUpdate.LatestVersion}}" aria-label="Software-Update verfügbar">
<span class="software-update-banner-icon" aria-hidden="true">↗</span>
<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}}
@@ -77,17 +77,31 @@
</div>
{{end}}
{{if and .IsAdmin .SoftwareUpdate.UpdateAvailable (ne .Page "system")}}
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title-global">
<div class="software-update-dialog">
<div class="software-update-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span><h2 id="software-update-title-global">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt"><li data-update-phase="pulling"><span>01</span>Image laden</li><li data-update-phase="restarting"><span>02</span>Dienst wechseln</li><li data-update-phase="verifying"><span>03</span>Healthcheck</li></ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{template "software_update_overlay" .}}
{{end}}
</body>
</html>
{{end}}
{{define "software_update_overlay"}}
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title{{if ne .Page "system"}}-global{{end}}">
<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>
<i class="update-signal signal-a"></i><i class="update-signal signal-b"></i><i class="update-signal signal-c"></i>
</div>
<span class="section-label">Systemupdate</span>
<h2 id="software-update-title{{if ne .Page "system"}}-global{{end}}" data-software-update-title>Update wird vorbereitet</h2>
<p data-software-update-message aria-live="polite">Das neue Container-Image wird sicher vorbereitet.</p>
<div class="software-update-progress-meta"><span data-software-update-progress-label>Vorbereitung</span><strong data-software-update-percent>04%</strong></div>
<div class="software-update-progress" data-software-update-progress-track role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="4"><i data-software-update-progress></i><span></span></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt">
<li data-update-phase="pulling"><span><b>01</b></span><strong>Image laden</strong><small>Registry</small></li>
<li data-update-phase="restarting"><span><b>02</b></span><strong>Dienst wechseln</strong><small>Container</small></li>
<li data-update-phase="verifying"><span><b>03</b></span><strong>Healthcheck</strong><small>Verifizieren</small></li>
</ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
</div>
</div>
{{end}}
+2 -2
View File
@@ -12,7 +12,7 @@
{{$integration := $view.Integration}}
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div>
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
</section>
@@ -57,7 +57,7 @@
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
<div class="archive-list">
{{range $view.ArchivedRecords}}
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt <time data-local-time="{{unixSeconds .ArchivedAt}}">{{formatTime .ArchivedAt}}</time></small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
{{end}}
</div>
</section>
+1 -1
View File
@@ -24,7 +24,7 @@
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
</label>
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
<div class="technical-summary"><span>Letzter Lauf</span><strong><time data-local-time="{{unixSeconds .Settings.LastRunAt}}">{{formatTime .Settings.LastRunAt}}</time></strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
</div>
<div class="form-grid endpoint-grid">
<label>IPv4-Endpunkte
+41 -21
View File
@@ -10,15 +10,15 @@
<div class="software-version-copy">
<div class="software-version-values">
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
<div class="software-version-cell is-latest"><span>Verfügbar</span><strong>{{if .SoftwareUpdate.LatestVersion}}{{.SoftwareUpdate.LatestVersion}}{{else}}–{{end}}</strong></div>
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
</div>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<div class="software-version-meta"><span>Letzte Prüfung <b>{{formatTime .SoftwareUpdate.LastCheckedAt}}</b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</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 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,29 +33,49 @@
</div>
</section>
<section class="panel update-credential-panel" id="update-access">
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>{{if .SoftwareUpdate.CredentialConfigured}}Update-Zugang ersetzen{{else}}Update-Zugang einrichten{{end}}</h2></div>{{if .SoftwareUpdate.CredentialConfigured}}<span class="status-pill status-good">verschlüsselt</span>{{end}}</div>
<form action="/system/update-credential" method="post" class="stacked-form">
<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}}">
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
<div class="form-actions"><button class="button button-primary" type="submit">Zugang sicher speichern</button></div>
<button class="button button-quiet" type="submit">Container neu starten</button>
</form>
</section>
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-title">
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
{{if .SoftwareUpdate.CredentialConfigured}}
<div class="update-access-state">
<span class="update-access-state-icon" aria-hidden="true">✓</span>
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
</div>
<details class="update-credential-editor">
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
{{else}}
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
{{end}}
<form action="/system/update-credential" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Gitea-Benutzername <input name="registry_username" autocomplete="username" maxlength="128" required></label><label>Passwort/Token <input name="registry_token" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></label></div>
<label>Admin-Passwort bestätigen <input name="current_password" type="password" autocomplete="current-password" maxlength="128" required></label>
<div class="form-actions"><button class="button button-primary" type="submit">{{if .SoftwareUpdate.CredentialConfigured}}Zugang aktualisieren{{else}}Zugang sicher speichern{{end}}</button></div>
</form>
{{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-orbit" aria-hidden="true"><span>DC</span><i></i></div>
<span class="section-label">Systemupdate</span>
<h2 id="software-update-title">Update wird vorbereitet</h2>
<p data-software-update-message>Das neue Container-Image wird geladen. DNS-Daten und Secrets bleiben erhalten.</p>
<div class="software-update-progress"><i data-software-update-progress></i></div>
<ol class="software-update-phases" aria-label="Update-Fortschritt">
<li data-update-phase="pulling"><span>01</span>Image laden</li>
<li data-update-phase="restarting"><span>02</span>Dienst wechseln</li>
<li data-update-phase="verifying"><span>03</span>Healthcheck</li>
</ol>
<button class="button button-quiet" type="button" data-software-update-close hidden>Schließen</button>
<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}}
+1 -1
View File
@@ -5,5 +5,5 @@
<article class="access-policy-card is-admin"><span class="access-policy-icon" aria-hidden="true">A</span><div><strong>Administratoren</strong><small>Vollzugriff ohne bearbeitbare Einzelrechte</small></div><span class="status-pill status-good">Vollzugriff</span></article>
<article class="access-policy-card"><span class="access-policy-icon" aria-hidden="true">M</span><div><strong>Mitglieder</strong><small>Nur ausdrücklich zugewiesene Domains und Hosts</small></div><span class="status-pill">Freigaben</span></article>
</section>
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell"><time data-local-time="{{unixSeconds .LastLoginAt}}">{{formatTime .LastLoginAt}}</time></span><span class="row-arrow">→</span></a>{{end}}</div></section>
{{end}}