feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3266 -140

No files matched your search

+2
View File
@@ -43,3 +43,5 @@ jobs:
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.version=${{ gitea.ref_name }}
provenance: false
build-args: |
VERSION=${{ gitea.ref_name }}
+3 -3
View File
@@ -72,7 +72,7 @@ Go-Quelltext formatieren, falls Go-Dateien geändert wurden. Formatiere nur bewu
```bash
docker run --rm \
-v "$PWD":/src \
-w /src golang:1.25.12-alpine \
-w /src golang:1.25.13-alpine \
gofmt -w PFAD/ZUR/GEAENDERTEN_DATEI.go
```
@@ -83,7 +83,7 @@ docker run --rm \
-v "$PWD":/src \
-v /tmp/dyndns-go-mod:/go/pkg/mod \
-v /tmp/dyndns-go-build:/root/.cache/go-build \
-w /src golang:1.25.12-alpine \
-w /src golang:1.25.13-alpine \
sh -c 'apk add --no-cache build-base >/dev/null && go vet ./... && go test -race -count=1 ./...'
```
@@ -94,7 +94,7 @@ docker run --rm \
-v "$PWD":/src \
-v /tmp/dyndns-go-mod:/go/pkg/mod \
-v /tmp/dyndns-go-build:/root/.cache/go-build \
-w /src golang:1.25.12-alpine \
-w /src golang:1.25.13-alpine \
sh -c 'apk add --no-cache build-base >/dev/null && go run golang.org/x/vuln/cmd/govulncheck@latest ./...'
```
+6 -2
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.12-alpine AS build
FROM golang:1.25.13-alpine AS build
ARG VERSION=v1.3.0
RUN apk add --no-cache build-base
WORKDIR /src
@@ -8,7 +10,8 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/dyndns-web ./cmd/dyndns-web
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w -X github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo.Version=${VERSION}" -o /out/dyndns-web ./cmd/dyndns-web && \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/update-agent ./cmd/update-agent
FROM alpine:3.23.2 AS runtime
@@ -18,6 +21,7 @@ RUN apk add --no-cache ca-certificates && \
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
COPY --from=build /out/update-agent /usr/local/bin/update-agent
USER dyndns:dyndns
WORKDIR /data
+44 -2
View File
@@ -23,36 +23,76 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
## Das Compose
```yaml
x-domain-control-image: &domain-control-image
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2
<<: *domain-control-image
restart: unless-stopped
ports:
- "9090:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
- dyndns-update-control:/run/domain-control-update
labels:
de.j-mahler.domain-control.role: app
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
update-agent:
<<: *domain-control-image
restart: unless-stopped
user: "0:10001"
entrypoint: [/usr/local/bin/update-agent]
healthcheck:
disable: true
network_mode: none
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dyndns-update-control:/run/domain-control-update
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32
mem_limit: 64m
volumes:
dyndns-data:
dyndns-secrets:
dyndns-update-control:
```
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden.
## Aktualisieren
In `compose.yml` die Versionsnummer beim Image ändern, anschließend das Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
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. 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.
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.
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur die eine gepinnte Image-Zeile unter `x-domain-control-image` ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
```bash
docker compose pull
docker compose up -d
```
Compose ersetzt geänderte Container und bindet die vorhandenen benannten Volumes wieder ein. `docker compose down -v` darf nicht verwendet werden, weil `-v` die Daten- und Secret-Volumes löschen würde. Der Ein-Klick-Updater verändert weder `compose.yml` noch Volumes; wer später wieder per Compose aktualisiert, setzt dort einfach die gewünschte konkrete Version und führt die beiden Befehle aus.
Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden.
## Funktionen
@@ -63,6 +103,8 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
- lokales Archiv statt Löschen von Hetzner-Records
- frei einstellbares Update-Intervall mit Countdown
- 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
- 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
+71
View File
@@ -0,0 +1,71 @@
# Domain Control aktualisieren
Die aktuelle Compose-Version ist `v1.3.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.
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
Container wiederherzustellen.
Ein nicht erreichbarer Git- oder Registry-Server unterbricht den laufenden
DynDNS-Dienst nicht.
## Mit Docker Compose
In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
```yaml
x-domain-control-image: &domain-control-image
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
```
Danach im Ordner der Compose-Datei ausführen:
```bash
docker compose pull
docker compose up -d
docker compose ps
```
Die Web-App und der Update-Agent verwenden durch den YAML-Anker dieselbe
Version. Für das Update ist kein `docker compose down` nötig. Insbesondere
niemals `docker compose down -v` verwenden, da `-v` die Daten- und
Secret-Volumes löschen würde.
Der Healthcheck lässt sich anschließend direkt prüfen:
```bash
curl --fail http://127.0.0.1:9090/healthz
```
Die erwartete Antwort lautet `ok`.
## Portainer oder Dockge
1. Den bestehenden Stack bearbeiten.
2. Nur den Tag der zentralen Image-Zeile auf `v1.3.0` setzen.
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
Den Stack nicht mitsamt seinen Volumes löschen oder neu anlegen.
## Rollback
Falls ein manuelles Rollback nötig ist, die zentrale Image-Zeile wieder auf die
zuvor eingesetzte konkrete Version setzen und erneut ausführen:
```bash
docker compose pull
docker compose up -d
```
Vor größeren Versionssprüngen sollten die Volumes `dyndns-data` und
`dyndns-secrets` gemeinsam gesichert werden. Nur zusammen enthalten sie sowohl
die Datenbank als auch den zur Entschlüsselung benötigten Master-Key.
+27 -1
View File
@@ -11,6 +11,7 @@ import (
"syscall"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
@@ -53,6 +54,10 @@ func run(logger *slog.Logger) error {
}
provider := hetzner.New(cfg.HCloudBaseURL)
updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger)
softwareUpdates, err := appupdate.New(database, cipher, cfg.UpdateAPIURL, cfg.UpdateAgentSock, cfg.UpdateInterval, logger)
if err != nil {
return err
}
settings, err := database.GetSettings(ctx)
if err != nil {
return err
@@ -65,7 +70,7 @@ func run(logger *slog.Logger) error {
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
}
}
serverUI, err := web.New(cfg, database, cipher, updater, logger)
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger)
if err != nil {
return err
}
@@ -73,6 +78,8 @@ func run(logger *slog.Logger) error {
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
updater.Start(appCtx)
softwareUpdates.Start(appCtx)
go pruneStore(appCtx, database, logger)
httpServer := &http.Server{
Addr: cfg.Address,
Handler: serverUI.Handler(),
@@ -80,6 +87,7 @@ func run(logger *slog.Logger) error {
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
serverErrors := make(chan error, 1)
go func() {
@@ -100,6 +108,24 @@ func run(logger *slog.Logger) error {
}
}
func pruneStore(ctx context.Context, database *store.Store, logger *slog.Logger) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pruneCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
err := database.Prune(pruneCtx)
cancel()
if err != nil && !errors.Is(err, context.Canceled) {
logger.Warn("prune old local data", "error", err)
}
}
}
}
func healthcheck() int {
url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL"))
if url == "" {
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateagent"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := run(logger); err != nil {
logger.Error("update agent stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
socket := env("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock")
dockerSocket := env("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock")
image := env("UPDATE_IMAGE_REPOSITORY", "git.j-mahler.de/admin/domain-control-webinterface-prod")
targetLabel := env("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app")
directory := filepath.Dir(socket)
if err := os.MkdirAll(directory, 0o711); err != nil {
return err
}
if err := os.Chmod(directory, 0o711); err != nil {
return err
}
if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
listener, err := net.Listen("unix", socket)
if err != nil {
return err
}
defer listener.Close()
defer os.Remove(socket)
// The container runs as uid 0 with gid 10001. Group-only access lets the
// unprivileged web process connect without exposing the socket to others.
if err := os.Chmod(socket, 0o660); err != nil {
return err
}
runner := updateagent.NewDockerRunner(dockerSocket, image, targetLabel, logger)
controller := updateagent.NewController(runner, logger)
server := &http.Server{
Handler: controller.Handler(),
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 15 * time.Second,
MaxHeaderBytes: 8 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serverErrors := make(chan error, 1)
go func() {
logger.Info("update agent ready", "socket", socket)
serverErrors <- server.Serve(listener)
}()
select {
case err := <-serverErrors:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
}
}
func env(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
+30 -1
View File
@@ -1,12 +1,18 @@
x-domain-control-image: &domain-control-image
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.3.0
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.2
<<: *domain-control-image
restart: unless-stopped
ports:
- "9090:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
- dyndns-update-control:/run/domain-control-update
labels:
de.j-mahler.domain-control.role: app
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
@@ -15,6 +21,29 @@ services:
cap_drop:
- ALL
update-agent:
<<: *domain-control-image
restart: unless-stopped
user: "0:10001"
entrypoint:
- /usr/local/bin/update-agent
healthcheck:
disable: true
network_mode: none
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dyndns-update-control:/run/domain-control-update
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=4m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32
mem_limit: 64m
volumes:
dyndns-data:
dyndns-secrets:
dyndns-update-control:
+2
View File
@@ -2,6 +2,8 @@ module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface
go 1.25.0
toolchain go1.25.13
require (
github.com/mattn/go-sqlite3 v1.14.47
golang.org/x/crypto v0.54.0
+84
View File
@@ -0,0 +1,84 @@
package appupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type AgentClient struct {
socket string
}
func NewAgentClient(socket string) *AgentClient {
return &AgentClient{socket: socket}
}
func (c *AgentClient) Start(ctx context.Context, request updateprotocol.Request) error {
encoded, err := json.Marshal(request)
if err != nil {
return err
}
response, err := c.do(ctx, http.MethodPost, "/v1/update", bytes.NewReader(encoded))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusAccepted {
return nil
}
if response.StatusCode == http.StatusConflict {
return errors.New("ein Software-Update läuft bereits")
}
return fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
func (c *AgentClient) Status(ctx context.Context) (updateprotocol.Status, error) {
response, err := c.do(ctx, http.MethodGet, "/v1/status", nil)
if err != nil {
return updateprotocol.Status{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return updateprotocol.Status{}, fmt.Errorf("Update-Agent antwortet mit HTTP %d", response.StatusCode)
}
var status updateprotocol.Status
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&status); err != nil {
return updateprotocol.Status{}, fmt.Errorf("Status des Update-Agents lesen: %w", err)
}
return status, nil
}
func (c *AgentClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
if c == nil || c.socket == "" {
return nil, errors.New("Update-Agent ist nicht konfiguriert")
}
transport := &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
dialer := net.Dialer{Timeout: time.Second}
return dialer.DialContext(ctx, "unix", c.socket)
},
}
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
request, err := http.NewRequestWithContext(ctx, method, "http://update-agent"+path, body)
if err != nil {
return nil, err
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.Do(request)
if err != nil {
return nil, errors.New("Update-Agent ist nicht erreichbar")
}
return response, nil
}
+302
View File
@@ -0,0 +1,302 @@
package appupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
type State struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
DesiredVersion string
LastCheckedAt time.Time
LastError string
CredentialConfigured bool
UpdateAvailable bool
AgentAvailable bool
AgentStatus updateprotocol.Status
}
type Manager struct {
store *store.Store
cipher *secure.Cipher
logger *slog.Logger
apiURL string
interval time.Duration
agent *AgentClient
httpClient *http.Client
checkMu sync.Mutex
stateMu sync.RWMutex
state State
checkSignal chan struct{}
}
type releaseResponse struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket string, interval time.Duration, logger *slog.Logger) (*Manager, error) {
settings, err := database.GetSoftwareUpdateSettings(context.Background())
if err != nil {
return nil, err
}
manager := &Manager{
store: database,
cipher: cipher,
logger: logger,
apiURL: apiURL,
interval: interval,
agent: NewAgentClient(agentSocket),
checkSignal: make(chan struct{}, 1),
httpClient: &http.Client{
Timeout: 12 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
manager.setStoredState(settings)
return manager, nil
}
func (m *Manager) Start(ctx context.Context) {
go func() {
m.refreshAgentStatus(ctx)
initial := time.NewTimer(15 * time.Second)
defer initial.Stop()
var ticker *time.Ticker
var tick <-chan time.Time
for {
select {
case <-ctx.Done():
if ticker != nil {
ticker.Stop()
}
return
case <-initial.C:
if ticker == nil {
ticker = time.NewTicker(m.interval)
tick = ticker.C
}
m.backgroundCheck(ctx)
case <-tick:
m.backgroundCheck(ctx)
case <-m.checkSignal:
m.backgroundCheck(ctx)
}
}
}()
}
func (m *Manager) backgroundCheck(ctx context.Context) {
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
m.logger.Warn("software update check failed", "error", err)
}
m.refreshAgentStatus(ctx)
}
func (m *Manager) TriggerCheck() {
select {
case m.checkSignal <- struct{}{}:
default:
}
}
func (m *Manager) State() State {
m.stateMu.RLock()
defer m.stateMu.RUnlock()
return m.state
}
func (m *Manager) SaveCredential(ctx context.Context, username, token string) error {
username, token = strings.TrimSpace(username), strings.TrimSpace(token)
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")
}
ciphertext, nonce, err := m.cipher.Encrypt(token)
if err != nil {
return fmt.Errorf("Update-Zugang verschlüsseln: %w", err)
}
if err := m.store.SaveSoftwareUpdateCredential(ctx, username, ciphertext, nonce); err != nil {
return err
}
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) CheckNow(ctx context.Context) error {
m.checkMu.Lock()
defer m.checkMu.Unlock()
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil {
return err
}
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
m.setStoredState(settings)
return ErrCredentialMissing
}
token, 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)
if err != nil {
return m.recordFailure(ctx, err)
}
checkedAt := time.Now().UTC()
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
return err
}
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
version = strings.TrimSpace(version)
state := m.State()
if !validReleaseVersion(version) || version != state.LatestVersion {
return errors.New("die ausgewählte Version ist nicht das zuletzt geprüfte Release")
}
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")
}
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
if err != nil {
return err
}
token, 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, RegistryUsername: settings.RegistryUsername, RegistryToken: token}
if err := m.agent.Start(ctx, request); err != nil {
return err
}
settings, err = m.store.GetSoftwareUpdateSettings(ctx)
if err == nil {
m.setStoredState(settings)
}
return err
}
func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, error) {
status, err := m.agent.Status(ctx)
m.stateMu.Lock()
m.state.AgentAvailable = err == nil
if err == nil {
m.state.AgentStatus = status
}
m.stateMu.Unlock()
return status, err
}
func (m *Manager) fetchLatest(ctx context.Context, token string) (releaseResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
if err != nil {
return releaseResponse{}, errors.New("Update-Endpunkt ist ungültig")
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "token "+token)
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
response, err := m.httpClient.Do(request)
if err != nil {
return releaseResponse{}, errors.New("Git-Server ist momentan nicht erreichbar")
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
}
if response.StatusCode != http.StatusOK {
return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)
}
var release releaseResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
return releaseResponse{}, errors.New("Release-Antwort des Git-Servers ist ungültig")
}
if release.Draft || release.Prerelease || !validReleaseVersion(release.TagName) {
return releaseResponse{}, errors.New("Git-Server lieferte kein gültiges stabiles Release")
}
release.HTMLURL = m.safeReleaseURL(release.HTMLURL)
return release, nil
}
func (m *Manager) safeReleaseURL(raw string) string {
apiURL, apiErr := url.Parse(m.apiURL)
releaseURL, releaseErr := url.Parse(raw)
if apiErr != nil || releaseErr != nil || releaseURL.Scheme != "https" || !strings.EqualFold(apiURL.Hostname(), releaseURL.Hostname()) {
return ""
}
return releaseURL.String()
}
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
checkedAt := time.Now().UTC()
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
return err
}
if settings, err := m.store.GetSoftwareUpdateSettings(ctx); err == nil {
m.setStoredState(settings)
}
return checkErr
}
func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
state := State{
CurrentVersion: buildinfo.Version,
LatestVersion: settings.LatestVersion,
ReleaseURL: settings.ReleaseURL,
DesiredVersion: settings.DesiredVersion,
LastCheckedAt: settings.LastCheckedAt,
LastError: settings.LastError,
CredentialConfigured: settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 && len(settings.TokenNonce) > 0,
}
if comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion); err == nil {
state.UpdateAvailable = comparison < 0
}
m.stateMu.Lock()
state.AgentAvailable = m.state.AgentAvailable
state.AgentStatus = m.state.AgentStatus
m.state = state
m.stateMu.Unlock()
}
func (m *Manager) refreshAgentStatus(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _ = m.UpdateStatus(statusCtx)
}
+86
View File
@@ -0,0 +1,86 @@
package appupdate
import (
"context"
"encoding/base64"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel()
const token = "test-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 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if unavailable.Load() {
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.3.1","html_url":"https://example.invalid/releases/v1.3.1","draft":false,"prerelease":false}`))
}))
defer api.Close()
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, api.URL, filepath.Join(t.TempDir(), "missing-agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatal(err)
}
if err := manager.SaveCredential(ctx, "registry-user", token); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError != "" {
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")
}
unavailable.Store(true)
if err := manager.CheckNow(ctx); err == nil {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.3.1" || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
+66
View File
@@ -0,0 +1,66 @@
package appupdate
import (
"fmt"
"strconv"
"strings"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type semanticVersion struct {
major int
minor int
patch int
}
func parseVersion(raw string) (semanticVersion, error) {
if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+-") {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
parts := strings.Split(strings.TrimPrefix(raw, "v"), ".")
if len(parts) != 3 {
return semanticVersion{}, fmt.Errorf("version must use vMAJOR.MINOR.PATCH")
}
values := make([]int, 3)
for index, part := range parts {
if part == "" || (len(part) > 1 && part[0] == '0') {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
value, err := strconv.Atoi(part)
if err != nil || value < 0 || value > 1_000_000 {
return semanticVersion{}, fmt.Errorf("version contains an invalid number")
}
values[index] = value
}
return semanticVersion{major: values[0], minor: values[1], patch: values[2]}, nil
}
func compareVersions(left, right string) (int, error) {
a, err := parseVersion(left)
if err != nil {
return 0, err
}
b, err := parseVersion(right)
if err != nil {
return 0, err
}
av, bv := []int{a.major, a.minor, a.patch}, []int{b.major, b.minor, b.patch}
for index := range av {
if av[index] < bv[index] {
return -1, nil
}
if av[index] > bv[index] {
return 1, nil
}
}
return 0, nil
}
func validReleaseVersion(version string) bool {
if !updateprotocol.ValidVersion(version) {
return false
}
_, err := parseVersion(version)
return err == nil
}
+27
View File
@@ -0,0 +1,27 @@
package appupdate
import "testing"
func TestCompareVersionsUsesStrictStableSemanticVersions(t *testing.T) {
t.Parallel()
tests := []struct {
left string
right string
want int
}{
{left: "v1.2.2", right: "v1.2.3", want: -1},
{left: "v2.0.0", right: "v1.99.99", want: 1},
{left: "v1.2.2", right: "v1.2.2", want: 0},
}
for _, test := range tests {
got, err := compareVersions(test.left, test.right)
if err != nil || got != test.want {
t.Errorf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
}
}
for _, invalid := range []string{"1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build"} {
if validReleaseVersion(invalid) {
t.Errorf("invalid release version accepted: %q", invalid)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package buildinfo
// 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.3.0"
+32
View File
@@ -1,8 +1,10 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -27,6 +29,9 @@ type Config struct {
HCloudBaseURL string
Mode string
ShutdownTimeout time.Duration
UpdateAPIURL string
UpdateAgentSock string
UpdateInterval time.Duration
}
func Load() (Config, error) {
@@ -51,6 +56,14 @@ func Load() (Config, error) {
}
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour)
if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour {
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h")
}
updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest")
if err := validateUpdateURL(updateAPIURL); err != nil {
return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err)
}
cookieSecure := mode == "https"
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
@@ -71,9 +84,28 @@ func Load() (Config, error) {
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
Mode: mode,
ShutdownTimeout: shutdownTimeout,
UpdateAPIURL: updateAPIURL,
UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
UpdateInterval: updateInterval,
}, nil
}
func validateUpdateURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("must be an absolute HTTPS URL without embedded credentials")
}
if parsed.Scheme == "https" {
return nil
}
host := parsed.Hostname()
address := net.ParseIP(host)
if host == "localhost" || (address != nil && address.IsLoopback()) {
return nil
}
return errors.New("HTTP is allowed only for loopback test endpoints")
}
func value(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
+15
View File
@@ -90,6 +90,21 @@ type PiHoleCredential struct {
Nonce []byte
}
// SoftwareUpdateSettings contains the locally persisted update state. The
// encrypted registry token is returned only to trusted backend components and
// is never passed to templates.
type SoftwareUpdateSettings struct {
RegistryUsername string
TokenCiphertext []byte
TokenNonce []byte
LatestVersion string
ReleaseURL string
DesiredVersion string
LastCheckedAt time.Time
LastError string
UpdatedAt time.Time
}
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
// merely retained at Pi-hole after being removed from the active WebUI list.
type PiHoleRecordLink struct {
+99 -20
View File
@@ -21,24 +21,42 @@ var (
ErrDisabled = errors.New("the updater is disabled")
)
const defaultRetryInitial = 5 * time.Second
type ipResolver interface {
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
ValidateEndpoint(ctx context.Context, raw string) error
}
type Scheduler struct {
store *store.Store
provider *hetzner.Client
resolver *ipresolve.Resolver
resolver ipResolver
cipher *secure.Cipher
logger *slog.Logger
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
retryMin time.Duration
retryMax time.Duration
}
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
return &Scheduler{
store: database,
provider: provider,
resolver: resolver,
cipher: cipher,
logger: logger,
wake: make(chan struct{}, 1),
retryMin: defaultRetryInitial,
}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer s.nextRun.Store(0)
consecutiveFailures := 0
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
}
retryAfter := time.Duration(0)
if settings.UpdaterEnabled {
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
s.logger.Warn("scheduled update run failed", "error", runErr)
_, runErr := s.Run(ctx)
if runErr == nil {
consecutiveFailures = 0
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
consecutiveFailures++
retryAfter = s.retryDelay(consecutiveFailures, wait)
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
}
} else {
consecutiveFailures = 0
s.nextRun.Store(0)
select {
case <-ctx.Done():
@@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) {
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
if retryAfter > 0 {
wait = retryAfter
}
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
timer := time.NewTimer(wait)
@@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) {
timer.Stop()
return
case <-s.wake:
consecutiveFailures = 0
if !timer.Stop() {
select {
case <-timer.C:
@@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) {
}()
}
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
minimum, maximum := s.retryMin, scheduledWait
if minimum <= 0 {
minimum = defaultRetryInitial
}
if s.retryMax > 0 && s.retryMax < maximum {
maximum = s.retryMax
}
if maximum < minimum {
maximum = minimum
}
wait := minimum
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
if wait > maximum/2 {
return maximum
}
wait *= 2
}
if wait > maximum {
return maximum
}
return wait
}
func (s *Scheduler) Trigger() {
s.nextRun.Store(0)
select {
@@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
message := "Keine öffentliche IP ermittelt"
if ipResult.IPv4Err != nil {
message += ": " + ipResult.IPv4Err.Error()
}
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
}
targets, err := s.store.ListManagedTargets(ctx)
if err != nil {
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
}
needsIPv6 := false
for _, target := range targets {
if target.Target.UpdateIPv6 {
needsIPv6 = true
break
}
}
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
var resolutionErr error
if ipResult.IPv4Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
}
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
}
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
message := "Keine öffentliche IP ermittelt"
if resolutionErr == nil {
resolutionErr = errors.New(message)
} else {
message += ": " + resolutionErr.Error()
}
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
}
if len(targets) == 0 {
summary := "Keine aktiven DynDNS-Ziele"
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
status := "ok"
if resolutionErr != nil {
status = "partial"
summary += "; eine IP-Familie war nicht verfügbar"
}
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
}
grouped := make(map[int64][]store.ManagedTarget)
@@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
status = "partial"
}
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
if resolutionErr != nil {
summary += "; eine IP-Familie war nicht verfügbar"
if status == "ok" {
status = "partial"
}
}
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
var runErr error
if failed > 0 {
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
}
runErr = errors.Join(runErr, resolutionErr)
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
+95
View File
@@ -5,11 +5,13 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -20,6 +22,24 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type retryResolver struct {
calls atomic.Int32
recovered chan struct{}
}
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
call := r.calls.Add(1)
if call == 1 {
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
}
if call == 2 {
close(r.recovered)
}
return ipresolve.Result{IPv4: "198.51.100.42"}
}
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
}
}
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
t.Parallel()
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
resolver := &retryResolver{recovered: make(chan struct{})}
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
schedule.resolver = resolver
schedule.retryMin = 5 * time.Millisecond
schedule.retryMax = 20 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
schedule.Start(ctx)
select {
case <-resolver.recovered:
case <-time.After(2 * time.Second):
t.Fatal("scheduler did not retry the failed startup resolution")
}
deadline := time.Now().Add(time.Second)
for {
settings, err = database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if settings.LastIPv4 == "198.51.100.42" {
break
}
if time.Now().After(deadline) {
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
}
time.Sleep(5 * time.Millisecond)
}
runs, err := database.RecentRuns(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
t.Fatalf("unexpected startup retry history: %#v", runs)
}
}
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
t.Parallel()
schedule := &Scheduler{retryMin: 5 * time.Second}
tests := []struct {
failures int
want time.Duration
}{
{failures: 1, want: 5 * time.Second},
{failures: 2, want: 10 * time.Second},
{failures: 3, want: 20 * time.Second},
{failures: 4, want: 40 * time.Second},
{failures: 5, want: time.Minute},
{failures: 20, want: time.Minute},
}
for _, test := range tests {
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
}
}
}
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
t.Parallel()
schedule := &Scheduler{}
+44 -4
View File
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"unicode/utf8"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
@@ -21,6 +22,14 @@ const (
argonTime = 3
argonThreads = 2
argonKeyLen = 32
PasswordMinLength = 15
PasswordMaxLength = 128
// PasswordTimingHash is a non-secret, precomputed Argon2id hash used only
// to equalize failed-login work when a username does not exist. Keeping it
// static avoids allocating the full Argon2 workspace during every startup.
PasswordTimingHash = "argon2id$v=19$m=65536,t=3,p=2$qrFe78U1tJk+AQzTg0+BHw$n4NjN4mcmJmVrJa7xB3M2AYMDX3PBs+5Y7iYXb/dR5Q"
)
type Cipher struct {
@@ -129,8 +138,30 @@ func HashToken(token string) string {
}
func HashPassword(password string) (string, error) {
if len(password) < 12 {
return "", errors.New("password must contain at least 12 characters")
return hashPassword(password, PasswordMinLength)
}
// HashProvisionedPassword hashes the initial password chosen by an
// administrator when creating another account. Product policy deliberately
// leaves its length to that administrator while still rejecting empty,
// overlong, or invalid UTF-8 input.
func HashProvisionedPassword(password string) (string, error) {
return hashPassword(password, 1)
}
func hashPassword(password string, minimumLength int) (string, error) {
if !utf8.ValidString(password) {
return "", errors.New("Passwort muss gültiger UTF-8-Text sein")
}
length := utf8.RuneCountInString(password)
if length < minimumLength {
if minimumLength == 1 {
return "", errors.New("Passwort darf nicht leer sein")
}
return "", fmt.Errorf("Passwort muss mindestens %d Zeichen enthalten", minimumLength)
}
if length > PasswordMaxLength {
return "", fmt.Errorf("Passwort darf höchstens %d Zeichen enthalten", PasswordMaxLength)
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
@@ -142,6 +173,9 @@ func HashPassword(password string) (string, error) {
}
func VerifyPassword(encoded, password string) bool {
if !utf8.ValidString(password) || utf8.RuneCountInString(password) > PasswordMaxLength {
return false
}
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
@@ -152,12 +186,18 @@ func VerifyPassword(encoded, password string) bool {
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if parts[2] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, threads) || memory != argonMemory || iterations != argonTime || threads != argonThreads {
return false
}
if len(parts[3]) != base64.RawStdEncoding.EncodedLen(16) || len(parts[4]) != base64.RawStdEncoding.EncodedLen(argonKeyLen) {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
if err != nil || len(salt) != 16 {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
if err != nil || len(want) != argonKeyLen {
return false
}
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
+29 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -81,7 +82,34 @@ func TestPasswordHash(t *testing.T) {
if VerifyPassword(hash, "wrong password") {
t.Fatal("wrong password verified")
}
if _, err := HashPassword("short"); err == nil {
if VerifyPassword(strings.Replace(hash, "m=65536", "m=4294967295", 1), "a secure password") {
t.Fatal("password hash with unsafe Argon2 parameters was accepted")
}
if _, err := HashPassword(strings.Repeat("a", PasswordMinLength-1)); err == nil {
t.Fatal("short password was accepted")
}
if _, err := HashPassword(strings.Repeat("a", PasswordMaxLength+1)); err == nil {
t.Fatal("overlong password was accepted")
}
}
func TestPasswordTimingHashMatchesCurrentArgonParameters(t *testing.T) {
t.Parallel()
if !VerifyPassword(PasswordTimingHash, "login timing normalization only") {
t.Fatal("precomputed login timing hash no longer matches the configured Argon2 parameters")
}
}
func TestProvisionedPasswordHasNoMinimumLengthBeyondNonEmpty(t *testing.T) {
t.Parallel()
hash, err := HashProvisionedPassword("x")
if err != nil || !VerifyPassword(hash, "x") {
t.Fatalf("short provisioned password was not hashed correctly: %v", err)
}
if _, err := HashProvisionedPassword(""); err == nil {
t.Fatal("empty provisioned password was accepted")
}
if _, err := HashProvisionedPassword(strings.Repeat("x", PasswordMaxLength+1)); err == nil {
t.Fatal("overlong provisioned password was accepted")
}
}
+179 -7
View File
@@ -21,7 +21,12 @@ type Store struct {
db *sql.DB
}
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
var (
ErrAdminAlreadyConfigured = errors.New("an administrator is already configured")
ErrAdminAccessImmutable = errors.New("administrator access is inherent and cannot be edited")
ErrLastActiveAdmin = errors.New("the last active administrator cannot be disabled or demoted")
ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
)
type ManagedTarget struct {
Target model.Target
@@ -116,6 +121,9 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.applyMigrationV10(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV11(ctx, tx); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
@@ -242,6 +250,23 @@ func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
return nil
}
func (s *Store) applyMigrationV11(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 11`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV11); err != nil {
return fmt.Errorf("apply schema v11: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(11, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
@@ -453,6 +478,22 @@ const schemaV10 = `
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
const schemaV11 = `
CREATE TABLE software_update_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
registry_username TEXT NOT NULL DEFAULT '',
token_ciphertext BLOB NOT NULL DEFAULT X'',
token_nonce BLOB NOT NULL DEFAULT X'',
latest_version TEXT NOT NULL DEFAULT '',
release_url TEXT NOT NULL DEFAULT '',
desired_version TEXT NOT NULL DEFAULT '',
last_checked_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
INSERT INTO software_update_settings(id, updated_at) VALUES(1, strftime('%s','now'));
`
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
@@ -460,9 +501,21 @@ func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
}
func (s *Store) CreateAdmin(ctx context.Context, username, displayName, passwordHash string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at) VALUES(?, ?, ?, 'admin', 1, ?, ?)`, username, displayName, passwordHash, now(), now())
result, err := s.db.ExecContext(ctx, `INSERT INTO users(username, display_name, password_hash, role, active, created_at, updated_at)
SELECT ?, ?, ?, 'admin', 1, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND active = 1)`, username, displayName, passwordHash, now(), now())
if err != nil {
return err
}
created, err := result.RowsAffected()
if err != nil {
return err
}
if created != 1 {
return ErrAdminAlreadyConfigured
}
return nil
}
func (s *Store) PasswordHash(ctx context.Context, userID int64) (string, error) {
var hash string
@@ -481,6 +534,25 @@ func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash s
return nil
}
func (s *Store) UpdatePasswordKeepingSession(ctx context.Context, userID int64, passwordHash string, keepSessionID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, keepSessionID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) GetUser(ctx context.Context, id int64) (model.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, username, display_name, role, active, created_at, updated_at, last_login_at FROM users WHERE id = ?`, id))
}
@@ -531,18 +603,66 @@ func (s *Store) CreateUser(ctx context.Context, username, displayName, passwordH
}
func (s *Store) UpdateUserState(ctx context.Context, userID int64, displayName, role string, active bool) error {
result, err := s.db.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var currentRole string
var currentActive int
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, userID).Scan(&currentRole, &currentActive); err != nil {
return err
}
if currentRole == "admin" && intBool(currentActive) && (role != "admin" || !active) {
var activeAdmins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return ErrLastActiveAdmin
}
}
result, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`, displayName, role, boolInt(active), now(), userID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
// Administrators receive full access from their role. Removing stale member
// grants prevents hidden permissions from resurfacing after a later demotion.
if role == "admin" {
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
}
if currentRole != role || intBool(currentActive) != active {
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) SetUserPassword(ctx context.Context, userID int64, passwordHash string) error {
return s.UpdatePassword(ctx, userID, passwordHash)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, now(), userID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) MarkUserLoggedIn(ctx context.Context, userID int64) error {
@@ -587,6 +707,13 @@ func (s *Store) ReplaceAccessGrants(ctx context.Context, userID int64, selection
return err
}
defer func() { _ = tx.Rollback() }()
var role string
if err := tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil {
return err
}
if role == "admin" {
return ErrAdminAccessImmutable
}
if _, err := tx.ExecContext(ctx, `DELETE FROM access_grants WHERE user_id = ?`, userID); err != nil {
return err
}
@@ -702,8 +829,43 @@ func (s *Store) UpdateSettings(ctx context.Context, interval int, updaterEnabled
return err
}
func (s *Store) GetSoftwareUpdateSettings(ctx context.Context) (model.SoftwareUpdateSettings, error) {
var settings model.SoftwareUpdateSettings
var checkedAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `SELECT registry_username, token_ciphertext, token_nonce, latest_version, release_url, desired_version, last_checked_at, last_error, updated_at FROM software_update_settings WHERE id = 1`).Scan(
&settings.RegistryUsername, &settings.TokenCiphertext, &settings.TokenNonce, &settings.LatestVersion, &settings.ReleaseURL, &settings.DesiredVersion, &checkedAt, &settings.LastError, &updatedAt,
)
settings.LastCheckedAt, settings.UpdatedAt = fromUnix(checkedAt), fromUnix(updatedAt)
return settings, err
}
func (s *Store) SaveSoftwareUpdateCredential(ctx context.Context, username string, ciphertext, nonce []byte) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET registry_username = ?, token_ciphertext = ?, token_nonce = ?, last_error = '', updated_at = ? WHERE id = 1`, username, ciphertext, nonce, now())
return err
}
func (s *Store) RecordSoftwareUpdateSuccess(ctx context.Context, latestVersion, releaseURL string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET latest_version = ?, release_url = ?, last_checked_at = ?, last_error = '', updated_at = ? WHERE id = 1`, latestVersion, releaseURL, unix(checkedAt), now())
return err
}
// RecordSoftwareUpdateFailure intentionally retains the last known release so
// a temporary Gitea outage never removes an already visible update reminder.
func (s *Store) RecordSoftwareUpdateFailure(ctx context.Context, message string, checkedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET last_checked_at = ?, last_error = ?, updated_at = ? WHERE id = 1`, unix(checkedAt), message, now())
return err
}
func (s *Store) SetDesiredSoftwareVersion(ctx context.Context, version string) error {
_, err := s.db.ExecContext(ctx, `UPDATE software_update_settings SET desired_version = ?, updated_at = ? WHERE id = 1`, version, now())
return err
}
func (s *Store) UpdateRunState(ctx context.Context, ipv4, ipv6, status string) error {
_, err := s.db.ExecContext(ctx, `UPDATE settings SET last_ipv4 = ?, last_ipv6 = ?, last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
_, err := s.db.ExecContext(ctx, `UPDATE settings SET
last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4),
last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6),
last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status)
return err
}
@@ -1095,9 +1257,19 @@ func (s *Store) ListTargetsWithIPv6(ctx context.Context) ([]model.Target, error)
}
func (s *Store) DisableIPv6ForTargets(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `UPDATE targets SET update_ipv6 = 0, static_ipv6 = '' WHERE archived_at = 0`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv6 = '' WHERE id = 1`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ArchiveTarget(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `UPDATE targets SET active = 0, archived_at = ?, last_status = 'Getrennt', last_error = '' WHERE id = ? AND archived_at = 0`, now(), id)
+128
View File
@@ -38,6 +38,26 @@ func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
}
}
func TestCreateAdminOnlyAllowsTheAtomicFirstSetup(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.CreateAdmin(ctx, "admin", "Administrator", "password-hash"); err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "second", "Second admin", "other-password-hash"); !errors.Is(err, ErrAdminAlreadyConfigured) {
t.Fatalf("second first-run administrator was not rejected: %v", err)
}
if _, _, err := database.GetUserByUsername(ctx, "second"); err == nil {
t.Fatal("rejected second administrator was persisted")
}
}
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -168,6 +188,100 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
}
}
func TestAdministratorAccessIsRoleBasedAndImmutable(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hash, err := secure.HashPassword("a secure administrator password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, admin.ID, admin.DisplayName, "member", false); !errors.Is(err, ErrLastActiveAdmin) {
t.Fatalf("last active administrator could be removed: %v", err)
}
if err := database.ReplaceAccessGrants(ctx, admin.ID, AccessSelection{}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("administrator accepted editable individual grants: %v", err)
}
member, err := database.CreateUser(ctx, "alice", "Alice", hash, "member")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "admin-access-test")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "role-test", "role.example")
if err != nil {
t.Fatal(err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); err != nil {
t.Fatal(err)
}
memberSession, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: "member-role-session", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateUserState(ctx, member.ID, member.DisplayName, "admin", true); err != nil {
t.Fatal(err)
}
if session, err := database.GetSession(ctx, memberSession.TokenHash); err != nil || session != nil {
t.Fatalf("role change did not invalidate existing sessions: %#v, %v", session, err)
}
grants, err := database.ListAccessGrants(ctx, member.ID)
if err != nil {
t.Fatal(err)
}
if len(grants) != 0 {
t.Fatalf("promoted administrator retained hidden member grants: %#v", grants)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}}); !errors.Is(err, ErrAdminAccessImmutable) {
t.Fatalf("promoted administrator accepted individual grants: %v", err)
}
}
func TestSoftwareUpdateStateRetainsKnownReleaseOnCheckFailure(t *testing.T) {
t.Parallel()
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("encrypted-token"), []byte("nonce")); err != nil {
t.Fatal(err)
}
checkedAt := time.Now().UTC().Truncate(time.Second)
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.2.3", "https://git.example/releases/v1.2.3", checkedAt); err != nil {
t.Fatal(err)
}
if err := database.SetDesiredSoftwareVersion(ctx, "v1.2.3"); err != nil {
t.Fatal(err)
}
if err := database.RecordSoftwareUpdateFailure(ctx, "temporarily unavailable", checkedAt.Add(time.Minute)); err != nil {
t.Fatal(err)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
if err != nil {
t.Fatal(err)
}
if settings.RegistryUsername != "registry-user" || string(settings.TokenCiphertext) != "encrypted-token" || settings.LatestVersion != "v1.2.3" || settings.DesiredVersion != "v1.2.3" || settings.LastError != "temporarily unavailable" {
t.Fatalf("software update state was not persisted safely: %#v", settings)
}
}
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -268,6 +382,9 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
if err != nil || len(targets) != 1 || targets[0].ID != target.ID {
t.Fatalf("IPv6 target was not listed: %#v err=%v", targets, err)
}
if err := database.UpdateRunState(ctx, "198.51.100.10", "2001:db8::20", "ok"); err != nil {
t.Fatal(err)
}
if err := database.DisableIPv6ForTargets(ctx); err != nil {
t.Fatal(err)
}
@@ -275,6 +392,10 @@ func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
}
settings, err := database.GetSettings(ctx)
if err != nil || settings.LastIPv6 != "" {
t.Fatalf("disabled IPv6 kept a stale detected address: %#v err=%v", settings, err)
}
if err := database.DeleteTarget(ctx, target.ID); err != nil {
t.Fatal(err)
}
@@ -335,6 +456,13 @@ func TestUpdaterSettingsAndErrorsCanBeReset(t *testing.T) {
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
t.Fatal(err)
}
if err := database.UpdateRunState(ctx, "", "", "error"); err != nil {
t.Fatal(err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.LastIPv4 != "198.51.100.1" {
t.Fatalf("transient resolver failure discarded the last known IPv4: %#v err=%v", settings, err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
}
+133
View File
@@ -0,0 +1,133 @@
package updateagent
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type Runner interface {
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
}
type Controller struct {
runner Runner
logger *slog.Logger
mu sync.RWMutex
status updateprotocol.Status
}
func NewController(runner Runner, logger *slog.Logger) *Controller {
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
}
func (c *Controller) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/status", c.statusHandler)
mux.HandleFunc("POST /v1/update", c.updateHandler)
return mux
}
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(c.Status())
}
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
defer r.Body.Close()
var request updateprotocol.Request
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
request.Version = strings.TrimSpace(request.Version)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if !c.begin(request.Version) {
http.Error(w, "update already running", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
go c.run(request)
}
func (c *Controller) begin(version string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.status.Busy() {
return false
}
now := time.Now().UTC()
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
return true
}
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
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))
return
}
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
c.logger.Info("software update completed", "version", request.Version)
}
func redactSecret(err error, secret string) error {
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
}
func (c *Controller) setPhase(phase, message string) {
c.mu.Lock()
c.status.Phase = phase
c.status.Message = message
c.status.UpdatedAt = time.Now().UTC()
c.mu.Unlock()
}
func (c *Controller) Status() updateprotocol.Status {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func publicAgentError(err error) string {
if err == nil {
return "Update fehlgeschlagen"
}
var public interface{ PublicMessage() string }
if errors.As(err, &public) {
return public.PublicMessage()
}
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
}
+95
View File
@@ -0,0 +1,95 @@
package updateagent
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
t.Parallel()
const secret = "sensitive-registry-token"
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
t.Fatalf("credential was not redacted: %q", redacted)
}
}
type blockingRunner struct {
started chan updateprotocol.Request
release chan struct{}
}
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
progress(updateprotocol.PhasePulling, "image is being pulled")
r.started <- request
select {
case <-ctx.Done():
return ctx.Err()
case <-r.release:
return nil
}
}
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
t.Parallel()
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
handler := controller.Handler()
invalid := httptest.NewRecorder()
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
if invalid.Code != http.StatusBadRequest {
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
}
trailing := httptest.NewRecorder()
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
if trailing.Code != http.StatusBadRequest {
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
}
const secret = "registry-token-must-not-leak"
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if accepted.Code != http.StatusAccepted {
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
}
select {
case request := <-runner.started:
if request.Version != "v1.2.3" || request.RegistryToken != secret {
t.Fatalf("runner received an altered request: %#v", request)
}
case <-time.After(time.Second):
t.Fatal("update runner did not start")
}
busy := httptest.NewRecorder()
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if busy.Code != http.StatusConflict {
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
}
status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
}
close(runner.release)
deadline := time.Now().Add(time.Second)
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if controller.Status().Phase != updateprotocol.PhaseComplete {
t.Fatalf("update did not complete: %#v", controller.Status())
}
}
+452
View File
@@ -0,0 +1,452 @@
package updateagent
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
type DockerRunner struct {
docker *dockerClient
imageRepository string
targetLabel string
logger *slog.Logger
}
type updateError struct {
message string
err error
}
func (e *updateError) Error() string { return e.err.Error() }
func (e *updateError) Unwrap() error { return e.err }
func (e *updateError) PublicMessage() string { return e.message }
func NewDockerRunner(socket, imageRepository, targetLabel string, logger *slog.Logger) *DockerRunner {
if imageRepository == "" {
imageRepository = defaultImageRepository
}
return &DockerRunner{docker: newDockerClient(socket), imageRepository: imageRepository, targetLabel: targetLabel, logger: logger}
}
func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) (resultErr error) {
if !updateprotocol.ValidVersion(request.Version) {
return errors.New("invalid target version")
}
if err := r.docker.negotiate(ctx); err != nil {
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
}
progress(updateprotocol.PhasePulling, "Container-Image wird geladen")
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
}
target, err := r.docker.findTarget(ctx, r.targetLabel)
if err != nil {
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
}
inspected, err := r.docker.inspectContainer(ctx, target.ID)
if err != nil {
return err
}
originalName := strings.TrimPrefix(inspected.Name, "/")
if originalName == "" {
return errors.New("target container has no name")
}
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix())
var oldStopped, oldRenamed bool
var newID string
defer func() {
if resultErr == nil || (!oldStopped && !oldRenamed && newID == "") {
return
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil {
r.logger.Error("software update rollback failed", "error", rollbackErr)
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
}
}()
progress(updateprotocol.PhaseRestarting, "Anwendung wird kontrolliert neu gestartet")
if err := r.docker.stopContainer(ctx, target.ID, 20); err != nil {
return err
}
oldStopped = true
if err := r.docker.renameContainer(ctx, target.ID, backupName); err != nil {
return err
}
oldRenamed = true
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version)
if err != nil {
return err
}
if err := r.docker.startContainer(ctx, newID); err != nil {
return err
}
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil {
return err
}
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
r.logger.Warn("old update rollback container could not be removed", "container", target.ID, "error", err)
}
oldStopped, oldRenamed, newID = false, false, ""
return nil
}
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error {
var failures []string
if newID != "" {
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
failures = append(failures, "stop replacement: "+err.Error())
}
if err := r.docker.removeContainer(ctx, newID, true); err != nil {
failures = append(failures, "remove replacement: "+err.Error())
}
}
if oldRenamed {
if err := r.docker.renameContainer(ctx, oldID, originalName); err != nil {
failures = append(failures, "restore name: "+err.Error())
}
}
if oldStopped {
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
failures = append(failures, "restart previous container: "+err.Error())
}
}
if len(failures) > 0 {
return errors.New(strings.Join(failures, "; "))
}
return nil
}
type dockerClient struct {
socket string
client *http.Client
apiPrefix string
}
type containerSummary struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
}
type containerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
Config json.RawMessage `json:"Config"`
HostConfig json.RawMessage `json:"HostConfig"`
NetworkSettings struct {
Networks map[string]endpointSettings `json:"Networks"`
} `json:"NetworkSettings"`
State containerState `json:"State"`
}
type endpointSettings struct {
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
Links []string `json:"Links,omitempty"`
Aliases []string `json:"Aliases,omitempty"`
MacAddress string `json:"MacAddress,omitempty"`
DriverOpts map[string]string `json:"DriverOpts,omitempty"`
GwPriority int `json:"GwPriority,omitempty"`
}
type containerState struct {
Running bool `json:"Running"`
ExitCode int `json:"ExitCode"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
}
func newDockerClient(socket string) *dockerClient {
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", socket)
},
MaxIdleConns: 2,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 30 * time.Second,
}
return &dockerClient{socket: socket, client: &http.Client{Transport: transport}}
}
func (c *dockerClient) negotiate(ctx context.Context) error {
response, err := c.request(ctx, http.MethodGet, "/version", nil, nil, false)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return dockerStatusError(response)
}
var version struct {
APIVersion string `json:"ApiVersion"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&version); err != nil {
return fmt.Errorf("decode Docker API version: %w", err)
}
for _, character := range version.APIVersion {
if (character < '0' || character > '9') && character != '.' {
return errors.New("Docker returned an invalid API version")
}
}
if version.APIVersion == "" {
return errors.New("Docker returned an empty API version")
}
c.apiPrefix = "/v" + version.APIVersion
return nil
}
func (c *dockerClient) findTarget(ctx context.Context, label string) (containerSummary, error) {
filters, _ := json.Marshal(map[string][]string{"label": {label}})
query := url.Values{"all": {"1"}, "filters": {string(filters)}}
response, err := c.request(ctx, http.MethodGet, "/containers/json?"+query.Encode(), nil, nil, true)
if err != nil {
return containerSummary{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return containerSummary{}, dockerStatusError(response)
}
var containers []containerSummary
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&containers); err != nil {
return containerSummary{}, err
}
var running []containerSummary
for _, container := range containers {
if container.State == "running" {
running = append(running, container)
}
}
if len(running) != 1 {
return containerSummary{}, fmt.Errorf("expected exactly one running target container, found %d", len(running))
}
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]})
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)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return dockerStatusError(response)
}
scanner := bufio.NewScanner(response.Body)
scanner.Buffer(make([]byte, 64<<10), 1<<20)
for scanner.Scan() {
var event struct {
Error string `json:"error"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err == nil && event.Error != "" {
return errors.New(event.Error)
}
}
return scanner.Err()
}
func (c *dockerClient) inspectContainer(ctx context.Context, id string) (containerInspect, error) {
response, err := c.request(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, nil, true)
if err != nil {
return containerInspect{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return containerInspect{}, dockerStatusError(response)
}
var inspected containerInspect
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
return containerInspect{}, err
}
return inspected, nil
}
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
query := url.Values{"t": {fmt.Sprint(seconds)}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) renameContainer(ctx context.Context, id, name string) error {
query := url.Values{"name": {name}}
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/rename?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) {
var body map[string]json.RawMessage
if err := json.Unmarshal(inspected.Config, &body); err != nil {
return "", fmt.Errorf("decode container config: %w", err)
}
imageJSON, _ := json.Marshal(image)
body["Image"] = imageJSON
body["HostConfig"] = inspected.HostConfig
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
oldIDPrefix := inspected.ID
if len(oldIDPrefix) > 12 {
oldIDPrefix = oldIDPrefix[:12]
}
for network, endpoint := range inspected.NetworkSettings.Networks {
aliases := endpoint.Aliases[:0]
for _, alias := range endpoint.Aliases {
if alias != inspected.ID && alias != oldIDPrefix {
aliases = append(aliases, alias)
}
}
endpoint.Aliases = aliases
endpoints[network] = endpoint
}
networkingJSON, _ := json.Marshal(map[string]any{"EndpointsConfig": endpoints})
body["NetworkingConfig"] = networkingJSON
encoded, err := json.Marshal(body)
if err != nil {
return "", err
}
query := url.Values{"name": {name}}
response, err := c.request(ctx, http.MethodPost, "/containers/create?"+query.Encode(), bytes.NewReader(encoded), http.Header{"Content-Type": {"application/json"}}, true)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return "", dockerStatusError(response)
}
var created struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&created); err != nil {
return "", err
}
if created.ID == "" {
return "", errors.New("Docker created a replacement without an ID")
}
return created.ID, nil
}
func (c *dockerClient) startContainer(ctx context.Context, id string) error {
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotModified {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) removeContainer(ctx context.Context, id string, force bool) error {
query := url.Values{"force": {fmt.Sprint(force)}, "v": {"0"}}
response, err := c.request(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?"+query.Encode(), nil, nil, true)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound {
return nil
}
return dockerStatusError(response)
}
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error {
deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second)
defer deadline.Stop()
defer ticker.Stop()
consecutiveHealthy := 0
startedAt := time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return errors.New("replacement container did not become healthy in time")
case <-ticker.C:
inspected, err := c.inspectContainer(ctx, id)
if err != nil {
return err
}
if !inspected.State.Running {
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
}
if inspected.State.Health == nil {
if time.Since(startedAt) >= 8*time.Second {
return nil
}
continue
}
if inspected.State.Health.Status == "healthy" {
consecutiveHealthy++
if consecutiveHealthy >= 3 {
return nil
}
} else {
consecutiveHealthy = 0
}
}
}
}
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
}
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
if err != nil {
return nil, err
}
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
return c.client.Do(request)
}
func dockerStatusError(response *http.Response) error {
message, _ := io.ReadAll(io.LimitReader(response.Body, 8<<10))
trimmed := strings.TrimSpace(string(message))
if trimmed == "" {
trimmed = http.StatusText(response.StatusCode)
}
return fmt.Errorf("Docker API HTTP %d: %s", response.StatusCode, trimmed)
}
func isDockerNotModified(err error) bool {
return err != nil && strings.Contains(err.Error(), "HTTP 304")
}
+40
View File
@@ -0,0 +1,40 @@
package updateprotocol
import (
"regexp"
"time"
)
var releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
const (
PhaseIdle = "idle"
PhaseQueued = "queued"
PhasePulling = "pulling"
PhaseRestarting = "restarting"
PhaseVerifying = "verifying"
PhaseComplete = "complete"
PhaseFailed = "failed"
)
type Request struct {
Version string `json:"version"`
RegistryUsername string `json:"registry_username"`
RegistryToken string `json:"registry_token"`
}
type Status struct {
Phase string `json:"phase"`
TargetVersion string `json:"target_version,omitempty"`
Message string `json:"message,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func (s Status) Busy() bool {
return s.Phase == PhaseQueued || s.Phase == PhasePulling || s.Phase == PhaseRestarting || s.Phase == PhaseVerifying
}
func ValidVersion(version string) bool {
return len(version) <= 64 && releaseVersionPattern.MatchString(version)
}
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
@@ -213,7 +212,7 @@ func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
return
}
+240 -38
View File
@@ -21,6 +21,7 @@ import (
"golang.org/x/net/idna"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
@@ -36,9 +37,12 @@ type Server struct {
store *store.Store
cipher *secure.Cipher
scheduler *scheduler.Scheduler
updates *appupdate.Manager
logger *slog.Logger
templates map[string]*template.Template
limiter *loginLimiter
dummyHash string
authSlots chan struct{}
}
type viewData struct {
@@ -74,6 +78,7 @@ type viewData struct {
ErrorPiHoles []model.PiHoleIntegration
ErrorCount int
PiHoleView *piHoleView
SoftwareUpdate appupdate.State
}
type flashMessage struct {
@@ -91,12 +96,12 @@ type accessZone struct {
ManagedTargetIDs map[int64]bool
}
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, 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) (*Server, error) {
templates, err := loadTemplates()
if err != nil {
return nil, err
}
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
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
}
func (s *Server) Handler() http.Handler {
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
mux.Handle("POST /run", s.adminOnly(s.runNow))
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
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("GET /errors", s.adminOnly(s.errorsPage))
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
mux.Handle("GET /security", s.authenticated(s.securityPage))
mux.Handle("GET /account", s.authenticated(s.accountPage))
mux.Handle("POST /account/password", s.authenticated(s.passwordChange))
mux.Handle("GET /security", s.authenticated(s.securityRedirect))
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
mux.Handle("GET /users", s.adminOnly(s.usersPage))
mux.Handle("GET /access", s.adminOnly(s.usersPage))
mux.Handle("GET /users", s.adminOnly(s.usersRedirect))
mux.Handle("GET /users/new", s.adminOnly(s.userNewPage))
mux.Handle("POST /users", s.adminOnly(s.userCreate))
mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail))
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset))
mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI))
mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI))
mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI))
return s.securityHeaders(mux)
}
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil {
if errors.Is(err, store.ErrAdminAlreadyConfigured) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
}
username, usernameErr := normalizeUsername(r.FormValue("username"))
user, hash, err := s.store.GetUserByUsername(r.Context(), username)
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
lookupOK := usernameErr == nil && err == nil
if !lookupOK {
hash = s.dummyHash
}
passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password"))
if !lookupOK || !user.Active || !passwordOK {
s.limiter.Fail(clientIP)
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
s.renderError(w, "login", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
return
}
s.limiter.Reset(clientIP)
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
s.redirectNotice(w, r, "/", "Angemeldet.")
}
func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool {
if s.authSlots == nil {
return secure.VerifyPassword(hash, password)
}
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case s.authSlots <- struct{}{}:
defer func() { <-s.authSlots }()
return secure.VerifyPassword(hash, password)
case <-ctx.Done():
return false
case <-timer.C:
return false
}
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) {
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("password")) {
s.redirectNotice(w, r, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error")
return
}
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
s.redirectNotice(w, r, "/", "Aktualisierung gestartet.")
}
func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
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()})
}
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error")
return
}
if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil {
s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username)
s.updates.TriggerCheck()
s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.")
}
func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
err := s.updates.CheckNow(checkCtx)
cancel()
if err != nil {
if errors.Is(err, appupdate.ErrCredentialMissing) {
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
return
}
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
return
}
s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.")
}
func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
version := strings.TrimSpace(r.FormValue("version"))
startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
err := s.updates.RequestUpdate(startCtx, version)
cancel()
if err != nil {
if strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)})
return
}
s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username)
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, "version": version})
return
}
s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info")
}
func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
if s.updates == nil {
http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
status, statusErr := s.updates.UpdateStatus(statusCtx)
cancel()
state := s.updates.State()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"agent_available": statusErr == nil,
"phase": status.Phase,
"message": status.Message,
"target_version": status.TargetVersion,
"current_version": state.CurrentVersion,
"latest_version": state.LatestVersion,
})
}
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
targets, err := s.store.ListTargetsWithErrors(r.Context())
if err != nil {
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.")
}
func (s *Server) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
}
func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
http.Redirect(w, r, "/account", http.StatusPermanentRedirect)
}
func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) {
s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error")
return
}
if r.FormValue("new_password") != r.FormValue("confirm_password") {
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error")
return
}
newHash, err := secure.HashPassword(r.FormValue("new_password"))
if err != nil {
s.redirectNotice(w, r, "/security", err.Error(), "error")
s.redirectNotice(w, r, "/account", err.Error(), "error")
return
}
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil {
s.serverError(w, err)
return
}
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
s.redirectNotice(w, r, "/security", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.")
}
func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) {
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
"running": s.scheduler.IsRunning(),
"enabled": settings.UpdaterEnabled,
"last_ipv4": settings.LastIPv4,
})
}
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
s.serverError(w, err)
return
}
s.render(w, "users", viewData{Title: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
}
func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) {
http.Redirect(w, r, "/access", http.StatusPermanentRedirect)
}
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
}
func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
return
}
hash, err := secure.HashPassword(r.FormValue("password"))
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
if err != nil {
s.renderUserNewError(w, session, err.Error(), form)
return
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
}
accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants})
}
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()})
}
func (s *Server) userAccessSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error")
return
}
if managedUser.IsAdmin() && (role != "admin" || !active) {
count, countErr := s.store.CountActiveAdmins(r.Context())
if countErr != nil {
s.serverError(w, countErr)
return
}
if count <= 1 {
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
if errors.Is(err, store.ErrLastActiveAdmin) {
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
return
}
}
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
s.serverError(w, err)
return
}
if role != managedUser.Role || active != managedUser.Active {
_ = s.store.DeleteUserSessions(r.Context(), userID)
}
_ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username)
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.")
}
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
s.serverError(w, err)
return
}
_ = s.store.DeleteUserSessions(r.Context(), userID)
_ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username)
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
}
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
if settings, err := s.store.GetSettings(context.Background()); err == nil {
data.UpdaterEnabled = settings.UpdaterEnabled
}
if data.IsAdmin && s.updates != nil {
data.SoftwareUpdate = s.updates.State()
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
}
func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) {
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
}
func (s *Server) serverError(w http.ResponseWriter, err error) {
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
}
func loadTemplates() (map[string]*template.Template, error) {
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"}
functions := template.FuncMap{
"formatTime": func(value time.Time) string {
if value.IsZero() {
@@ -2045,13 +2204,28 @@ func remoteIP(r *http.Request) string {
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
lastCleanup time.Time
}
const (
loginAttemptLimit = 5
loginAttemptWindow = 15 * time.Minute
loginLimiterCleanupEvery = time.Minute
loginLimiterMaxTrackedIPs = 4096
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()}
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
func (l *loginLimiter) Allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-15 * time.Minute)
now := time.Now()
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery {
l.cleanupLocked(now)
}
cutoff := now.Add(-loginAttemptWindow)
values := l.attempts[ip]
kept := values[:0]
for _, value := range values {
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
kept = append(kept, value)
}
}
if len(kept) == 0 {
delete(l.attempts, ip)
} else {
l.attempts[ip] = kept
return len(kept) < 5
}
if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs {
return false
}
return len(kept) < loginAttemptLimit
}
func (l *loginLimiter) Fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs {
return
}
l.attempts[ip] = append(l.attempts[ip], time.Now())
}
func (l *loginLimiter) Reset(ip string) {
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
defer l.mu.Unlock()
delete(l.attempts, ip)
}
func (l *loginLimiter) cleanupLocked(now time.Time) {
cutoff := now.Add(-loginAttemptWindow)
for ip, values := range l.attempts {
kept := values[:0]
for _, value := range values {
if value.After(cutoff) {
kept = append(kept, value)
}
}
if len(kept) == 0 {
delete(l.attempts, ip)
} else {
l.attempts[ip] = kept
}
}
l.lastCleanup = now
}
+265 -2
View File
@@ -9,10 +9,12 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
@@ -223,6 +225,267 @@ func TestLoginErrorRendersAsToast(t *testing.T) {
if strings.Contains(body, `class="flash"`) {
t.Error("legacy in-flow flash message is still rendered")
}
for _, removed := range []string{"Geschützter Bereich", "Geschützte Anmeldung", "Rate-Limit · CSRF-Schutz", "Sitzungen laufen automatisch ab"} {
if strings.Contains(body, removed) {
t.Errorf("login still renders removed security marketing copy %q", removed)
}
}
}
func TestLoginLimiterPrunesExpiredClientsAndBoundsMemory(t *testing.T) {
t.Parallel()
limiter := newLoginLimiter()
limiter.attempts["expired"] = []time.Time{time.Now().Add(-loginAttemptWindow - time.Minute)}
limiter.lastCleanup = time.Now().Add(-loginLimiterCleanupEvery - time.Second)
if !limiter.Allow("fresh") {
t.Fatal("fresh client was rejected after expired entries should have been pruned")
}
if _, exists := limiter.attempts["expired"]; exists {
t.Fatal("expired login limiter entry was not pruned")
}
now := time.Now()
for index := 0; index < loginLimiterMaxTrackedIPs; index++ {
limiter.attempts["client-"+strconv.Itoa(index)] = []time.Time{now}
}
limiter.lastCleanup = now
if limiter.Allow("overflow") {
t.Fatal("untracked client was accepted after the bounded limiter reached capacity")
}
}
func TestMembersCannotSeeOrUseAccessAdministration(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() })
hash, err := secure.HashPassword("a sufficiently secure test password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(err)
}
admin, _, err := database.GetUserByUsername(ctx, "admin")
if err != nil {
t.Fatal(err)
}
member, err := database.CreateUser(ctx, "member", "Mitglied", hash, "member")
if err != nil {
t.Fatal(err)
}
const rawToken = "member-session-token"
if _, err := database.CreateSession(ctx, model.Session{UserID: member.ID, TokenHash: secure.HashToken(rawToken), CSRFToken: "member-csrf", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
server := &Server{config: config.Config{}, store: database}
handler := server.Handler()
requests := []struct {
method string
path string
body string
}{
{method: http.MethodGet, path: "/access"},
{method: http.MethodGet, path: "/users"},
{method: http.MethodGet, path: "/users/new"},
{method: http.MethodGet, path: "/system"},
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
}
for _, test := range requests {
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
request.AddCookie(&http.Cookie{Name: "dyndns_session", Value: rawToken})
if test.body != "" {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Errorf("member request %s %s returned %d, want 403", test.method, test.path, response.Code)
}
}
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
var rendered bytes.Buffer
data := viewData{Title: "Mein Konto", Page: "account", CSRF: "member-csrf", User: member, IsAdmin: false}
if err := templates["security"].ExecuteTemplate(&rendered, "security", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
if !strings.Contains(body, `href="/account"`) {
t.Error("member navigation does not expose the personal account")
}
for _, forbidden := range []string{`href="/access"`, `href="/users`, `href="/system"`, `Zugriff verwalten`} {
if strings.Contains(body, forbidden) {
t.Errorf("member UI exposes administrator control %q", forbidden)
}
}
}
func TestAdministratorRightsRenderAsImmutableFullAccess(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 7, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: admin.DisplayName, Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, ManagedUser: admin, AccessZones: []accessZone{{Zone: model.Zone{ID: 9, Name: "example.de"}}}}
if err := templates["user_detail"].ExecuteTemplate(&rendered, "user_detail", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
for _, expected := range []string{"Vollzugriff ist fest mit der Rolle verbunden", "keine bearbeitbaren Domain- oder Host-Einzelrechte", "Alle Rechte"} {
if !strings.Contains(body, expected) {
t.Errorf("immutable administrator access UI is missing %q", expected)
}
}
for _, forbidden := range []string{`name="zone_`, `name="target_`, `name="manage_zone_`, `name="manage_target_`, `name="create_zone_`} {
if strings.Contains(body, forbidden) {
t.Errorf("administrator detail renders editable permission input %q", forbidden)
}
}
}
func TestAccessNavigationCombinesUsersAndSecurity(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Users: []model.User{admin}}
if err := templates["users"].ExecuteTemplate(&rendered, "users", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
navStart, navEnd := strings.Index(body, "<nav "), strings.Index(body, "</nav>")
if navStart < 0 || navEnd <= navStart {
t.Fatal("administrator navigation was not rendered")
}
navigation := body[navStart:navEnd]
if !strings.Contains(navigation, `href="/access"`) || !strings.Contains(body, "Zugriff &amp; Sicherheit") {
t.Fatal("combined access administration entry is missing")
}
if strings.Contains(body, "Zugangsdaten") || strings.Contains(body, "API-Secrets bleiben verschlüsselt gespeichert") {
t.Fatal("removed credential policy card is still rendered")
}
for _, legacy := range []string{`href="/security"`, `href="/users"`} {
if strings.Contains(navigation, legacy) {
t.Errorf("legacy separate navigation entry remains: %q", legacy)
}
}
}
func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
updateState := appupdate.State{CurrentVersion: "v1.2.2", LatestVersion: "v1.2.3", UpdateAvailable: true, AgentAvailable: true, CredentialConfigured: true}
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", "v1.2.3 ist bereit", `data-software-update-form`, `data-software-update-overlay`} {
if !strings.Contains(notification.String(), expected) {
t.Errorf("global update notification is missing %q", expected)
}
}
var page bytes.Buffer
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
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"} {
if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", expected)
}
}
var freshPage bytes.Buffer
freshData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: appupdate.State{CurrentVersion: "v1.2.2"}}
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"} {
if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected)
}
}
if strings.Contains(freshPage.String(), "Update-Kanal") {
t.Error("confusing legacy update channel box is still rendered")
}
}
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
admin := model.User{ID: 1, Username: "admin", DisplayName: "Administrator", Role: "admin", Active: true}
var rendered bytes.Buffer
data := viewData{Title: "Benutzer anlegen", Page: "access", CSRF: "csrf", User: admin, IsAdmin: true, Form: map[string]string{"role": "member"}}
if err := templates["user_new"].ExecuteTemplate(&rendered, "user_new", data); err != nil {
t.Fatal(err)
}
body := rendered.String()
for _, expected := range []string{"user-create-panel", "user-create-identity-grid", "field-spacer", `name="password"`, `name="confirm_password"`} {
if !strings.Contains(body, expected) {
t.Errorf("user creation form is missing %q", expected)
}
}
for _, field := range []string{`name="password"`, `name="confirm_password"`} {
start := strings.Index(body, field)
if start < 0 {
continue
}
end := strings.Index(body[start:], ">")
if end < 0 {
t.Fatalf("password input %q has no closing bracket", field)
}
if strings.Contains(body[start:start+end], "minlength=") {
t.Errorf("password input %q still has a minimum length", field)
}
}
}
func TestSetupRendersFirstRunOnboardingAndPasswordGuidance(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "Ersteinrichtung",
Page: "setup",
Mode: "lan",
CSRF: "csrf",
Form: map[string]string{"username": "admin", "display_name": "Administrator"},
}
if err := templates["setup"].ExecuteTemplate(&rendered, "setup", data); err != nil {
t.Fatalf("render setup: %v", err)
}
body := rendered.String()
for _, expected := range []string{`class="onboarding-shell"`, `class="onboarding-form-wrap"`, `class="onboarding-progress"`, `aria-current="step"`, `Erster Start`, `Administrator anlegen`, `Aktuell`, `Schritt 1 von 3`, `Instanz bereit`, `data-setup-form`, `data-setup-password`, `minlength="15"`, `maxlength="128"`, `LAN-Modus aktiv`} {
if !strings.Contains(body, expected) {
t.Errorf("first-run onboarding is missing %q", expected)
}
}
if strings.Contains(body, "Kein E-Mail-Reset") {
t.Error("first-run onboarding still renders the removed password reset footnote")
}
}
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
@@ -238,7 +501,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
CSRF: "csrf",
IsAdmin: true,
Zone: model.Zone{ID: 1, Name: "example.de"},
Settings: model.Settings{IPv6Enabled: true},
Settings: model.Settings{IPv6Enabled: true, LastIPv4: "198.51.100.42"},
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
}
@@ -251,7 +514,7 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
if timeAt < 0 || statusAt < timeAt {
t.Fatalf("target time must be rendered before the status pill: %s", body)
}
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-current-ipv4-placeholder`, `placeholder="198.51.100.42"`, `data-target-requirement`, `data-ipv6-enabled="true"`, `data-scroll-page-end`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
if !strings.Contains(body, expected) {
t.Errorf("domain detail is missing %q", expected)
}
+143 -8
View File
@@ -91,6 +91,32 @@
syncIPv6Settings();
}
const setupForm = document.querySelector('[data-setup-form]');
if (setupForm) {
const password = setupForm.querySelector('[data-setup-password]');
const confirmation = setupForm.querySelector('[data-setup-confirm]');
const lengthHint = setupForm.querySelector('[data-password-length]');
const matchHint = setupForm.querySelector('[data-password-match]');
const syncPasswordGuidance = () => {
const length = password ? [...password.value].length : 0;
const longEnough = length >= 15 && length <= 128;
const matches = Boolean(confirmation?.value) && confirmation.value === password?.value;
lengthHint?.classList.toggle('is-valid', longEnough);
matchHint?.classList.toggle('is-valid', matches);
lengthHint?.setAttribute('data-state', longEnough ? 'valid' : 'pending');
matchHint?.setAttribute('data-state', matches ? 'valid' : 'pending');
password?.classList.toggle('is-valid', longEnough);
confirmation?.classList.toggle('is-valid', matches);
setupForm.classList.toggle('is-password-ready', longEnough && matches);
confirmation?.setCustomValidity(confirmation.value && !matches ? 'Die Passphrasen stimmen nicht überein.' : '');
};
password?.addEventListener('input', syncPasswordGuidance);
confirmation?.addEventListener('input', syncPasswordGuidance);
syncPasswordGuidance();
}
for (const form of document.querySelectorAll('[data-target-form]')) {
const active = form.querySelector('[data-target-active]');
const staticFields = form.querySelector('[data-static-ip-fields]');
@@ -226,12 +252,107 @@
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
if (countdowns.length === 0) return;
const softwareUpdateForms = [...document.querySelectorAll('[data-software-update-form]')];
if (softwareUpdateForms.length > 0) {
const overlay = document.querySelector('[data-software-update-overlay]');
const message = overlay?.querySelector('[data-software-update-message]');
const close = overlay?.querySelector('[data-software-update-close]');
const phaseNodes = [...(overlay?.querySelectorAll('[data-update-phase]') || [])];
const phaseOrder = ['pulling', 'restarting', 'verifying'];
let polling = false;
let targetVersion = '';
let startedAt = 0;
let nextRun = Number(countdowns[0].dataset.countdown || 0);
let running = countdowns[0].dataset.running === 'true';
let enabled = countdowns[0].dataset.enabled !== 'false';
const setUpdatePhase = (phase, text) => {
if (!overlay) return;
overlay.dataset.updateState = phase;
if (message && text) message.textContent = text;
const currentIndex = phaseOrder.indexOf(phase);
for (const node of phaseNodes) {
const nodeIndex = phaseOrder.indexOf(node.dataset.updatePhase);
node.classList.toggle('is-active', nodeIndex === currentIndex);
node.classList.toggle('is-complete', currentIndex > nodeIndex || phase === 'complete');
}
};
const pollSoftwareUpdate = async () => {
if (!polling) return;
if (Date.now() - startedAt > 12 * 60 * 1000) {
polling = false;
setUpdatePhase('failed', 'Das Update dauert ungewöhnlich lange. Der DNS-Dienst läuft unabhängig davon weiter; prüfe den Status erneut.');
if (close) close.hidden = false;
return;
}
try {
const response = await fetch('/api/v1/software-update/status', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
if (!response.ok) throw new Error('status unavailable');
const state = await response.json();
const phase = state.phase || 'queued';
const messages = {
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.',
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.');
if (phase === 'complete') {
polling = false;
window.setTimeout(() => window.location.assign('/system'), 1400);
return;
}
if (phase === 'failed') {
polling = false;
if (close) close.hidden = false;
return;
}
} catch {
setUpdatePhase('reconnecting', 'Dienst startet neu – Verbindung wird automatisch wiederhergestellt.');
}
window.setTimeout(pollSoftwareUpdate, 1500);
};
for (const form of softwareUpdateForms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (polling || !overlay) return;
const formData = new FormData(form);
targetVersion = String(formData.get('version') || '');
overlay.hidden = false;
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(() => ({}));
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.');
if (close) close.hidden = false;
for (const candidate of softwareUpdateForms) candidate.querySelector('button')?.removeAttribute('disabled');
}
});
}
close?.addEventListener('click', () => {
overlay.hidden = true;
document.body.classList.remove('software-update-open');
close.hidden = true;
for (const form of softwareUpdateForms) form.querySelector('button')?.removeAttribute('disabled');
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
if (countdowns.length === 0 && currentIPv4Fields.length === 0) return;
let nextRun = Number(countdowns[0]?.dataset.countdown || 0);
let running = countdowns[0]?.dataset.running === 'true';
let enabled = countdowns[0]?.dataset.enabled !== 'false';
let lastRefresh = 0;
const formatRemaining = (seconds) => {
@@ -243,6 +364,7 @@
};
const refreshSchedule = async () => {
if (document.hidden) return;
const now = Date.now();
if (now - lastRefresh < 2500) return;
lastRefresh = now;
@@ -253,12 +375,17 @@
nextRun = Number(state.next_run_unix || 0);
running = Boolean(state.running);
enabled = state.enabled !== false;
if (typeof state.last_ipv4 === 'string' && state.last_ipv4.trim()) {
for (const field of currentIPv4Fields) field.placeholder = state.last_ipv4.trim();
}
} catch {
// The visible countdown keeps its last known state when the status request fails.
// Visible scheduler data keeps its last known state when the request fails.
}
};
const tick = () => {
if (document.hidden) return;
if (countdowns.length === 0) return;
if (!enabled) {
for (const node of countdowns) {
node.textContent = 'Inaktiv';
@@ -276,8 +403,16 @@
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
};
if (countdowns.length > 0) {
tick();
window.setInterval(tick, 1000);
}
refreshSchedule();
window.setInterval(refreshSchedule, 30000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
lastRefresh = 0;
tick();
refreshSchedule();
window.setInterval(tick, 1000);
window.setInterval(refreshSchedule, 30000);
});
})();
+270 -4
View File
@@ -27,6 +27,7 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { min-width: 320px; min-height: 100%; background: var(--bg); }
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
button, input, textarea, select { font: inherit; }
@@ -59,12 +60,15 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
.nav-update-dot { width: 7px; height: 7px; margin-left: auto; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.14), 0 0 12px rgba(96,165,250,.42); }
.sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
.sidebar-account-link { display: grid; min-width: 0; grid-template-columns: 32px minmax(0,1fr); gap: 9px; align-items: center; padding: 3px; border-radius: 7px; }
.sidebar-account-link:hover { background: #1b2739; color: #fff; }
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
.sidebar-account small { color: #748298; font-size: 9px; }
.sidebar-account form { display: contents; }
.sidebar-account form { display: block; }
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
.sidebar-account button:hover { background: #26344a; color: #fff; }
@@ -72,10 +76,21 @@ 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-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; }
.software-update-banner div > strong { margin-top: 2px; font-size: 12px; }
.software-update-banner-actions { display: flex; gap: 7px; align-items: center; }
.software-update-banner-actions form { display: contents; }
.software-update-banner .button-quiet { border-color: rgba(191,219,254,.2); background: rgba(255,255,255,.055); color: #dbeafe; }
.software-update-banner .button-quiet:hover { background: rgba(255,255,255,.1); color: #fff; }
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
.auth-shell { display: grid; min-height: 100vh; min-height: 100svh; place-items: center; padding: clamp(18px, 4vw, 48px); background: radial-gradient(circle at 12% 18%, rgba(37,99,235,.1), transparent 28%), linear-gradient(145deg, #edf2f7 0%, #f8fafc 55%, #eef2f6 100%); }
.page-setup { overflow: hidden; }
.page-setup .auth-shell { width: 100%; height: 100vh; height: 100svh; min-height: 0; overflow: hidden; padding: 0; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
@@ -315,6 +330,91 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
.security-strip span { color: var(--success); }
.login-meta { text-align: center; }
.login-card { position: relative; overflow: hidden; width: min(460px, 100%); padding: 34px; border-radius: 18px; }
.login-card::before { position: absolute; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, var(--accent), #2dd4bf); content: ""; }
.login-card-top { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.login-mode { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 99px; background: var(--warning-soft); color: var(--warning); font-family: var(--mono); font-size: 8px; font-weight: 800; }
.login-mode.is-secure { border-color: #b4ddd4; background: #effaf7; color: var(--success); }
.login-card h1 { margin-top: 36px; }
.login-card .lede { margin-bottom: 24px; }
.onboarding-shell { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: minmax(400px, .9fr) minmax(560px, 1.1fr); overflow: hidden; background: #f8fafc; }
.onboarding-intro { position: relative; display: flex; min-width: 0; overflow: hidden; flex-direction: column; padding: clamp(34px, 4vw, 64px); background: radial-gradient(circle at 12% -5%, rgba(59,130,246,.27), transparent 34%), radial-gradient(circle at 105% 88%, rgba(45,212,191,.12), transparent 36%), linear-gradient(150deg, #0e1726 0%, #152238 100%); color: #fff; }
.onboarding-intro::before { position: absolute; inset: 0; background-image: linear-gradient(rgba(148,163,184,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.035) 1px, transparent 1px); background-size: 38px 38px; content: ""; pointer-events: none; }
.onboarding-intro::after { position: absolute; top: 0; right: 0; width: 1px; height: 100%; background: rgba(255,255,255,.12); content: ""; pointer-events: none; }
.onboarding-brand-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.onboarding-edition { color: #718096; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.auth-brand-inverse { color: #f8fafc; }
.auth-brand-inverse span { border: 1px solid #3a4b65; background: rgba(28,41,64,.88); color: #b9d0ff; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
.onboarding-copy { position: relative; z-index: 1; margin-top: clamp(38px, 6vh, 68px); }
.onboarding-eyebrow { display: inline-flex; margin-bottom: 15px; padding: 5px 8px; border: 1px solid rgba(147,164,186,.2); border-radius: 99px; background: rgba(15,23,42,.24); color: #9fb0c7; font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .13em; text-transform: uppercase; }
.onboarding-copy h1 { max-width: 620px; margin-bottom: 16px; color: #f8fafc; font-size: clamp(40px, 4.3vw, 64px); line-height: 1; letter-spacing: -.06em; }
.onboarding-copy p { max-width: 500px; margin: 0; color: #9aa8ba; font-size: clamp(11px, 1vw, 14px); line-height: 1.6; }
.onboarding-progress { position: relative; z-index: 1; max-width: 520px; margin-top: clamp(28px, 4vh, 42px); }
.onboarding-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #77869a; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.onboarding-progress strong { color: #aec8f6; font-size: 8px; }
.onboarding-progress-track { display: block; height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: rgba(148,163,184,.14); }
.onboarding-progress-track i { display: block; width: 33.333%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #3b82f6, #75a7ff); box-shadow: 0 0 16px rgba(59,130,246,.55); }
.onboarding-steps { position: relative; z-index: 1; display: grid; max-width: 520px; gap: 7px; margin: 14px 0 0; padding: 0; list-style: none; }
.onboarding-steps::before { position: absolute; top: 35px; bottom: 35px; left: 29px; z-index: -1; width: 1px; background: rgba(148,163,184,.14); content: ""; }
.onboarding-steps li { position: relative; display: grid; min-height: 62px; grid-template-columns: 36px minmax(0,1fr); gap: 13px; align-items: center; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; color: #718096; }
.onboarding-steps li > span { position: relative; z-index: 1; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(148,163,184,.16); border-radius: 10px; background: #152238; color: #718096; font-family: var(--mono); font-size: 9px; font-weight: 750; }
.onboarding-steps strong, .onboarding-steps small { display: block; }
.onboarding-steps strong { color: #bac5d4; font-size: 12px; }
.onboarding-steps small { margin-top: 2px; color: #718096; font-size: 9px; line-height: 1.45; }
.onboarding-step-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.onboarding-step-title em { padding: 3px 6px; border-radius: 99px; background: rgba(59,130,246,.18); color: #a8c7ff; font-family: var(--mono); font-size: 7px; font-style: normal; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.onboarding-steps .is-current { border-color: rgba(96,165,250,.26); background: linear-gradient(90deg, rgba(37,99,235,.18), rgba(37,99,235,.055)); box-shadow: inset 3px 0 #4f8ff7, 0 10px 28px rgba(2,8,23,.12); }
.onboarding-steps .is-current > span { border-color: #4f8ff7; background: #2563eb; color: #fff; box-shadow: 0 6px 18px rgba(37,99,235,.28); }
.onboarding-steps .is-current strong { color: #fff; }
.onboarding-card { display: grid; min-width: 0; place-items: center; padding: clamp(32px, 4vw, 64px) clamp(36px, 6vw, 96px) clamp(48px, 7vw, 96px); background: #f8fafc; }
.onboarding-form-wrap { width: min(660px, 100%); }
.onboarding-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: clamp(20px, 3vh, 28px); color: #7b8797; font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.setup-ready { display: inline-flex; align-items: center; gap: 8px; color: #4f6074; }
.setup-ready i { width: 7px; height: 7px; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 3px rgba(34,160,107,.1); }
.onboarding-form-heading h2 { margin: 0 0 10px; font-size: clamp(30px, 3.2vw, 44px); line-height: 1.08; letter-spacing: -.05em; }
.onboarding-lede { max-width: 590px; margin-bottom: clamp(18px, 2.6vh, 24px); color: var(--muted); font-size: 12px; line-height: 1.6; }
.setup-form { gap: 14px; }
.setup-field-row { gap: 16px; }
.setup-form label { gap: 6px; color: #334155; font-size: 10px; }
.setup-form input { min-height: 46px; border-radius: 8px; background: #fff; }
.setup-form input.is-valid:not(:focus) { border-color: #91cfc3; box-shadow: 0 0 0 3px rgba(15,118,110,.055); }
.password-guidance { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
.password-guidance span { display: flex; min-width: 0; min-height: 38px; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid #dbe2e9; border-radius: 9px; background: #fff; color: #738093; font-size: 9px; font-weight: 700; line-height: 1.25; transition: border-color .24s ease, background .24s ease, color .24s ease, box-shadow .24s ease, transform .24s ease; }
.password-guidance i { display: grid; width: 20px; height: 20px; flex: none; place-items: center; border-radius: 7px; background: #edf1f5; color: transparent; font-family: var(--mono); font-size: 10px; font-style: normal; font-weight: 900; transform: scale(.82); transition: background .24s ease, color .18s ease, transform .3s cubic-bezier(.2,1.5,.4,1); }
.password-guidance span.is-valid { border-color: #a8dacf; background: linear-gradient(120deg, #f4fbf9, #edf9f6); color: #0d6f67; box-shadow: 0 5px 16px rgba(15,118,110,.08); transform: translateY(-1px); animation: guidance-pop .38s cubic-bezier(.2,.9,.3,1.35) both; }
.password-guidance span.is-valid i { background: #0f8a7f; color: #fff; box-shadow: 0 4px 10px rgba(15,118,110,.2); transform: scale(1); animation: guidance-check .42s cubic-bezier(.2,1.55,.4,1) both; }
.setup-submit { position: relative; width: 100%; min-height: 48px; overflow: hidden; margin-top: 2px; border-radius: 8px; transition: border-color .2s, background .2s, box-shadow .24s, transform .2s; }
.setup-submit > span { position: relative; z-index: 1; transition: transform .22s ease; }
.setup-submit:hover > span { transform: translateX(3px); }
.setup-form.is-password-ready .setup-submit { box-shadow: 0 10px 24px rgba(37,99,235,.18); }
.setup-form.is-password-ready .setup-submit::after { position: absolute; top: -80%; bottom: -80%; left: -35%; width: 24%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.48), transparent); content: ""; pointer-events: none; transform: rotate(16deg); animation: setup-button-sheen .75s ease-out both; }
.onboarding-footer { margin-top: clamp(16px, 2.5vh, 22px); padding-top: 16px; border-top: 1px solid var(--line); }
.onboarding-mode { display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 8px; align-items: center; color: #7a5a16; }
.onboarding-mode > span { display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid #d7bd80; border-radius: 50%; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.onboarding-mode p { margin: 0; }
.onboarding-mode strong, .onboarding-mode small { display: block; }
.onboarding-mode strong { font-size: 9px; }
.onboarding-mode small { margin-top: 1px; color: #887347; font-size: 8px; font-weight: 500; line-height: 1.4; }
.onboarding-mode.is-secure { color: var(--success); }
.onboarding-mode.is-secure > span { border-color: #8bc5ba; }
.onboarding-mode.is-secure small { color: #59877f; }
@keyframes setup-reveal { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes setup-panel-reveal { from { opacity: 0; transform: translateY(18px) scale(.988); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes setup-progress-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes setup-ready-pulse { 0% { box-shadow: 0 0 0 0 rgba(34,160,107,.32); } 65% { box-shadow: 0 0 0 7px rgba(34,160,107,0); } 100% { box-shadow: 0 0 0 3px rgba(34,160,107,.1); } }
@keyframes guidance-pop { 0% { transform: translateY(0) scale(.97); } 70% { transform: translateY(-2px) scale(1.015); } 100% { transform: translateY(-1px) scale(1); } }
@keyframes guidance-check { 0% { opacity: 0; transform: scale(.35) rotate(-18deg); } 70% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { opacity: 1; transform: scale(1) rotate(0); } }
@keyframes setup-button-sheen { from { left: -35%; } to { left: 118%; } }
.onboarding-brand-row { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-copy { animation: setup-reveal .55s .06s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-progress { animation: setup-reveal .55s .12s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-progress-track i { transform-origin: left; animation: setup-progress-fill .75s .38s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-steps li { animation: setup-reveal .5s cubic-bezier(.2,.8,.3,1) both; }
.onboarding-steps li:nth-child(1) { animation-delay: .18s; }
.onboarding-steps li:nth-child(2) { animation-delay: .24s; }
.onboarding-steps li:nth-child(3) { animation-delay: .3s; }
.onboarding-form-wrap { animation: setup-panel-reveal .58s .08s cubic-bezier(.2,.8,.3,1) both; }
.setup-ready i { animation: setup-ready-pulse .8s .58s ease-out both; }
.token-card { width: min(680px, 100%); }
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
@@ -336,6 +436,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
.row-arrow { color: var(--accent); }
.access-panel { margin-top: 12px; }
.access-policy-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
.access-policy-card { display: grid; min-height: 76px; grid-template-columns: 36px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.access-policy-card.is-admin { border-color: #b9cdef; background: linear-gradient(120deg,#fff 62%,#f0f5ff); }
.access-policy-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 9px; background: #edf1f5; color: #526174; font-family: var(--mono); font-size: 10px; font-weight: 900; }
.access-policy-card.is-admin .access-policy-icon { background: var(--accent-soft); color: #315eaa; }
.access-policy-card strong, .access-policy-card small { display: block; }
.access-policy-card strong { font-size: 11px; }
.access-policy-card small { margin-top: 2px; color: var(--muted); font-size: 8px; line-height: 1.4; }
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
.access-list { display: grid; gap: 9px; }
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
@@ -349,6 +457,77 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.access-target strong, .access-target small { display: block; }
.access-target small { color: var(--muted); font-size: 8px; }
.access-manage-switch { white-space: nowrap; }
.admin-full-access-card { display: grid; grid-template-columns: 46px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 17px; border: 1px solid #acd8ce; border-radius: var(--radius); background: linear-gradient(110deg,#fff 65%,#eefaf7); box-shadow: var(--shadow); }
.admin-full-access-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; background: #dff5ef; color: var(--success); font-family: var(--mono); font-size: 13px; font-weight: 900; }
.admin-full-access-card h2 { margin-bottom: 3px; }
.admin-full-access-card p { margin: 0; color: var(--muted); font-size: 10px; }
.account-password-panel { width: min(760px,100%); }
.user-create-panel { width: min(930px,100%); }
.user-create-panel .wide-form { max-width: none; }
.user-create-grid { gap: 14px; align-items: stretch; }
.user-create-grid > label { min-width: 0; align-content: start; }
.user-create-grid input { height: 42px; }
.user-create-identity-grid > label small { min-height: 27px; }
.field-spacer { visibility: hidden; }
.software-version-card { position: relative; display: grid; min-height: 164px; overflow: hidden; grid-template-columns: 62px minmax(0,1fr) auto; gap: 20px; align-items: center; margin-bottom: 12px; padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: #fff; box-shadow: var(--shadow); }
.software-version-card::after { position: absolute; top: -90px; right: -70px; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle,rgba(37,99,235,.08),transparent 68%); content: ""; pointer-events: none; }
.software-version-card.has-update { border-color: #b4c9ec; background: linear-gradient(120deg,#fff 55%,#f1f6ff); box-shadow: 0 16px 38px rgba(37,99,235,.09); }
.software-version-visual { position: relative; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid #cbd8e8; border-radius: 16px; background: #f4f7fb; color: #315eaa; font-family: var(--mono); font-size: 11px; font-weight: 900; }
.software-version-visual i { position: absolute; right: -3px; bottom: -3px; width: 14px; height: 14px; border: 3px solid #fff; border-radius: 50%; background: #22a06b; }
.software-version-copy { min-width: 0; }
.software-version-values { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
.software-version-cell { display: grid; min-width: 150px; gap: 3px; align-content: center; padding-right: 24px; }
.software-version-cell + .software-version-cell { padding-left: 24px; border-left: 1px solid var(--line); }
.software-version-cell span { color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; }
.software-version-cell strong { color: var(--text); font-family: var(--mono); font-size: clamp(22px,2.6vw,32px); line-height: 1.1; letter-spacing: -.04em; }
.software-version-cell.is-latest strong { color: var(--accent); }
.software-version-state { margin: 9px 0 0; color: var(--muted); font-size: 10px; }
.software-version-meta { display: flex; flex-wrap: wrap; gap: 6px 15px; margin-top: 10px; color: var(--muted); font-size: 8px; }
.software-version-meta b { color: #4b5c70; font-weight: 750; }
.software-version-action { position: relative; z-index: 1; }
.software-check-button { gap: 7px; }
.software-check-button i { font-style: normal; font-size: 15px; line-height: 1; transition: transform .2s ease; }
.software-check-button:hover i { transform: rotate(45deg); }
.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); }
.update-credential-panel { width: min(930px,100%); }
.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-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-orbit { to { transform: rotate(360deg); } }
.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; }
@@ -381,6 +560,23 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.pihole-action-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.onboarding-shell { grid-template-columns: minmax(300px, .72fr) minmax(500px, 1.28fr); }
.onboarding-intro { padding: 34px; }
.onboarding-copy { margin-top: 38px; }
.onboarding-copy h1 { font-size: 43px; }
.onboarding-progress { margin-top: 27px; }
.onboarding-steps small { display: none; }
.onboarding-card { padding: 34px 42px; }
}
@media (max-width: 820px), (max-height: 600px) {
.onboarding-shell { grid-template-columns: 1fr; }
.onboarding-intro { display: none; }
.onboarding-card { padding: clamp(20px, 5vw, 38px); }
.onboarding-form-wrap { width: min(660px, 100%); }
}
@media (max-width: 720px) {
.admin-layout { display: block; }
.sidebar { position: static; height: auto; padding: 10px 12px; }
@@ -413,6 +609,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.archive-row .button { width: 100%; }
.info-box { grid-template-columns: 1fr; }
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
.access-policy-grid { grid-template-columns: 1fr; }
.access-zone-controls { width: 100%; }
.access-target { grid-template-columns: 1fr; }
.error-summary-grid { grid-template-columns: 1fr; }
@@ -420,9 +617,78 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.error-item > a { grid-column: 2; }
.pihole-action-grid { grid-template-columns: 1fr; }
.pihole-connect-stage { min-height: 0; }
.software-update-banner { width: calc(100% - 24px); grid-template-columns: 34px minmax(0,1fr); margin-top: 12px; }
.software-update-banner-actions { grid-column: 1 / -1; }
.software-update-banner-actions .button, .software-update-banner-actions form { width: 100%; }
.software-version-card { grid-template-columns: 48px minmax(0,1fr); gap: 13px; padding: 18px; }
.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%; }
.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; }
.page-setup .auth-shell { align-items: stretch; padding: 0; }
.setup-field-row { grid-template-columns: repeat(2, minmax(0,1fr)); }
.onboarding-card { padding: 24px; }
.onboarding-card-meta { margin-bottom: 24px; }
.onboarding-form-heading h2 { font-size: 32px; }
.onboarding-lede { margin-bottom: 20px; }
.setup-form { gap: 12px; }
.setup-form input { min-height: 43px; }
.onboarding-footer { margin-top: 17px; padding-top: 15px; }
}
@media (max-width: 520px) {
.access-policy-card { grid-template-columns: 34px minmax(0,1fr); }
.access-policy-card > .status-pill { grid-column: 2; justify-self: start; }
.software-version-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
.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; }
.onboarding-card { padding: 18px; }
.onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 28px; }
.onboarding-lede { margin-bottom: 15px; font-size: 10px; }
.setup-field-row { grid-template-columns: 1fr; gap: 9px; }
.setup-form { gap: 9px; }
.setup-form label { gap: 4px; }
.setup-form input { min-height: 39px; }
.setup-submit { min-height: 42px; }
.onboarding-footer { margin-top: 12px; padding-top: 11px; }
}
@media (max-height: 700px) {
.onboarding-intro { padding-block: 30px; }
.onboarding-copy { margin-top: 28px; }
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
.onboarding-copy p { font-size: 11px; }
.onboarding-progress { margin-top: 20px; }
.onboarding-progress-track { margin-top: 7px; }
.onboarding-steps { gap: 4px; margin-top: 9px; }
.onboarding-steps li { min-height: 50px; padding-block: 7px; }
.onboarding-steps small { display: none; }
.onboarding-card { padding-block: 24px; }
.onboarding-card-meta { margin-bottom: 18px; }
.onboarding-form-heading h2 { font-size: 31px; }
.onboarding-lede { margin-bottom: 17px; }
.setup-form { gap: 10px; }
.setup-form input { min-height: 41px; }
.onboarding-footer { margin-top: 13px; padding-top: 13px; }
}
@media (max-height: 560px) {
.onboarding-card-meta { margin-bottom: 12px; }
.onboarding-lede { display: none; }
.onboarding-form-heading h2 { margin-bottom: 14px; }
.setup-form input { min-height: 38px; }
.password-guidance span { min-height: 32px; padding: 5px 7px; }
.password-guidance i { width: 18px; height: 18px; }
.setup-submit { min-height: 40px; }
.onboarding-footer { padding-top: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
.toast, .toast-progress { animation: none !important; }
}
+2 -2
View File
@@ -32,7 +32,7 @@
<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}}">
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder value="{{.StaticIPv4}}" inputmode="decimal" placeholder="{{if $.Settings.LastIPv4}}{{$.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
@@ -106,7 +106,7 @@
{{if or .IsAdmin .CanCreateTargets}}
<section class="action-grid admin-actions-grid">
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 data-current-ipv4-placeholder inputmode="decimal" placeholder="{{if .Settings.LastIPv4}}{{.Settings.LastIPv4}}{{else}}203.0.113.10{{end}}" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist; grau steht die zuletzt ermittelte aktuelle IPv4.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
</article>
{{if .IsAdmin}}
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
+27 -6
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-15">
<script src="/static/app.js?v=features-15" defer></script>
<link rel="stylesheet" href="/static/styles.css?v=features-21">
<script src="/static/app.js?v=features-21" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
@@ -26,13 +26,12 @@
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
<a href="/access" {{if eq .Page "access"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Zugriff</a>
<a href="/system" {{if eq .Page "system"}}aria-current="page"{{end}}><span class="nav-icon">07</span>Update{{if .SoftwareUpdate.UpdateAvailable}}<i class="nav-update-dot" aria-label="Update verfügbar"></i>{{end}}</a>
{{end}}
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{else}}03{{end}}</span>Sicherheit</a>
</nav>
<div class="sidebar-account">
<span class="account-avatar">{{initial .User.DisplayName}}</span>
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
<a class="sidebar-account-link" href="/account" aria-label="Eigenes Konto öffnen"><span class="account-avatar">{{initial .User.DisplayName}}</span><span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span></a>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
@@ -44,6 +43,16 @@
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
{{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">
<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.AgentAvailable}}<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}}
<main id="main" class="app-shell">
{{template "content" .}}
</main>
@@ -67,6 +76,18 @@
</div>
</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>
{{end}}
</body>
</html>
{{end}}
+6 -6
View File
@@ -1,18 +1,18 @@
{{define "login"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card">
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
<h1>Anmelden</h1>
<section class="auth-card login-card">
<div class="login-card-top"><div class="auth-brand"><span>DD</span> DNS Control</div><span class="login-mode {{if eq .Mode "https"}}is-secure{{end}}">{{if eq .Mode "https"}}HTTPS{{else}}LAN{{end}}</span></div>
<h1>Willkommen zurück</h1>
<p class="lede">Melde dich mit deinem persönlichen Zugang an.</p>
<form action="/login" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Benutzername
<input name="username" type="text" autocomplete="username" required autofocus>
<input name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" maxlength="40" required autofocus>
</label>
<label>Passwort
<input name="password" type="password" autocomplete="current-password" required>
<input name="password" type="password" autocomplete="current-password" maxlength="128" required>
</label>
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
</form>
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
</section>
{{end}}
+2 -5
View File
@@ -1,8 +1,5 @@
{{define "security"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
<section class="split-grid">
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
</section>
<header class="page-heading"><div><span class="page-kicker">Persönlich</span><h1>Mein Konto</h1><p>@{{.User.Username}} · {{if .IsAdmin}}Administrator{{else}}Mitglied{{end}}</p></div>{{if .IsAdmin}}<a class="button button-quiet" href="/access">Zugriff verwalten</a>{{end}}</header>
<section class="form-panel account-password-panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Eigenes Passwort ändern</h2></div></div><form action="/account/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" maxlength="128" required></label><div class="form-grid"><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="15" maxlength="128" required></label><label>Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="15" maxlength="128" required></label></div><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></section>
{{end}}
+54 -16
View File
@@ -1,26 +1,64 @@
{{define "setup"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card">
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
<h1>System einrichten</h1>
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
<form action="/setup" method="post" class="stacked-form">
<section class="onboarding-shell">
<aside class="onboarding-intro">
<div class="onboarding-brand-row">
<div class="auth-brand auth-brand-inverse"><span>DD</span> DNS Control</div>
<span class="onboarding-edition">Self-hosted</span>
</div>
<div class="onboarding-copy">
<span class="onboarding-eyebrow">Erster Start</span>
<h1>DNS-Verwaltung,<br>die einfach läuft.</h1>
<p>Einrichten, verbinden, automatisch aktuell halten.</p>
</div>
<div class="onboarding-progress" aria-label="Einrichtung: Schritt 1 von 3">
<div><span>Einrichtung</span><strong>1 von 3</strong></div>
<span class="onboarding-progress-track" aria-hidden="true"><i></i></span>
</div>
<ol class="onboarding-steps" aria-label="Einrichtung">
<li class="is-current" aria-current="step"><span>01</span><div><div class="onboarding-step-title"><strong>Administrator anlegen</strong><em>Aktuell</em></div><small>Sicheren Zugang für diese Instanz erstellen.</small></div></li>
<li><span>02</span><div><strong>Dienste verbinden</strong><small>Hetzner und optional Pi-hole hinzufügen.</small></div></li>
<li><span>03</span><div><strong>Automatisieren</strong><small>DNS-Einträge zuverlässig aktuell halten.</small></div></li>
</ol>
</aside>
<section class="onboarding-card" aria-labelledby="setup-title">
<div class="onboarding-form-wrap">
<div class="onboarding-card-meta">
<span class="setup-ready"><i aria-hidden="true"></i> Instanz bereit</span>
<span>Schritt 1 von 3</span>
</div>
<header class="onboarding-form-heading">
<h2 id="setup-title">Administrator anlegen</h2>
<p class="onboarding-lede">Lege den ersten Administrator für diese Instanz an.</p>
</header>
<form action="/setup" method="post" class="stacked-form setup-form" data-setup-form>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Admin-Benutzername
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
<div class="form-grid setup-field-row"><label>Benutzername
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="40" required autofocus>
</label>
<label>Anzeigename
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
<input name="display_name" type="text" value="{{index .Form "display_name"}}" autocomplete="name" maxlength="80" required>
</label></div>
<label>Admin-Passwort
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
<div class="form-grid setup-field-row"><label>Admin-Passphrase
<input name="password" type="password" autocomplete="new-password" minlength="15" maxlength="128" aria-describedby="password-guidance" data-setup-password required>
</label>
<label>Passwort wiederholen
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
</label>
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
<label>Passphrase wiederholen
<input name="confirm_password" type="password" autocomplete="new-password" minlength="15" maxlength="128" data-setup-confirm required>
</label></div>
<div class="password-guidance" id="password-guidance" aria-live="polite">
<span data-password-length><i aria-hidden="true">✓</i> Mindestens 15 Zeichen</span>
<span data-password-match><i aria-hidden="true">✓</i> Eingaben stimmen überein</span>
</div>
<button class="button button-primary setup-submit" type="submit">Administrator erstellen <span aria-hidden="true">→</span></button>
</form>
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
<div class="onboarding-footer onboarding-mode {{if eq .Mode "https"}}is-secure{{end}}">
<span aria-hidden="true">{{if eq .Mode "https"}}●{{else}}!{{end}}</span>
<p><strong>{{if eq .Mode "https"}}HTTPS-Modus aktiv{{else}}LAN-Modus aktiv{{end}}</strong><small>{{if eq .Mode "https"}}Verschlüsselte Verbindung{{else}}Nur im lokalen Netz verwenden{{end}}</small></p>
</div>
</div>
</section>
</section>
{{end}}
+58
View File
@@ -0,0 +1,58 @@
{{define "system"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading system-heading">
<div><span class="page-kicker">Wartung</span><h1>Software-Update</h1><p>Version prüfen und die Instanz kontrolliert aktualisieren.</p></div>
<form action="/system/check" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet software-check-button" type="submit"><i aria-hidden="true">↻</i> Nach Update suchen</button></form>
</header>
<section class="software-version-card {{if .SoftwareUpdate.UpdateAvailable}}has-update{{end}}">
<div class="software-version-visual" aria-hidden="true"><span>DC</span><i></i></div>
<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>
<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 .SoftwareUpdate.AgentAvailable}}bereit{{else}}nicht erreichbar{{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}}
</div>
<div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentAvailable}}
<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>
</form>
{{else if .SoftwareUpdate.UpdateAvailable}}
<span class="status-pill status-warn">Update-Agent nicht erreichbar</span>
{{else}}
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured)}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
{{end}}
</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">
<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>
</form>
</section>
<div class="software-update-overlay" data-software-update-overlay hidden role="dialog" aria-modal="true" aria-labelledby="software-update-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>
</div>
{{end}}
+4 -4
View File
@@ -2,7 +2,7 @@
{{define "content"}}
<header class="page-heading">
<div>
<a class="back-link" href="/users">← Benutzer</a>
<a class="back-link" href="/access">← Zugriff & Sicherheit</a>
<span class="page-kicker">Benutzerprofil</span>
<h1>{{.ManagedUser.DisplayName}}</h1>
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
@@ -25,8 +25,8 @@
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
<label>Neues Passwort <input name="password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="15" maxlength="128" autocomplete="new-password" required></label>
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
</form>
@@ -68,6 +68,6 @@
</form>
</section>
{{else}}
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
<section class="admin-full-access-card access-panel"><span class="admin-full-access-icon" aria-hidden="true">A</span><div><span class="section-label">Administratorrolle</span><h2>Vollzugriff ist fest mit der Rolle verbunden</h2><p>Für Administratoren gibt es keine bearbeitbaren Domain- oder Host-Einzelrechte.</p></div><span class="status-pill status-good">Alle Rechte</span></section>
{{end}}
{{end}}
+5 -5
View File
@@ -1,13 +1,13 @@
{{define "user_new"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
<section class="form-panel compact-panel">
<header class="page-heading"><div><a class="back-link" href="/access">← Zugriff & Sicherheit</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten starten ohne Freigaben.</p></div></header>
<section class="form-panel compact-panel user-create-panel">
<form action="/users" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
<div class="form-grid user-create-grid user-create-identity-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required><small class="field-spacer" aria-hidden="true">&nbsp;</small></label></div>
<div class="form-grid user-create-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" maxlength="128" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" maxlength="128" required></label></div>
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/access">Abbrechen</a></div>
</form>
</section>
{{end}}
+5 -1
View File
@@ -1,5 +1,9 @@
{{define "users"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
<header class="page-heading"><div><span class="page-kicker">Administration</span><h1>Zugriff & Sicherheit</h1><p>Konten, Rollen und Freigaben an einem Ort.</p></div><div class="heading-actions"><a class="button button-quiet" href="/account">Mein Konto</a><a class="button button-primary" href="/users/new">Benutzer anlegen</a></div></header>
<section class="access-policy-grid" aria-label="Rollenmodell">
<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>
{{end}}