feat: production-ready domain control dashboard

This commit is contained in:
admin committed 2026-07-12 20:56:45 +02:00
1 parent 9c60eaf3fe
commit 65c22ee5a1
45 files changed
+7932 -1

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.gitea
.agents
.codex
build
inspo
coverage
bin
tmp
secrets
.env*
backups
compose*.yml
docker-compose.yml
*.md
+10
View File
@@ -0,0 +1,10 @@
# For LAN use, set this to the host's specific private address, for example 192.168.178.10.
# Keep 127.0.0.1 when a reverse proxy terminates HTTPS locally.
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
# lan is only appropriate for an isolated, trusted LAN. Set https behind a TLS reverse proxy.
APP_MODE=lan
# Create this file once with: openssl rand -base64 32 > secrets/dyndns_master_key
MASTER_KEY_FILE=./secrets/dyndns_master_key
+11
View File
@@ -0,0 +1,11 @@
# Unveränderlichen Release-Tag aus der Gitea Container Registry verwenden.
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
IMAGE_TAG=v1.0.0
# Hinter einem HTTPS-Reverse-Proxy ausschließlich an localhost binden.
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
APP_MODE=https
# Datei einmalig auf dem Produktionsserver erzeugen und dauerhaft sichern.
MASTER_KEY_FILE=./secrets/dyndns_master_key
+45
View File
@@ -0,0 +1,45 @@
name: Container-Image veröffentlichen
on:
push:
tags:
- "v*"
env:
REGISTRY: git.j-mahler.de
IMAGE_NAME: admin/domain-control-webinterface-prod
jobs:
container:
runs-on: ubuntu-latest
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: QEMU einrichten
uses: docker/setup-qemu-action@v3
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
- name: An der Gitea Container Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Versioniertes Image bauen und veröffentlichen
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.ref_name }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.version=${{ gitea.ref_name }}
provenance: false
+13
View File
@@ -0,0 +1,13 @@
.env
.env.production
secrets/
data/
*.db
*.db-shm
*.db-wal
*.log
bin/
coverage/
/build/
/inspo/
/backups/
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.12-alpine AS build
RUN apk add --no-cache build-base
WORKDIR /src
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
FROM alpine:3.23.2 AS runtime
RUN apk add --no-cache ca-certificates && \
addgroup -S -g 10001 dyndns && \
adduser -S -D -H -u 10001 -G dyndns dyndns && \
mkdir -p /data && chown dyndns:dyndns /data
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
USER dyndns:dyndns
WORKDIR /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dyndns-web", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/dyndns-web"]
+170
View File
@@ -0,0 +1,170 @@
# Produktivbetrieb mit Gitea und Docker Compose
Dieses Repository veröffentlicht versionierte Container-Images unter:
```text
git.j-mahler.de/admin/domain-control-webinterface-prod:vX.Y.Z
```
Das Image enthält ausschließlich die Anwendung. SQLite-Datenbank und Master-Key liegen dauerhaft auf dem Produktionsserver und bleiben bei Image-Updates erhalten.
## 1. Gitea für Releases vorbereiten
In den Repository-Einstellungen unter **Actions → Secrets** diese zwei Secrets anlegen:
- `REGISTRY_USERNAME`: Gitea-Benutzer oder eigener Release-Bot
- `REGISTRY_TOKEN`: persönlicher Gitea-Token mit Schreibrecht für Container-Pakete
Auf der Gitea-Instanz muss ein Actions-Runner mit dem Label `ubuntu-latest` registriert sein. Jeder Git-Tag, der mit `v` beginnt, startet anschließend `.gitea/workflows/release.yml` und baut Images für AMD64 und ARM64.
Erstes Release veröffentlichen:
```bash
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
Nach erfolgreicher Action stehen `v1.0.0` und der bewegliche Tag `stable` in der Gitea Container Registry. Auf Produktion immer den unveränderlichen Versionstag verwenden.
### Image alternativ manuell veröffentlichen
Falls Gitea Actions noch nicht eingerichtet ist:
```bash
echo "$GITEA_TOKEN" | docker login git.j-mahler.de --username DEIN_BENUTZER --password-stdin
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.j-mahler.de/admin/domain-control-webinterface-prod:v1.0.0 \
-t git.j-mahler.de/admin/domain-control-webinterface-prod:stable \
--push .
```
## 2. Produktionsserver vorbereiten
Voraussetzungen:
- Linux-Server mit Docker Engine und Docker-Compose-Plugin
- HTTPS-Reverse-Proxy wie Caddy, nginx oder Traefik
- DNS-Name, der auf den Server zeigt
Repository installieren:
```bash
sudo mkdir -p /opt/domain-control
sudo chown "$USER":"$USER" /opt/domain-control
git clone https://git.j-mahler.de/admin/domain-control-webinterface-prod.git /opt/domain-control
cd /opt/domain-control
cp .env.production.example .env.production
mkdir -p secrets backups
chmod 700 secrets backups
openssl rand -base64 32 > secrets/dyndns_master_key
chmod 644 secrets/dyndns_master_key
```
Der Master-Key wird vom unprivilegierten Benutzer im Container gelesen. Deshalb ist die Datei lesbar, während das übergeordnete Verzeichnis auf dem Host nur für seinen Besitzer zugänglich bleibt. Den Schlüssel niemals nachträglich ersetzen: Ohne ihn lassen sich gespeicherte Hetzner-Tokens nicht mehr entschlüsseln.
Bei einer privaten Registry auf dem Produktionsserver einmalig mit einem Token anmelden, der nur Leserechte auf Container-Pakete besitzt:
```bash
echo "$GITEA_PULL_TOKEN" | docker login git.j-mahler.de --username deploy-bot --password-stdin
```
In `.env.production` mindestens `IMAGE_TAG` kontrollieren:
```dotenv
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
IMAGE_TAG=v1.0.0
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
APP_MODE=https
MASTER_KEY_FILE=./secrets/dyndns_master_key
```
Anwendung laden und starten:
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
docker compose --env-file .env.production -f compose.production.yml ps
curl --fail http://127.0.0.1:8080/healthz
```
## 3. HTTPS-Reverse-Proxy
Port 8080 bleibt mit `BIND_ADDRESS=127.0.0.1` ausschließlich lokal erreichbar. Ein minimales Caddy-Beispiel:
```caddyfile
dns.example.de {
reverse_proxy 127.0.0.1:8080
}
```
Danach `https://dns.example.de` öffnen und den ersten Administrator anlegen. `APP_MODE=https` sorgt dabei für HTTPS-gebundene Session-Cookies.
Nur in einem isolierten, vertrauenswürdigen LAN darf stattdessen eine private Server-IP und `APP_MODE=lan` verwendet werden. Den Dienst nicht unverschlüsselt über `0.0.0.0` ins Internet stellen.
## 4. Backup vor jedem Update
Für eine konsistente SQLite-Kopie wird der Container kurz gestoppt:
```bash
cd /opt/domain-control
docker compose --env-file .env.production -f compose.production.yml stop dyndns
docker run --rm \
-v domain-control-data:/data:ro \
-v "$PWD/backups:/backup" \
alpine:3.23.2 \
cp /data/dyndns.db /backup/dyndns-before-update.db
docker compose --env-file .env.production -f compose.production.yml start dyndns
```
Zusätzlich `secrets/dyndns_master_key` verschlüsselt und außerhalb dieses Servers sichern. Eine Datenbanksicherung ohne den dazugehörigen Master-Key ist für gespeicherte API-Tokens unbrauchbar.
## 5. Update
Nach Veröffentlichung beispielsweise von `v1.1.0`:
1. Backup wie oben erstellen.
2. In `.env.production` setzen:
```dotenv
IMAGE_TAG=v1.1.0
```
3. Neues Image laden und Container ersetzen:
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
docker compose --env-file .env.production -f compose.production.yml ps
docker compose --env-file .env.production -f compose.production.yml logs --tail=100
curl --fail http://127.0.0.1:8080/healthz
```
Das benannte Volume `domain-control-data` wird dabei nicht ersetzt.
## 6. Rollback
Ohne Datenbankmigration reicht es, in `.env.production` den vorherigen `IMAGE_TAG` einzutragen und erneut `pull` sowie `up -d` auszuführen.
Wenn die neue Version eine Datenbankmigration ausgeführt hat, gemeinsam auf das vorherige Image und die unmittelbar davor erstellte Datenbanksicherung zurückgehen. Automatische, unbeaufsichtigte Updates sind deshalb nicht empfohlen.
## 7. Nützliche Betriebsbefehle
```bash
# Status
docker compose --env-file .env.production -f compose.production.yml ps
# Logs
docker compose --env-file .env.production -f compose.production.yml logs -f --tail=100
# Neustart
docker compose --env-file .env.production -f compose.production.yml restart dyndns
# Bereits heruntergeladene, ungenutzte Images aufräumen
docker image prune
```
+76
View File
@@ -0,0 +1,76 @@
# Hetzner DynDNS Dashboard
Ein schlankes, selbst gehostetes Dashboard für mehrere Hetzner-DNS-Zonen und DynDNS-Ziele. Der Container erkennt seine öffentliche IPv4/IPv6 in einem einstellbaren Rhythmus und aktualisiert ausschließlich aktivierte A-/AAAA-Records über die Hetzner Cloud DNS API.
## Was es kann
- Mehrere Hetzner-Domains mit getrennten, verschlüsselten API-Tokens
- Import vorhandener A-/AAAA-Records und Anlegen neuer DynDNS-Hosts
- Reversibles Trennen von DynDNS-Zielen: Hetzner-Records bleiben bestehen und werden im lokalen Archiv angezeigt
- Globale, konfigurierbare IP-Prüfung mit HTTPS-Fallbackquellen
- Live-Countdown bis zum nächsten Scheduler-Lauf und manueller Sofortlauf für Admins
- Beschreibungen lokal und als Hetzner-Record-Kommentar
- Sichere Mehrbenutzerverwaltung:
- `Admin`: vollständige Zonen-, Token-, Updater- und Benutzerverwaltung
- `Mitglied`: ausschließlich explizit freigegebene Domains oder einzelne Subdomains
- SQLite-Datenvolume, Docker Secret für die Token-Verschlüsselung und kein externer Login-Dienst
## Schnellstart
1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen:
```bash
mkdir -p secrets
openssl rand -base64 32 > secrets/dyndns_master_key
chmod 700 secrets
chmod 644 secrets/dyndns_master_key
cp .env.example .env
```
2. Für LAN-Zugriff in `.env` `BIND_ADDRESS` auf die konkrete private IP des Docker-Hosts setzen, zum Beispiel `192.168.178.10`. Verwende im HTTP-LAN-Modus nie `0.0.0.0`.
Die Schlüsseldatei benötigt bei Docker Compose Leserechte für den unprivilegierten Containerprozess. Das Verzeichnis selbst bleibt deshalb auf dem Host mit Modus `700` geschützt; im Container läuft nur der nicht-root DynDNS-Prozess.
3. Starten:
```bash
docker compose up -d --build
```
4. Öffne `http://<host-ip>:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token.
## Produktivbetrieb
Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut:
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
```
Die vollständige Anleitung für Gitea Actions, HTTPS-Reverse-Proxy, Installation, Backups, Updates und Rollbacks steht in [PRODUCTION.md](PRODUCTION.md).
## Sicherheit
- Passwörter werden mit Argon2id gehasht.
- Hetzner-Tokens werden mit XChaCha20-Poly1305 verschlüsselt; der Schlüssel liegt als Docker Secret außerhalb des Datenvolumes.
- Cookies sind `HttpOnly` und `SameSite=Strict`; alle schreibenden Formulare verwenden CSRF-Tokens.
- Token-Anzeige verlangt die erneute Eingabe des Admin-Passworts und setzt `Cache-Control: no-store`.
- Unprivilegierte Nutzer sehen niemals API-Tokens, globale IP-Quellen, Update-Historie anderer Domains oder Benutzerverwaltung.
`APP_MODE=lan` ist nur für ein isoliertes, vertrauenswürdiges Netz geeignet. Passwort und API-Token sind bei HTTP auf einem fremden oder geteilten Netz nicht gegen Mitlesen geschützt. Für WLAN mit Gästen oder externen Zugriff setze einen HTTPS-Reverse-Proxy vor den Container und verwende `APP_MODE=https`.
## Berechtigungen
Ein Admin legt unter **Benutzer** Konten an und entscheidet pro Mitglied:
- **Ganze Domain**: Zugriff auf alle aktuellen und später importierten/angelegten DynDNS-Ziele dieser Zone.
- **Einzelne Subdomain**: Zugriff nur auf den konkreten Host, etwa `home.example.de`.
Mitglieder dürfen ihren freigegebenen Host aktivieren/deaktivieren und dessen Beschreibung pflegen. Sie können keine DNS-Records löschen, Domains importieren, API-Tokens einsehen oder globale Einstellungen verändern.
## Daten und Betrieb
Die persistente Datenbank liegt im Docker-Volume `dyndns-data`. Sichere für Backups sowohl das Volume als auch das separate Master-Key-Secret; ohne den Master-Key lassen sich gespeicherte Hetzner-Tokens absichtlich nicht wiederherstellen.
Der Container bietet `/healthz` und `/readyz`. Für eine Aktualisierung ohne Warten auf das Intervall gibt es im Admin-Dashboard **Jetzt aktualisieren**.
View File
Whitespace-only changes.
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"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"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler"
"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/web"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
os.Exit(healthcheck())
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := run(logger); err != nil {
logger.Error("application stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
cipher, err := secure.LoadCipher(cfg.MasterKeyFile)
if err != nil {
return err
}
ctx := context.Background()
database, err := store.Open(ctx, cfg.DatabasePath)
if err != nil {
return err
}
defer database.Close()
if err := database.Prune(ctx); err != nil {
logger.Warn("prune old local data", "error", err)
}
provider := hetzner.New(cfg.HCloudBaseURL)
updater := scheduler.New(database, provider, ipresolve.New(), cipher, logger)
settings, err := database.GetSettings(ctx)
if err != nil {
return err
}
if !settings.IPv6Enabled {
deleted, cleanupErr := updater.DisableIPv6(ctx)
if cleanupErr != nil {
logger.Warn("enforce disabled IPv6", "error", cleanupErr)
} else if deleted > 0 {
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
}
}
serverUI, err := web.New(cfg, database, cipher, updater, logger)
if err != nil {
return err
}
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
updater.Start(appCtx)
httpServer := &http.Server{
Addr: cfg.Address,
Handler: serverUI.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErrors := make(chan error, 1)
go func() {
logger.Info("starting server", "address", cfg.Address, "mode", cfg.Mode)
serverErrors <- httpServer.ListenAndServe()
}()
select {
case err := <-serverErrors:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
case <-appCtx.Done():
logger.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return httpServer.Shutdown(shutdownCtx)
}
}
func healthcheck() int {
url := strings.TrimSpace(os.Getenv("APP_HEALTHCHECK_URL"))
if url == "" {
url = "http://127.0.0.1:8080/healthz"
}
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Get(url)
if err != nil {
return 1
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return 1
}
return 0
}
+39
View File
@@ -0,0 +1,39 @@
name: domain-control
services:
dyndns:
image: ${DYNDNS_IMAGE:-git.j-mahler.de/admin/domain-control-webinterface-prod}:${IMAGE_TAG:-v1.0.0}
restart: unless-stopped
init: true
stop_grace_period: 20s
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
environment:
APP_MODE: ${APP_MODE:-https}
APP_ADDRESS: :8080
APP_DATA_DIR: /data
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
volumes:
- dyndns-data:/data
secrets:
- dyndns_master_key
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
secrets:
dyndns_master_key:
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
volumes:
dyndns-data:
name: domain-control-data
+32
View File
@@ -0,0 +1,32 @@
services:
dyndns:
build:
context: .
image: local/hetzner-dyndns-dashboard:latest
restart: unless-stopped
ports:
# Use one explicit RFC1918 host address for LAN access, never 0.0.0.0 in HTTP mode.
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
environment:
APP_MODE: ${APP_MODE:-lan}
APP_ADDRESS: :8080
APP_DATA_DIR: /data
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
volumes:
- dyndns-data:/data
secrets:
- dyndns_master_key
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
secrets:
dyndns_master_key:
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
volumes:
dyndns-data:
+14
View File
@@ -0,0 +1,14 @@
module github.com/root-kdw12/hetzner-api-dyndns-records-webinterface
go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.47
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
)
require (
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
Submodule inspo/hetzner-dyndns deleted from e89c1262cc.
+92
View File
@@ -0,0 +1,92 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
DefaultIPv4Primary = "https://api.ipify.org"
DefaultIPv4Fallback = "https://ifconfig.co/ip"
DefaultIPv6Primary = "https://api6.ipify.org"
DefaultIPv6Fallback = "https://ifconfig.co/ip"
)
type Config struct {
Address string
DataDir string
DatabasePath string
MasterKeyFile string
CookieSecure bool
SessionTTL time.Duration
HCloudBaseURL string
Mode string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
dataDir := value("APP_DATA_DIR", "/data")
address := value("APP_ADDRESS", ":8080")
if _, _, err := net.SplitHostPort(address); err != nil {
return Config{}, fmt.Errorf("APP_ADDRESS must be host:port: %w", err)
}
mode := strings.ToLower(value("APP_MODE", "lan"))
if mode != "lan" && mode != "https" {
return Config{}, fmt.Errorf("APP_MODE must be either lan or https")
}
ttl, err := duration("APP_SESSION_TTL", 8*time.Hour)
if err != nil || ttl < time.Hour || ttl > 7*24*time.Hour {
return Config{}, fmt.Errorf("APP_SESSION_TTL must be between 1h and 168h")
}
shutdownTimeout, err := duration("APP_SHUTDOWN_TIMEOUT", 15*time.Second)
if err != nil {
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
}
keyFile := os.Getenv("APP_MASTER_KEY_FILE")
if keyFile == "" {
return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)")
}
cookieSecure := mode == "https"
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
parsed, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
return Config{}, fmt.Errorf("APP_COOKIE_SECURE: %w", parseErr)
}
cookieSecure = parsed
}
return Config{
Address: address,
DataDir: dataDir,
DatabasePath: value("APP_DATABASE_PATH", filepath.Join(dataDir, "dyndns.db")),
MasterKeyFile: keyFile,
CookieSecure: cookieSecure,
SessionTTL: ttl,
HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"),
Mode: mode,
ShutdownTimeout: shutdownTimeout,
}, nil
}
func value(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
}
return fallback
}
func duration(name string, fallback time.Duration) (time.Duration, error) {
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
return time.ParseDuration(raw)
}
return fallback, nil
}
+221
View File
@@ -0,0 +1,221 @@
package hetzner
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
http *http.Client
}
type Zone struct {
ID string
Name string
}
type Record struct {
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
type RRSet struct {
Name string `json:"name"`
Type string `json:"type"`
TTL *int `json:"ttl"`
Records []Record `json:"records"`
Protection struct {
Change bool `json:"change"`
} `json:"protection"`
}
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string {
if e.Code != "" {
return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message)
}
return fmt.Sprintf("Hetzner API %d: %s", e.Status, e.Message)
}
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
var result []Zone
for page := 1; ; page++ {
var response struct {
Zones []struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"zones"`
Meta struct {
Pagination struct {
NextPage *int `json:"next_page"`
} `json:"pagination"`
} `json:"meta"`
}
query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"50"}}
if err := c.doJSON(ctx, token, http.MethodGet, "/zones?"+query.Encode(), nil, &response); err != nil {
return nil, err
}
for _, zone := range response.Zones {
result = append(result, Zone{ID: strconv.FormatInt(zone.ID, 10), Name: zone.Name})
}
if response.Meta.Pagination.NextPage == nil {
break
}
page = *response.Meta.Pagination.NextPage - 1
}
return result, nil
}
func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) {
var result []RRSet
for page := 1; ; page++ {
var response struct {
RRSets []RRSet `json:"rrsets"`
Meta struct {
Pagination struct {
NextPage *int `json:"next_page"`
} `json:"pagination"`
} `json:"meta"`
}
query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"100"}}
if err := c.doJSON(ctx, token, http.MethodGet, "/zones/"+url.PathEscape(zoneID)+"/rrsets?"+query.Encode(), nil, &response); err != nil {
return nil, err
}
result = append(result, response.RRSets...)
if response.Meta.Pagination.NextPage == nil {
break
}
page = *response.Meta.Pagination.NextPage - 1
}
return result, nil
}
func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordType string, ttl int, records []Record) error {
body := struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Records []Record `json:"records"`
}{Name: name, Type: recordType, TTL: ttl, Records: records}
return c.doJSON(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body, nil)
}
func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
body := struct {
Records []Record `json:"records"`
}{Records: records}
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil)
}
func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error {
body := struct {
TTL int `json:"ttl"`
}{TTL: ttl}
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil)
}
func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error {
return c.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil)
}
func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error {
body := struct {
Records []struct {
Value string `json:"value"`
Comment string `json:"comment"`
} `json:"records"`
}{Records: make([]struct {
Value string `json:"value"`
Comment string `json:"comment"`
}, 0, len(records))}
for _, record := range records {
body.Records = append(body.Records, struct {
Value string `json:"value"`
Comment string `json:"comment"`
}{Value: record.Value, Comment: record.Comment})
}
return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body, nil)
}
func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error {
var body io.Reader
if requestBody != nil {
encoded, err := json.Marshal(requestBody)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request Hetzner API: %w", err)
}
defer resp.Body.Close()
content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("read Hetzner API response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return decodeAPIError(resp.StatusCode, content)
}
if responseBody != nil && len(content) > 0 {
if err := json.Unmarshal(content, responseBody); err != nil {
return fmt.Errorf("decode Hetzner API response: %w", err)
}
}
return nil
}
func rrsetPath(zoneID, name, recordType string) string {
return "/zones/" + url.PathEscape(zoneID) + "/rrsets/" + url.PathEscape(name) + "/" + url.PathEscape(recordType)
}
func decodeAPIError(status int, content []byte) error {
var response struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(content, &response)
message := strings.TrimSpace(response.Error.Message)
if message == "" {
message = http.StatusText(status)
}
return &APIError{Status: status, Code: response.Error.Code, Message: message}
}
+148
View File
@@ -0,0 +1,148 @@
package hetzner
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
t.Parallel()
type capturedRequest struct {
Method string
Path string
Authorization string
Records []Record
}
captured := make(chan capturedRequest, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Records []Record `json:"records"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
captured <- capturedRequest{
Method: r.Method,
Path: r.URL.Path,
Authorization: r.Header.Get("Authorization"),
Records: body.Records,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
}))
defer api.Close()
client := New(api.URL)
err := client.SetRRSetRecords(
context.Background(),
"test-token",
"123",
"home",
"A",
[]Record{{Value: "198.51.100.42", Comment: "DynDNS"}},
)
if err != nil {
t.Fatalf("set RRSet records: %v", err)
}
request := <-captured
if request.Method != http.MethodPost {
t.Errorf("unexpected method: got %s, want POST", request.Method)
}
if request.Path != "/zones/123/rrsets/home/A/actions/set_records" {
t.Errorf("unexpected path: %s", request.Path)
}
if request.Authorization != "Bearer test-token" {
t.Errorf("unexpected authorization header: %q", request.Authorization)
}
if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" {
t.Errorf("unexpected records payload: %#v", request.Records)
}
}
func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
t.Parallel()
captured := make(chan map[string]any, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body["method"] = r.Method
body["path"] = r.URL.Path
captured <- body
w.WriteHeader(http.StatusCreated)
}))
defer api.Close()
client := New(api.URL)
if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil {
t.Fatalf("update record comment: %v", err)
}
request := <-captured
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" {
t.Fatalf("unexpected request: %#v", request)
}
records, ok := request["records"].([]any)
if !ok || len(records) != 1 {
t.Fatalf("unexpected records payload: %#v", request["records"])
}
record, ok := records[0].(map[string]any)
if !ok {
t.Fatalf("unexpected record payload: %#v", records[0])
}
comment, exists := record["comment"]
if !exists || comment != "" {
t.Errorf("empty comment must be sent explicitly, got %#v", record)
}
}
func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
t.Parallel()
captured := make(chan map[string]any, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body["method"] = r.Method
body["path"] = r.URL.Path
captured <- body
w.WriteHeader(http.StatusCreated)
}))
defer api.Close()
client := New(api.URL)
if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil {
t.Fatalf("change RRSet TTL: %v", err)
}
request := <-captured
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) {
t.Fatalf("unexpected request: %#v", request)
}
}
func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
t.Parallel()
captured := make(chan *http.Request, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured <- r
w.WriteHeader(http.StatusNoContent)
}))
defer api.Close()
client := New(api.URL)
if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil {
t.Fatalf("delete RRSet: %v", err)
}
request := <-captured
if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" {
t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization"))
}
}
+190
View File
@@ -0,0 +1,190 @@
package ipresolve
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
type Result struct {
IPv4 string
IPv6 string
IPv4Err error
IPv6Err error
}
type Resolver struct {
resolver *net.Resolver
}
func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} }
func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result {
return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true)
}
func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result {
result := Result{}
result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true)
if ipv6Enabled {
result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false)
}
return result
}
func (r *Resolver) ValidateEndpoint(ctx context.Context, raw string) error {
_, _, err := r.parseAndResolve(ctx, raw, true)
if err == nil {
return nil
}
_, _, err6 := r.parseAndResolve(ctx, raw, false)
if err6 == nil {
return nil
}
return fmt.Errorf("endpoint is not a reachable public HTTPS host: %w", err)
}
func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 bool) (string, error) {
if len(endpoints) == 0 {
return "", errors.New("no endpoints configured")
}
var failures []string
for _, endpoint := range endpoints {
value, err := r.resolveOne(ctx, endpoint, ipv4)
if err == nil {
return value, nil
}
failures = append(failures, safeEndpointName(endpoint)+": "+err.Error())
}
family := "IPv6"
if ipv4 {
family = "IPv4"
}
return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; "))
}
func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) {
u, ips, err := r.parseAndResolve(ctx, raw, ipv4)
if err != nil {
return "", err
}
port := u.Port()
if port == "" {
port = "443"
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
Proxy: nil,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()},
DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) {
var lastErr error
for _, ip := range ips {
conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "text/plain")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
if err != nil {
return "", err
}
if len(body) > 1024 {
return "", errors.New("response is larger than 1 KiB")
}
address, err := netip.ParseAddr(strings.TrimSpace(string(body)))
if err != nil {
return "", errors.New("response is not a plain IP address")
}
if ipv4 != address.Is4() {
return "", errors.New("response has the wrong IP family")
}
if !publicAddress(address) {
return "", errors.New("response is not a globally routable address")
}
return address.String(), nil
}
func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return nil, nil, errors.New("invalid URL")
}
if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return nil, nil, errors.New("only credential-free HTTPS URLs are allowed")
}
if port := u.Port(); port != "" && port != "443" {
return nil, nil, errors.New("only HTTPS port 443 is allowed")
}
if u.RawQuery != "" {
return nil, nil, errors.New("query parameters are not allowed")
}
host := u.Hostname()
if literal, err := netip.ParseAddr(host); err == nil {
if literal.Is4() != ipv4 || !publicAddress(literal) {
return nil, nil, errors.New("host must resolve to a public address of the requested family")
}
return u, []netip.Addr{literal}, nil
}
addresses, err := r.resolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, nil, fmt.Errorf("resolve host: %w", err)
}
filtered := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
if address.Is4() == ipv4 && publicAddress(address) {
filtered = append(filtered, address)
}
}
if len(filtered) == 0 {
return nil, nil, errors.New("host has no public address of the requested family")
}
return u, filtered, nil
}
func publicAddress(address netip.Addr) bool {
return address.IsValid() && address.IsGlobalUnicast() && !address.IsPrivate() && !address.IsLoopback() && !address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() && !address.IsUnspecified() && !address.IsMulticast()
}
func safeEndpointName(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return "configured endpoint"
}
return u.Hostname()
}
+34
View File
@@ -0,0 +1,34 @@
package ipresolve
import (
"context"
"net/netip"
"testing"
)
func TestPublicAddress(t *testing.T) {
t.Parallel()
for _, raw := range []string{"1.1.1.1", "2001:4860:4860::8888"} {
address := netip.MustParseAddr(raw)
if !publicAddress(address) {
t.Fatalf("expected %s to be public", raw)
}
}
for _, raw := range []string{"127.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fe80::1"} {
address := netip.MustParseAddr(raw)
if publicAddress(address) {
t.Fatalf("expected %s to be rejected", raw)
}
}
}
func TestDisabledIPv6IsNotResolvedOrReportedAsError(t *testing.T) {
t.Parallel()
result := New().ResolveWithIPv6(context.Background(), nil, nil, false)
if result.IPv4Err == nil {
t.Fatal("missing IPv4 endpoints should still be reported")
}
if result.IPv6 != "" || result.IPv6Err != nil {
t.Fatalf("disabled IPv6 must be skipped cleanly: %#v", result)
}
}
+137
View File
@@ -0,0 +1,137 @@
package model
import "time"
// Settings are global to the single-admin installation.
type Settings struct {
UpdateIntervalSeconds int
UpdaterEnabled bool
IPv6Enabled bool
IPv4Endpoints []string
IPv6Endpoints []string
LastIPv4 string
LastIPv6 string
LastRunAt time.Time
LastRunStatus string
UpdatedAt time.Time
}
// Credential is a reusable, encrypted Hetzner project token.
type Credential struct {
ID int64
Label string
Ciphertext []byte
Nonce []byte
Fingerprint string
CreatedAt time.Time
}
// Zone is one Hetzner DNS zone selected by the administrator.
type Zone struct {
ID int64
CredentialID int64
ProviderID string
Name string
CreatedAt time.Time
LastSyncAt time.Time
LastError string
}
// Target is the desired state for one host name and its dynamic address families.
type Target struct {
ID int64
ZoneID int64
RecordName string
FQDN string
UpdateIPv4 bool
UpdateIPv6 bool
Active bool
StaticIPv4 string
StaticIPv6 string
Description string
TTL int
LastUpdatedAt time.Time
LastStatus string
LastError string
CreatedAt time.Time
ArchivedAt time.Time
}
// CNAMERecord is a static DNS alias managed directly in a zone. It is kept
// separate from Target because the DynDNS worker must never overwrite it.
type CNAMERecord struct {
ID int64
ZoneID int64
Active bool
RecordName string
FQDN string
Destination string
Description string
TTL int
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct {
ID int64
Username string
DisplayName string
Role string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
LastLoginAt time.Time
}
func (u User) IsAdmin() bool { return u.Role == "admin" }
type AccessGrant struct {
ID int64
UserID int64
ZoneID int64
TargetID *int64
CanManageTargets bool
CanCreateTargets bool
}
// Session deliberately contains only hashes/random CSRF material, never a password.
type Session struct {
ID int64
UserID int64
Username string
DisplayName string
Role string
TokenHash string
CSRFToken string
CreatedAt time.Time
ExpiresAt time.Time
ReauthUntil time.Time
}
type AuditEvent struct {
ID int64
CreatedAt time.Time
Kind string
Message string
}
type UpdateRun struct {
ID int64
StartedAt time.Time
FinishedAt time.Time
Status string
IPv4 string
IPv6 string
Summary string
}
type Dashboard struct {
Settings Settings
Zones []Zone
ActiveTargets int
RecentRuns []UpdateRun
RecentEvents []AuditEvent
NextRunAt time.Time
SchedulerRunning bool
ErrorCount int
}
+830
View File
@@ -0,0 +1,830 @@
package scheduler
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
"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"
)
var (
ErrRunInProgress = errors.New("an update run is already in progress")
ErrDisabled = errors.New("the updater is disabled")
)
type Scheduler struct {
store *store.Store
provider *hetzner.Client
resolver *ipresolve.Resolver
cipher *secure.Cipher
logger *slog.Logger
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
}
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)}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer s.nextRun.Store(0)
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
s.logger.Error("read scheduler settings", "error", err)
s.nextRun.Store(0)
timer := time.NewTimer(time.Minute)
select {
case <-ctx.Done():
timer.Stop()
return
case <-s.wake:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-timer.C:
}
continue
}
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)
}
} else {
s.nextRun.Store(0)
select {
case <-ctx.Done():
return
case <-s.wake:
}
continue
}
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
if wait < time.Minute {
wait = time.Minute
}
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return
case <-s.wake:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-timer.C:
}
}
}()
}
func (s *Scheduler) Trigger() {
s.nextRun.Store(0)
select {
case s.wake <- struct{}{}:
default:
}
}
func (s *Scheduler) IsRunning() bool { return s.running.Load() }
func (s *Scheduler) NextRunAt() time.Time {
value := s.nextRun.Load()
if value == 0 {
return time.Time{}
}
return time.Unix(value, 0).UTC()
}
func (s *Scheduler) Run(ctx context.Context) (string, error) {
if !s.running.CompareAndSwap(false, true) {
return "", ErrRunInProgress
}
defer s.running.Store(false)
settings, err := s.store.GetSettings(ctx)
if err != nil {
return "", err
}
if !settings.UpdaterEnabled {
return "", ErrDisabled
}
runID, err := s.store.CreateUpdateRun(ctx)
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)
}
if len(targets) == 0 {
summary := "Keine aktiven DynDNS-Ziele"
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
}
grouped := make(map[int64][]store.ManagedTarget)
for _, target := range targets {
grouped[target.Zone.ID] = append(grouped[target.Zone.ID], target)
}
updated, failed := 0, 0
for _, group := range grouped {
if len(group) == 0 {
continue
}
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
failed += len(group)
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
for _, target := range group {
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
}
_ = s.store.MarkZoneSynced(ctx, group[0].Zone.ID, safeError(err))
}
}
status := "ok"
if failed > 0 {
status = "partial"
}
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != 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)
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
_ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary)
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
if status != "ok" {
_ = s.store.AddAudit(ctx, "update."+status, summary)
}
return summary, runErr
}
func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.ManagedTarget, ipv4, ipv6 string, ipv6Enabled bool, updated, failed *int) error {
zone, credential := targets[0].Zone, targets[0].Credential
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return err
}
byKey := make(map[string]hetzner.RRSet, len(rrsets))
for _, rrset := range rrsets {
if rrset.Type == "A" || rrset.Type == "AAAA" {
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
}
}
for _, item := range targets {
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
if targetErr != nil {
*failed = *failed + 1
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false)
continue
}
if targetUpdated {
*updated = *updated + 1
}
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated)
}
return s.store.MarkZoneSynced(ctx, zone.ID, "")
}
func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) {
var states []string
updated := false
if target.UpdateIPv4 {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 {
if !ipv6Enabled {
states = append(states, "AAAA global deaktiviert")
} else {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
}
if len(states) == 0 {
return false, "Deaktivierte IP-Familien", nil
}
return updated, strings.Join(states, " · "), nil
}
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
if ip == "" {
return false, recordType + " nicht verfügbar", nil
}
if rrset.Name == "" {
ttl := target.TTL
if ttl < 60 {
ttl = 60
}
if err := s.provider.CreateRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
return false, "", err
}
return true, recordType + " angelegt", nil
}
if rrset.Protection.Change {
return false, "", errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
}
if len(rrset.Records) != 1 {
return false, "", fmt.Errorf("%s enthält %d Records und wird aus Sicherheitsgründen nicht geändert", rrset.Name, len(rrset.Records))
}
ttlChanged := false
if rrset.TTL == nil || *rrset.TTL != targetTTL(target.TTL) {
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, targetTTL(target.TTL)); err != nil {
return false, "", err
}
ttlChanged = true
}
current := rrset.Records[0]
if current.Value == ip {
if current.Comment != target.Description {
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil {
return false, "", err
}
status := recordType + " Kommentar synchronisiert"
if ttlChanged {
status += " · TTL aktualisiert"
}
return true, status, nil
}
if ttlChanged {
return true, recordType + " TTL aktualisiert", nil
}
return false, recordType + " unverändert", nil
}
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
return false, "", err
}
status := recordType + " aktualisiert"
if ttlChanged {
status += " · TTL aktualisiert"
}
return true, status, nil
}
func (s *Scheduler) SyncZone(ctx context.Context, zoneID int64) (int, error) {
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
if err != nil {
return 0, err
}
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return 0, err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
_ = s.store.MarkZoneSynced(ctx, zone.ID, safeError(err))
return 0, err
}
type imported struct {
ipv4, ipv6 bool
description string
ttl int
}
grouped := map[string]*imported{}
var cnames []model.CNAMERecord
for _, rrset := range rrsets {
if rrset.Type == "CNAME" {
if len(rrset.Records) != 1 {
continue
}
ttl := targetTTL(60)
if rrset.TTL != nil {
ttl = targetTTL(*rrset.TTL)
}
cnames = append(cnames, model.CNAMERecord{
ZoneID: zone.ID,
Active: true,
RecordName: normalRecordName(rrset.Name),
FQDN: fqdn(rrset.Name, zone.Name),
Destination: rrset.Records[0].Value,
Description: rrset.Records[0].Comment,
TTL: ttl,
})
continue
}
if rrset.Type != "A" && rrset.Type != "AAAA" {
continue
}
entry := grouped[rrset.Name]
if entry == nil {
entry = &imported{ttl: 60}
grouped[rrset.Name] = entry
}
if rrset.Type == "A" {
entry.ipv4 = true
} else {
entry.ipv6 = true
}
if rrset.TTL != nil && *rrset.TTL >= 60 {
entry.ttl = *rrset.TTL
}
if entry.description == "" && len(rrset.Records) > 0 {
entry.description = rrset.Records[0].Comment
}
}
added := 0
for name, entry := range grouped {
target := model.Target{ZoneID: zone.ID, RecordName: normalRecordName(name), FQDN: fqdn(name, zone.Name), UpdateIPv4: entry.ipv4, UpdateIPv6: entry.ipv6, Description: entry.description, TTL: entry.ttl}
isNew, err := s.store.UpsertImportedTarget(ctx, target)
if err != nil {
return added, err
}
if isNew {
added++
continue
}
if err := s.store.UpdateImportedTargetTTL(ctx, zone.ID, target.RecordName, target.TTL); err != nil {
return added, err
}
}
if err := s.store.ReplaceCNAMERecords(ctx, zone.ID, cnames); err != nil {
return 0, err
}
_ = s.store.MarkZoneSynced(ctx, zone.ID, "")
_ = s.store.AddAudit(ctx, "zone.import", fmt.Sprintf("%s: %d neue A/AAAA-Ziele importiert", zone.Name, added))
return added, nil
}
func (s *Scheduler) SyncTargetComment(ctx context.Context, target model.Target) error {
zone, credential, err := s.store.GetZoneWithCredential(ctx, target.ZoneID)
if err != nil {
return err
}
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return err
}
for _, rrset := range rrsets {
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
continue
}
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || len(rrset.Records) != 1 {
continue
}
if rrset.Records[0].Comment != target.Description {
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, rrset.Type, []hetzner.Record{{Value: rrset.Records[0].Value, Comment: target.Description}}); err != nil {
return err
}
}
}
return nil
}
// SyncTargetTTL applies a changed TTL immediately when the selected A/AAAA
// RRsets already exist. Missing RRsets will receive the same TTL when the
// DynDNS worker creates them on its next run.
func (s *Scheduler) SyncTargetTTL(ctx context.Context, target model.Target) error {
zone, token, err := s.zoneToken(ctx, target.ZoneID)
if err != nil {
return err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return err
}
ttl := targetTTL(target.TTL)
var recordTypes []string
for _, rrset := range rrsets {
if rrset.Name != target.RecordName {
continue
}
if (rrset.Type == "A" && !target.UpdateIPv4) || (rrset.Type == "AAAA" && !target.UpdateIPv6) || (rrset.Type != "A" && rrset.Type != "AAAA") {
continue
}
if rrset.TTL != nil && *rrset.TTL == ttl {
continue
}
if rrset.Protection.Change {
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
}
recordTypes = append(recordTypes, rrset.Type)
}
for _, recordType := range recordTypes {
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, target.RecordName, recordType, ttl); err != nil {
return err
}
}
return nil
}
// SyncStaticTarget writes manually entered addresses while DynDNS is disabled.
// It deliberately reuses the guarded RRSet update path used by the updater:
// protected or multi-record RRSets are left untouched.
func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (bool, string, error) {
zone, token, err := s.zoneToken(ctx, target.ZoneID)
if err != nil {
return false, "", err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return false, "", err
}
byKey := make(map[string]hetzner.RRSet, len(rrsets))
for _, rrset := range rrsets {
if rrset.Type == "A" || rrset.Type == "AAAA" {
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
}
}
updated := false
var states []string
if target.UpdateIPv4 && target.StaticIPv4 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 && target.StaticIPv6 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if len(states) == 0 {
return false, "Keine feste IP eingetragen", nil
}
return updated, strings.Join(states, " · "), nil
}
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
// target itself. This lets users temporarily disable every family without
// losing its configuration.
func (s *Scheduler) DeleteTargetFamilies(ctx context.Context, target model.Target, deleteIPv4, deleteIPv6 bool) (int, error) {
if !deleteIPv4 && !deleteIPv6 {
return 0, nil
}
zone, token, err := s.zoneToken(ctx, target.ZoneID)
if err != nil {
return 0, err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return 0, err
}
var recordTypes []string
for _, rrset := range rrsets {
if rrset.Name != target.RecordName || (rrset.Type != "A" && rrset.Type != "AAAA") {
continue
}
if (rrset.Type == "A" && !deleteIPv4) || (rrset.Type == "AAAA" && !deleteIPv6) {
continue
}
if rrset.Protection.Change {
return 0, errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
}
recordTypes = append(recordTypes, rrset.Type)
}
for _, recordType := range recordTypes {
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, target.RecordName, recordType); err != nil {
return 0, err
}
}
return len(recordTypes), nil
}
// DeleteZoneRecords removes every A, AAAA and CNAME RRSet represented by the
// supplied local records. Other provider records in the zone remain untouched.
// Protected RRsets are rejected before the first remote deletion.
func (s *Scheduler) DeleteZoneRecords(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord) (int, error) {
zone, token, err := s.zoneToken(ctx, zoneID)
if err != nil {
return 0, err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return 0, err
}
managed := make(map[string]struct{}, len(targets)*2+len(cnames))
for _, target := range targets {
managed[rrKey(target.RecordName, "A")] = struct{}{}
managed[rrKey(target.RecordName, "AAAA")] = struct{}{}
}
for _, record := range cnames {
managed[rrKey(record.RecordName, "CNAME")] = struct{}{}
}
deletions := make([]hetzner.RRSet, 0, len(managed))
for _, rrset := range rrsets {
if _, found := managed[rrKey(rrset.Name, rrset.Type)]; !found {
continue
}
if rrset.Protection.Change {
return 0, fmt.Errorf("das RRset %s (%s) ist bei Hetzner gegen Änderungen geschützt", fqdn(rrset.Name, zone.Name), rrset.Type)
}
deletions = append(deletions, rrset)
}
deleted := 0
for _, rrset := range deletions {
if err := s.provider.DeleteRRSet(ctx, token, zone.ProviderID, rrset.Name, rrset.Type); err != nil {
return deleted, err
}
deleted++
}
return deleted, nil
}
// DeleteIPv6Targets removes all managed AAAA RRSets before global IPv6 is
// disabled. All protected RRSets are detected before the first deletion.
func (s *Scheduler) DeleteIPv6Targets(ctx context.Context, targets []model.Target) (int, error) {
type zoneState struct {
zone model.Zone
token string
rrsets map[string]hetzner.RRSet
}
type deletion struct {
zone model.Zone
token string
name string
}
states := make(map[int64]zoneState)
deletions := make([]deletion, 0, len(targets))
for _, target := range targets {
state, found := states[target.ZoneID]
if !found {
zone, token, err := s.zoneToken(ctx, target.ZoneID)
if err != nil {
return 0, err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return 0, err
}
state = zoneState{zone: zone, token: token, rrsets: make(map[string]hetzner.RRSet, len(rrsets))}
for _, rrset := range rrsets {
state.rrsets[rrKey(rrset.Name, rrset.Type)] = rrset
}
states[target.ZoneID] = state
}
rrset, exists := state.rrsets[rrKey(target.RecordName, "AAAA")]
if !exists {
continue
}
if rrset.Protection.Change {
return 0, errors.New("mindestens ein AAAA-Record ist bei Hetzner gegen Änderungen geschützt")
}
deletions = append(deletions, deletion{zone: state.zone, token: state.token, name: target.RecordName})
}
for _, item := range deletions {
if err := s.provider.DeleteRRSet(ctx, item.token, item.zone.ProviderID, item.name, "AAAA"); err != nil {
return 0, err
}
}
return len(deletions), nil
}
// DisableIPv6 removes managed AAAA RRSets and disables IPv6 on every linked
// local target. It is safe to run when IPv6 is already disabled.
func (s *Scheduler) DisableIPv6(ctx context.Context) (int, error) {
targets, err := s.store.ListTargetsWithIPv6(ctx)
if err != nil {
return 0, err
}
deleted, err := s.DeleteIPv6Targets(ctx, targets)
if err != nil {
return 0, err
}
if err := s.store.DisableIPv6ForTargets(ctx); err != nil {
return 0, err
}
return deleted, nil
}
// SyncZoneTTL updates the TTL of every RRSet this application manages in a
// zone. It preflights protected or stale CNAME RRSets before changing any
// remote value, while missing DynDNS RRSets keep the new TTL locally for their
// eventual creation.
func (s *Scheduler) SyncZoneTTL(ctx context.Context, zoneID int64, targets []model.Target, cnames []model.CNAMERecord, ttl int) (int, error) {
zone, token, err := s.zoneToken(ctx, zoneID)
if err != nil {
return 0, err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return 0, err
}
byKey := make(map[string]hetzner.RRSet, len(rrsets))
for _, rrset := range rrsets {
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
}
type change struct {
name string
recordType string
}
changes := make([]change, 0, len(targets)*2+len(cnames))
seen := make(map[string]bool, cap(changes))
queue := func(name, recordType string, required bool) error {
key := rrKey(name, recordType)
rrset, found := byKey[key]
if !found {
if required {
return fmt.Errorf("der CNAME %s wurde bei Hetzner nicht gefunden; lies die Zone erneut ein", fqdn(name, zone.Name))
}
return nil
}
if rrset.TTL != nil && *rrset.TTL == targetTTL(ttl) {
return nil
}
if rrset.Protection.Change {
return errors.New("mindestens ein RRset ist bei Hetzner gegen Änderungen geschützt")
}
if !seen[key] {
seen[key] = true
changes = append(changes, change{name: name, recordType: recordType})
}
return nil
}
for _, target := range targets {
if target.UpdateIPv4 {
if err := queue(target.RecordName, "A", false); err != nil {
return 0, err
}
}
if target.UpdateIPv6 {
if err := queue(target.RecordName, "AAAA", false); err != nil {
return 0, err
}
}
}
for _, record := range cnames {
if !record.Active {
continue
}
if err := queue(record.RecordName, "CNAME", true); err != nil {
return 0, err
}
}
for _, item := range changes {
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, item.name, item.recordType, targetTTL(ttl)); err != nil {
return 0, err
}
}
return len(changes), nil
}
func (s *Scheduler) CreateCNAME(ctx context.Context, record model.CNAMERecord) error {
zone, token, err := s.zoneToken(ctx, record.ZoneID)
if err != nil {
return err
}
return s.provider.CreateRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL), []hetzner.Record{{Value: record.Destination, Comment: record.Description}})
}
func (s *Scheduler) UpdateCNAME(ctx context.Context, previous, record model.CNAMERecord) error {
if previous.ZoneID != record.ZoneID || previous.RecordName != record.RecordName {
return errors.New("der Name eines CNAME-Records kann nicht geändert werden")
}
if previous.Destination == record.Destination && previous.Description == record.Description && previous.TTL == record.TTL {
return nil
}
zone, token, err := s.zoneToken(ctx, record.ZoneID)
if err != nil {
return err
}
rrsets, err := s.provider.ListRRSets(ctx, token, zone.ProviderID)
if err != nil {
return err
}
var current *hetzner.RRSet
for index := range rrsets {
if rrsets[index].Name == record.RecordName && rrsets[index].Type == "CNAME" {
current = &rrsets[index]
break
}
}
if current == nil {
return errors.New("der CNAME-Record wurde bei Hetzner nicht gefunden; lies die Zone erneut ein")
}
if current.Protection.Change {
return errors.New("das RRset ist bei Hetzner gegen Änderungen geschützt")
}
if previous.TTL != record.TTL {
if err := s.provider.ChangeRRSetTTL(ctx, token, zone.ProviderID, record.RecordName, "CNAME", targetTTL(record.TTL)); err != nil {
return err
}
}
if previous.Destination != record.Destination || previous.Description != record.Description {
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, record.RecordName, "CNAME", []hetzner.Record{{Value: record.Destination, Comment: record.Description}}); err != nil {
return err
}
}
return nil
}
func (s *Scheduler) DeleteCNAME(ctx context.Context, record model.CNAMERecord) error {
zone, token, err := s.zoneToken(ctx, record.ZoneID)
if err != nil {
return err
}
return s.provider.DeleteRRSet(ctx, token, zone.ProviderID, record.RecordName, "CNAME")
}
func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, string, error) {
zone, credential, err := s.store.GetZoneWithCredential(ctx, zoneID)
if err != nil {
return model.Zone{}, "", err
}
token, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return model.Zone{}, "", err
}
return zone, token, nil
}
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
for _, endpoint := range endpoints {
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
return fmt.Errorf("%s: %w", endpoint, err)
}
}
return nil
}
func (s *Scheduler) ValidateToken(ctx context.Context, token string) ([]hetzner.Zone, error) {
return s.provider.ListZones(ctx, token)
}
func (s *Scheduler) Provider() *hetzner.Client { return s.provider }
func rrKey(name, recordType string) string { return normalRecordName(name) + "|" + recordType }
func targetTTL(ttl int) int {
if ttl < 60 {
return 60
}
return ttl
}
func normalRecordName(name string) string {
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
if name == "" || name == "@" {
return "@"
}
return name
}
func fqdn(name, zone string) string {
if normalRecordName(name) == "@" {
return zone
}
return normalRecordName(name) + "." + zone
}
func safeError(err error) string {
if err == nil {
return ""
}
message := err.Error()
if len(message) > 300 {
return message[:300]
}
return message
}
+538
View File
@@ -0,0 +1,538 @@
package scheduler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
"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"
)
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
t.Parallel()
ctx := context.Background()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/zones/123/rrsets" || r.Header.Get("Authorization") != "Bearer test-token" {
http.Error(w, "unexpected request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"rrsets": [
{"name":"@","type":"A","ttl":60,"records":[{"value":"198.51.100.10","comment":"Apex"}]},
{"name":"home","type":"A","ttl":120,"records":[{"value":"198.51.100.11","comment":"Router"}]},
{"name":"home","type":"AAAA","ttl":120,"records":[{"value":"2001:db8::10","comment":"Router"}]},
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"example.de."}]}
],
"meta":{"pagination":{"next_page":null}}
}`))
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
added, err := schedule.SyncZone(ctx, zone.ID)
if err != nil || added != 2 {
t.Fatalf("expected apex and home to be imported, added=%d err=%v", added, err)
}
targets, err := database.ListTargets(ctx, zone.ID)
if err != nil || len(targets) != 2 {
t.Fatalf("expected two address targets, got %#v err=%v", targets, err)
}
var homeID int64
for _, target := range targets {
if target.RecordName == "home" {
homeID = target.ID
if !target.UpdateIPv4 || !target.UpdateIPv6 || target.Active || target.Description != "Router" || target.TTL != 120 {
t.Fatalf("home target was not grouped safely: %#v", target)
}
}
}
if homeID == 0 {
t.Fatal("home target missing")
}
cnames, err := database.ListCNAMERecords(ctx, zone.ID)
if err != nil || len(cnames) != 1 || !cnames[0].Active || cnames[0].RecordName != "www" || cnames[0].Destination != "example.de." || cnames[0].TTL != 300 {
t.Fatalf("CNAME was not imported: %#v err=%v", cnames, err)
}
home, err := database.GetTarget(ctx, homeID)
if err != nil {
t.Fatal(err)
}
home.TTL = 60
if err := database.UpdateTarget(ctx, home); err != nil {
t.Fatal(err)
}
added, err = schedule.SyncZone(ctx, zone.ID)
if err != nil || added != 0 {
t.Fatalf("TTL refresh import failed: added=%d err=%v", added, err)
}
home, err = database.GetTarget(ctx, homeID)
if err != nil || home.TTL != 120 {
t.Fatalf("provider TTL was not refreshed on existing target: %#v err=%v", home, err)
}
if err := database.ArchiveTarget(ctx, homeID); err != nil {
t.Fatal(err)
}
added, err = schedule.SyncZone(ctx, zone.ID)
if err != nil || added != 0 {
t.Fatalf("re-import should not restore archived target, added=%d err=%v", added, err)
}
linked, _ := database.ListTargets(ctx, zone.ID)
archived, _ := database.ListArchivedTargets(ctx, zone.ID)
if len(linked) != 1 || len(archived) != 1 || archived[0].ID != homeID {
t.Fatalf("archive state changed during provider import: linked=%#v archived=%#v", linked, archived)
}
}
func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(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() })
settings, err := database.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(ctx, settings.UpdateIntervalSeconds, false, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if _, err := schedule.Run(ctx); !errors.Is(err, ErrDisabled) {
t.Fatalf("disabled updater accepted a run: %v", err)
}
runs, err := database.RecentRuns(ctx, 10)
if err != nil || len(runs) != 0 {
t.Fatalf("disabled run must not create history: %#v err=%v", runs, err)
}
schedule.nextRun.Store(time.Now().Add(time.Hour).Unix())
schedule.Trigger()
if !schedule.NextRunAt().IsZero() {
t.Fatalf("trigger did not reset the visible countdown: %v", schedule.NextRunAt())
}
select {
case <-schedule.wake:
default:
t.Fatal("trigger did not wake the scheduler")
}
}
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
t.Parallel()
schedule := &Scheduler{}
changed, status, err := schedule.syncTarget(
context.Background(),
"unused-token",
model.Zone{ProviderID: "123"},
model.Target{RecordName: "home", UpdateIPv6: true},
nil,
"",
"",
false,
)
if err != nil || changed || status != "AAAA global deaktiviert" {
t.Fatalf("disabled IPv6 was not skipped cleanly: changed=%v status=%q err=%v", changed, status, err)
}
}
func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
t.Parallel()
ctx := context.Background()
changes := make(chan int, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones/123/rrsets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]}],"meta":{"pagination":{"next_page":null}}}`))
case "/zones/123/rrsets/home/A/actions/change_ttl":
var body struct {
TTL int `json:"ttl"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changes <- body.TTL
w.WriteHeader(http.StatusCreated)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err := schedule.SyncTargetTTL(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, TTL: 300}); err != nil {
t.Fatalf("sync target TTL: %v", err)
}
select {
case ttl := <-changes:
if ttl != 300 {
t.Fatalf("unexpected changed TTL: %d", ttl)
}
default:
t.Fatal("TTL action was not called")
}
}
func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
t.Parallel()
ctx := context.Background()
actions := make(chan string, 2)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones/123/rrsets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11","comment":"Old"}]}],"meta":{"pagination":{"next_page":null}}}`))
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
actions <- r.URL.Path
w.WriteHeader(http.StatusCreated)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
changed, status, err := schedule.SyncStaticTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, StaticIPv4: "203.0.113.25", Description: "Manual", TTL: 300})
if err != nil || !changed || status != "A aktualisiert · TTL aktualisiert" {
t.Fatalf("static target was not synchronized: changed=%v status=%q err=%v", changed, status, err)
}
seen := map[string]bool{}
for range 2 {
seen[<-actions] = true
}
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records"} {
if !seen[expected] {
t.Errorf("missing static update action %s", expected)
}
}
}
func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
t.Parallel()
ctx := context.Background()
actions := make(chan string, 2)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones/123/rrsets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]}],"meta":{"pagination":{"next_page":null}}}`))
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
actions <- r.URL.Path
w.WriteHeader(http.StatusCreated)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
changed, err := schedule.SyncZoneTTL(ctx, zone.ID, []model.Target{{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true}}, []model.CNAMERecord{{ZoneID: zone.ID, Active: true, RecordName: "www"}}, 600)
if err != nil || changed != 2 {
t.Fatalf("zone TTL was not synchronized: changed=%d err=%v", changed, err)
}
seen := map[string]bool{}
for range 2 {
seen[<-actions] = true
}
for _, expected := range []string{"/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl"} {
if !seen[expected] {
t.Errorf("missing bulk TTL action %s", expected)
}
}
}
func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
t.Parallel()
ctx := context.Background()
deletions := make(chan string, 2)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones/123/rrsets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]}],"meta":{"pagination":{"next_page":null}}}`))
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA":
if r.Method != http.MethodDelete {
http.Error(w, "unexpected method", http.StatusBadRequest)
return
}
deletions <- r.URL.Path
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
target := model.Target{ZoneID: zone.ID, RecordName: "home", UpdateIPv4: true, UpdateIPv6: true}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
deleted, err := schedule.DeleteTargetFamilies(ctx, target, true, false)
if err != nil || deleted != 1 {
t.Fatalf("A record was not deleted: deleted=%d err=%v", deleted, err)
}
deleted, err = schedule.DeleteIPv6Targets(ctx, []model.Target{target})
if err != nil || deleted != 1 {
t.Fatalf("global AAAA cleanup failed: deleted=%d err=%v", deleted, err)
}
seen := map[string]bool{}
for range 2 {
seen[<-deletions] = true
}
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA"} {
if !seen[expected] {
t.Errorf("missing delete request %s", expected)
}
}
}
func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
t.Parallel()
ctx := context.Background()
deletions := make(chan string, 4)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones/123/rrsets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"rrsets":[
{"name":"home","type":"A","ttl":60,"records":[{"value":"198.51.100.11"}]},
{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::11"}]},
{"name":"legacy","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::12"}]},
{"name":"www","type":"CNAME","ttl":300,"records":[{"value":"home.example.de."}]},
{"name":"mail","type":"CNAME","ttl":300,"records":[{"value":"external.example."}]},
{"name":"@","type":"TXT","ttl":300,"records":[{"value":"unmanaged"}]}
],"meta":{"pagination":{"next_page":null}}}`))
case "/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME":
if r.Method != http.MethodDelete {
http.Error(w, "unexpected method", http.StatusBadRequest)
return
}
deletions <- r.URL.Path
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
keyFile := filepath.Join(dir, "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)
}
ciphertext, nonce, err := cipher.Encrypt("test-token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("test-token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.de")
if err != nil {
t.Fatal(err)
}
schedule := New(database, hetzner.New(api.URL), ipresolve.New(), cipher, slog.New(slog.NewTextHandler(os.Stderr, nil)))
deleted, err := schedule.DeleteZoneRecords(
ctx,
zone.ID,
[]model.Target{{RecordName: "home"}, {RecordName: "legacy"}},
[]model.CNAMERecord{{RecordName: "www"}},
)
if err != nil || deleted != 4 {
t.Fatalf("managed zone records were not deleted: deleted=%d err=%v", deleted, err)
}
seen := map[string]bool{}
for range 4 {
seen[<-deletions] = true
}
for _, expected := range []string{"/zones/123/rrsets/home/A", "/zones/123/rrsets/home/AAAA", "/zones/123/rrsets/legacy/AAAA", "/zones/123/rrsets/www/CNAME"} {
if !seen[expected] {
t.Errorf("missing delete request %s", expected)
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package secure
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
const (
argonMemory = 64 * 1024
argonTime = 3
argonThreads = 2
argonKeyLen = 32
)
type Cipher struct {
key []byte
}
func LoadCipher(path string) (*Cipher, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read master key: %w", err)
}
raw = []byte(strings.TrimSpace(string(raw)))
key, err := base64.RawStdEncoding.DecodeString(string(raw))
if err != nil {
key, err = base64.StdEncoding.DecodeString(string(raw))
}
if err != nil || len(key) != chacha20poly1305.KeySize {
return nil, errors.New("master key must be a base64-encoded 32-byte value")
}
return &Cipher{key: key}, nil
}
func (c *Cipher) Encrypt(plaintext string) (ciphertext, nonce []byte, err error) {
aead, err := chacha20poly1305.NewX(c.key)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, chacha20poly1305.NonceSizeX)
if _, err = rand.Read(nonce); err != nil {
return nil, nil, fmt.Errorf("create encryption nonce: %w", err)
}
return aead.Seal(nil, nonce, []byte(plaintext), nil), nonce, nil
}
func (c *Cipher) Decrypt(ciphertext, nonce []byte) (string, error) {
aead, err := chacha20poly1305.NewX(c.key)
if err != nil {
return "", err
}
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", errors.New("could not decrypt stored credential")
}
return string(plaintext), nil
}
func (c *Cipher) Fingerprint(value string) string {
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func RandomToken(bytes int) (string, error) {
buf := make([]byte, bytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func HashPassword(password string) (string, error) {
if len(password) < 12 {
return "", errors.New("password must contain at least 12 characters")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil
}
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
}
var memory uint32
var iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
+59
View File
@@ -0,0 +1,59 @@
package secure
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
)
func TestCipherRoundTripAndFingerprint(t *testing.T) {
t.Parallel()
dir := t.TempDir()
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
path := filepath.Join(dir, "master-key")
if err := os.WriteFile(path, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
t.Fatal(err)
}
cipher, err := LoadCipher(path)
if err != nil {
t.Fatal(err)
}
ciphertext, nonce, err := cipher.Encrypt("hetzner-token-value")
if err != nil {
t.Fatal(err)
}
if string(ciphertext) == "hetzner-token-value" {
t.Fatal("token was not encrypted")
}
plaintext, err := cipher.Decrypt(ciphertext, nonce)
if err != nil {
t.Fatal(err)
}
if plaintext != "hetzner-token-value" {
t.Fatalf("got %q", plaintext)
}
if cipher.Fingerprint("same") != cipher.Fingerprint("same") || cipher.Fingerprint("same") == cipher.Fingerprint("different") {
t.Fatal("fingerprint must be deterministic and token-specific")
}
}
func TestPasswordHash(t *testing.T) {
t.Parallel()
hash, err := HashPassword("a secure password")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword(hash, "a secure password") {
t.Fatal("expected password verification to succeed")
}
if VerifyPassword(hash, "wrong password") {
t.Fatal("wrong password verified")
}
if _, err := HashPassword("short"); err == nil {
t.Fatal("short password was accepted")
}
}
File diff suppressed because it is too large. Load diff
+294
View File
@@ -0,0 +1,294 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
)
func TestUserAccessCanBeScopedToTargetOrDomain(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 password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", hash); err != nil {
t.Fatal(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"), "fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "10", "example.de")
if err != nil {
t.Fatal(err)
}
home, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, Active: true, StaticIPv4: "203.0.113.10", TTL: 60})
if err != nil {
t.Fatal(err)
}
if home.StaticIPv4 != "203.0.113.10" {
t.Fatalf("static IPv4 was not persisted: %#v", home)
}
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "other", FQDN: "other.example.de", UpdateIPv4: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
t.Fatal(err)
}
allowed, err := database.CanAccessTarget(ctx, member.ID, zone.ID, home.ID)
if err != nil || !allowed {
t.Fatalf("target-specific grant was not honored: allowed=%v err=%v", allowed, err)
}
allowed, err = database.CanAccessTarget(ctx, member.ID, zone.ID, other.ID)
if err != nil || allowed {
t.Fatalf("ungranted target was visible: allowed=%v err=%v", allowed, err)
}
managed, err := database.CanManageTarget(ctx, member.ID, zone.ID, home.ID)
if err != nil || !managed {
t.Fatalf("target management grant was not honored: managed=%v err=%v", managed, err)
}
targets, err := database.ListAccessibleTargets(ctx, member.ID, zone.ID)
if err != nil || len(targets) != 1 || targets[0].ID != home.ID {
t.Fatalf("expected one scoped target, got %#v (err=%v)", targets, err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{ZoneIDs: []int64{zone.ID}, ManageZoneIDs: []int64{zone.ID}, CreateTargetZoneIDs: []int64{zone.ID}}); err != nil {
t.Fatal(err)
}
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
if err != nil || len(targets) != 2 {
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
}
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
if err != nil || !canCreate {
t.Fatalf("domain create grant was not honored: canCreate=%v err=%v", canCreate, err)
}
if err := database.ArchiveTarget(ctx, home.ID); err != nil {
t.Fatal(err)
}
linked, err := database.ListTargets(ctx, zone.ID)
if err != nil || len(linked) != 1 || linked[0].ID != other.ID {
t.Fatalf("archived target remained linked: %#v, %v", linked, err)
}
archived, err := database.ListArchivedTargets(ctx, zone.ID)
if err != nil || len(archived) != 1 || archived[0].ID != home.ID || archived[0].ArchivedAt.IsZero() {
t.Fatalf("archived target was not retained: %#v, %v", archived, err)
}
imported, err := database.UpsertImportedTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, TTL: 60})
if err != nil || imported {
t.Fatalf("provider re-import unexpectedly restored an archived target: imported=%v err=%v", imported, err)
}
if err := database.RestoreTarget(ctx, home.ID); err != nil {
t.Fatal(err)
}
restored, err := database.GetTarget(ctx, home.ID)
if err != nil || !restored.ArchivedAt.IsZero() || restored.Active {
t.Fatalf("target was not safely restored as inactive: %#v, %v", restored, err)
}
}
func TestInactiveUserSessionIsRejected(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, _ := secure.HashPassword("a secure password")
user, err := database.CreateUser(ctx, "member", "Member", hash, "member")
if err != nil {
t.Fatal(err)
}
session, err := database.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: "token", CSRFToken: "csrf", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
loaded, err := database.GetSession(ctx, session.TokenHash)
if err != nil || loaded == nil || loaded.Username != "member" {
t.Fatalf("session was not resolved with user: %#v, %v", loaded, err)
}
if err := database.UpdateUserState(ctx, user.ID, "Member", "member", false); err != nil {
t.Fatal(err)
}
loaded, err = database.GetSession(ctx, session.TokenHash)
if err != nil || loaded != nil {
t.Fatalf("inactive user session was accepted: %#v, %v", loaded, err)
}
}
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(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() })
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "ipv6-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "40", "example.de")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, StaticIPv6: "2001:db8::10", TTL: 60})
if err != nil {
t.Fatal(err)
}
targets, err := database.ListTargetsWithIPv6(ctx)
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.DisableIPv6ForTargets(ctx); err != nil {
t.Fatal(err)
}
target, err = database.GetTarget(ctx, target.ID)
if err != nil || target.UpdateIPv6 || target.StaticIPv6 != "" {
t.Fatalf("IPv6 target was not disabled cleanly: %#v err=%v", target, err)
}
if err := database.DeleteTarget(ctx, target.ID); err != nil {
t.Fatal(err)
}
if _, err := database.GetTarget(ctx, target.ID); err == nil {
t.Fatal("deleted target was still found")
}
}
func TestUpdaterSettingsAndErrorsCanBeReset(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() })
settings, err := database.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if !settings.UpdaterEnabled || !settings.IPv6Enabled {
t.Fatalf("new installations must enable updater and IPv6 by default: %#v", settings)
}
if err := database.UpdateSettings(ctx, 600, false, false, []string{"https://v4.example"}, []string{"https://v6.example"}); err != nil {
t.Fatal(err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.UpdaterEnabled || settings.IPv6Enabled || settings.UpdateIntervalSeconds != 600 {
t.Fatalf("updater settings were not persisted: %#v err=%v", settings, err)
}
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "error-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "20", "errors.example")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.errors.example", UpdateIPv4: true, Active: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateTargetResult(ctx, target.ID, "Fehler", "provider rejected update", false); err != nil {
t.Fatal(err)
}
if err := database.MarkZoneSynced(ctx, zone.ID, "zone unavailable"); err != nil {
t.Fatal(err)
}
runID, err := database.CreateUpdateRun(ctx)
if err != nil {
t.Fatal(err)
}
if err := database.FinishUpdateRun(ctx, runID, "partial", "198.51.100.1", "", "one error"); err != nil {
t.Fatal(err)
}
if err := database.UpdateRunState(ctx, "198.51.100.1", "", "partial"); err != nil {
t.Fatal(err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 3 {
t.Fatalf("unexpected error count: count=%d err=%v", count, err)
}
if err := database.ClearErrors(ctx); err != nil {
t.Fatal(err)
}
if count, err := database.CountErrors(ctx); err != nil || count != 0 {
t.Fatalf("errors were not cleared: count=%d err=%v", count, err)
}
clearedTarget, err := database.GetTarget(ctx, target.ID)
if err != nil || clearedTarget.LastError != "" || clearedTarget.LastStatus != "Zurückgesetzt" {
t.Fatalf("target error was not reset: %#v err=%v", clearedTarget, err)
}
settings, err = database.GetSettings(ctx)
if err != nil || settings.LastRunStatus != "" {
t.Fatalf("last run error state was not reset: %#v err=%v", settings, err)
}
}
func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(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() })
credential, err := database.CreateCredential(ctx, "Hetzner", []byte("ciphertext"), []byte("nonce"), "cname-fingerprint")
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "30", "example.de")
if err != nil {
t.Fatal(err)
}
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "www", FQDN: "www.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
if err != nil {
t.Fatal(err)
}
created.Destination = "edge.example.net."
created.TTL = 600
if err := database.UpdateCNAMERecord(ctx, created); err != nil {
t.Fatal(err)
}
updated, err := database.GetCNAMERecord(ctx, created.ID)
if err != nil || !updated.Active || updated.Destination != "edge.example.net." || updated.TTL != 600 {
t.Fatalf("CNAME update not persisted: %#v, %v", updated, err)
}
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "cdn", FQDN: "cdn.example.de", Destination: "assets.example.net.", TTL: 120}}); err != nil {
t.Fatal(err)
}
records, err := database.ListCNAMERecords(ctx, zone.ID)
if err != nil || len(records) != 2 {
t.Fatalf("provider CNAME import was not mirrored: %#v, %v", records, err)
}
byName := make(map[string]model.CNAMERecord, len(records))
for _, record := range records {
byName[record.RecordName] = record
}
if byName["www"].Active || !byName["cdn"].Active || byName["cdn"].Destination != "assets.example.net." || byName["cdn"].TTL != 120 {
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
}
}
File diff suppressed because it is too large. Load diff
+216
View File
@@ -0,0 +1,216 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
)
func TestInputNormalizers(t *testing.T) {
t.Parallel()
username, err := normalizeUsername("Alice_Admin")
if err != nil || username != "alice_admin" {
t.Fatalf("username normalization failed: %q, %v", username, err)
}
if _, err := normalizeUsername("not allowed!"); err == nil {
t.Fatal("invalid username accepted")
}
zone, err := normalizeZone("BÜCHER.example.")
if err != nil || zone != "xn--bcher-kva.example" {
t.Fatalf("zone normalization failed: %q, %v", zone, err)
}
record, fqdn, err := normalizeRecord("home", "example.de")
if err != nil || record != "home" || fqdn != "home.example.de" {
t.Fatalf("record normalization failed: %q %q %v", record, fqdn, err)
}
destination, err := normalizeCNAMEDestination("BÜCHER.example.")
if err != nil || destination != "xn--bcher-kva.example." {
t.Fatalf("CNAME destination normalization failed: %q %v", destination, err)
}
if _, err := normalizeCNAMEDestination("198.51.100.20"); err == nil {
t.Fatal("IP address accepted as CNAME destination")
}
if ttl, err := parseTTL("300"); err != nil || ttl != 300 {
t.Fatalf("TTL parsing failed: %d %v", ttl, err)
}
if _, err := parseTTL("120"); err == nil {
t.Fatal("unsupported TTL accepted")
}
if ttl, err := parseEditableTTL("120", 120); err != nil || ttl != 120 {
t.Fatalf("existing unsupported TTL could not be preserved: %d %v", ttl, err)
}
staticIPv4, err := normalizeStaticIP("203.0.113.10", 4)
if err != nil || staticIPv4 != "203.0.113.10" {
t.Fatalf("static IPv4 normalization failed: %q %v", staticIPv4, err)
}
if _, err := normalizeStaticIP("203.0.113.10", 6); err == nil {
t.Fatal("IPv4 accepted as static IPv6")
}
missingStaticIP := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4="))
missingStaticIP.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if _, _, err := parseStaticTargetIPs(missingStaticIP, true, false); err == nil {
t.Fatal("inactive DynDNS target accepted enabled IPv4 without a static address")
}
staticAddresses := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=203.0.113.20&static_ipv6=2001%3Adb8%3A%3A20"))
staticAddresses.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ipv4, ipv6, err := parseStaticTargetIPs(staticAddresses, true, true)
if err != nil || ipv4 != "203.0.113.20" || ipv6 != "2001:db8::20" {
t.Fatalf("valid static addresses rejected: ipv4=%q ipv6=%q err=%v", ipv4, ipv6, err)
}
if targetDynDNSActive(true, false, false) {
t.Fatal("DynDNS remained active without an address family")
}
if !targetDynDNSActive(true, true, false) {
t.Fatal("IPv4-only DynDNS was rejected")
}
}
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
t.Parallel()
if got := unixSeconds(time.Time{}); got != 0 {
t.Fatalf("zero time must be exposed as unix 0, got %d", got)
}
value := time.Date(2026, time.July, 11, 12, 30, 0, 0, time.UTC)
if got := unixSeconds(value); got != value.Unix() {
t.Fatalf("unexpected unix time: got %d, want %d", got, value.Unix())
}
}
func TestNoticeUsesValidatedKind(t *testing.T) {
t.Parallel()
request := httptest.NewRequest(http.MethodGet, "/login?notice=Passwort+nicht+korrekt.&notice_kind=error", nil)
got := notice(request)
if got == nil || got.Message != "Passwort nicht korrekt." || got.Kind != "error" {
t.Fatalf("unexpected notice: %#v", got)
}
request = httptest.NewRequest(http.MethodGet, "/login?notice=Hinweis&notice_kind=not-a-valid-kind", nil)
got = notice(request)
if got == nil || got.Kind != "info" {
t.Fatalf("invalid kind must fall back to info: %#v", got)
}
if got := notice(httptest.NewRequest(http.MethodGet, "/login", nil)); got != nil {
t.Fatalf("empty notice must not render a toast: %#v", got)
}
}
func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
t.Parallel()
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/settings", nil)
server := &Server{}
server.redirectNotice(response, request, "/domains?view=active", "Gespeichert.")
if response.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: got %d, want %d", response.Code, http.StatusSeeOther)
}
location, err := response.Result().Location()
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if got := location.Query().Get("view"); got != "active" {
t.Fatalf("existing query lost: %q", got)
}
if got := location.Query().Get("notice"); got != "Gespeichert." {
t.Fatalf("unexpected notice: %q", got)
}
if got := location.Query().Get("notice_kind"); got != "success" {
t.Fatalf("unexpected notice kind: %q", got)
}
}
func TestLoginErrorRendersAsToast(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "Eingabe prüfen",
Page: "login",
Flash: newFlash("Passwort nicht korrekt.", "error"),
}
if err := templates["login"].ExecuteTemplate(&rendered, "login", data); err != nil {
t.Fatalf("render login: %v", err)
}
body := rendered.String()
for _, expected := range []string{`class="toast toast-error"`, `data-toast`, `role="alert"`, `Passwort nicht korrekt.`} {
if !strings.Contains(body, expected) {
t.Errorf("rendered login is missing %q", expected)
}
}
if strings.Contains(body, `class="flash"`) {
t.Error("legacy in-flow flash message is still rendered")
}
}
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "example.de",
Page: "domains",
CSRF: "csrf",
IsAdmin: true,
Zone: model.Zone{ID: 1, Name: "example.de"},
Settings: model.Settings{IPv6Enabled: true},
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}},
}
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
t.Fatalf("render domain detail: %v", err)
}
body := rendered.String()
statusAt := strings.Index(body, `A unverändert`)
timeAt := strings.Index(body, `class="target-meta"><time>`)
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"`, `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)
}
}
}
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "example.de",
Page: "domains",
CSRF: "csrf",
IsAdmin: true,
Zone: model.Zone{ID: 1, Name: "example.de"},
Settings: model.Settings{IPv6Enabled: false},
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, TTL: 60}},
}
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
t.Fatalf("render domain detail: %v", err)
}
body := rendered.String()
for _, expected := range []string{`data-ipv6-enabled="false"`, `IPv6 / AAAA pausiert`, `data-static-ipv6`} {
if !strings.Contains(body, expected) {
t.Errorf("globally-disabled IPv6 UI is missing %q", expected)
}
}
inputAt := strings.Index(body, `name="update_ipv6"`)
if inputAt < 0 || !strings.Contains(body[inputAt:inputAt+min(100, len(body)-inputAt)], "disabled") {
t.Error("globally-disabled IPv6 checkbox remained enabled")
}
}
+252
View File
@@ -0,0 +1,252 @@
(() => {
const toast = document.querySelector('[data-toast]');
if (toast) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const region = toast.closest('.toast-region');
const duration = toast.classList.contains('toast-error') ? 6500 : 4800;
let remaining = duration;
let startedAt = 0;
let dismissTimer;
let timerRunning = false;
const dismiss = () => {
if (!toast.isConnected || toast.classList.contains('is-leaving')) return;
window.clearTimeout(dismissTimer);
timerRunning = false;
toast.classList.add('is-leaving');
if (reducedMotion) {
region?.remove();
return;
}
window.setTimeout(() => region?.remove(), 220);
};
const resume = () => {
if (timerRunning || toast.classList.contains('is-leaving')) return;
toast.classList.remove('is-paused');
startedAt = performance.now();
timerRunning = true;
dismissTimer = window.setTimeout(dismiss, remaining);
};
const pause = () => {
if (!timerRunning || toast.classList.contains('is-leaving')) return;
remaining = Math.max(0, remaining - (performance.now() - startedAt));
window.clearTimeout(dismissTimer);
timerRunning = false;
toast.classList.add('is-paused');
};
toast.querySelector('[data-toast-close]')?.addEventListener('click', dismiss);
toast.addEventListener('pointerenter', pause);
toast.addEventListener('pointerleave', () => {
if (!toast.contains(document.activeElement)) resume();
});
toast.addEventListener('focusin', pause);
toast.addEventListener('focusout', () => {
window.requestAnimationFrame(() => {
if (!toast.matches(':hover') && !toast.contains(document.activeElement)) resume();
});
});
resume();
}
const currentURL = new URL(window.location.href);
if (currentURL.searchParams.has('notice')) {
currentURL.searchParams.delete('notice');
currentURL.searchParams.delete('notice_kind');
const cleanLocation = `${currentURL.pathname}${currentURL.search}${currentURL.hash}`;
window.history.replaceState(window.history.state, '', cleanLocation);
}
const settingsForm = document.querySelector('[data-settings-form]');
if (settingsForm) {
const updaterToggle = settingsForm.querySelector('[data-updater-toggle]');
const ipv6Toggle = settingsForm.querySelector('[data-ipv6-toggle]');
const ipv6Settings = settingsForm.querySelector('[data-ipv6-settings]');
const syncToggleCard = (input) => {
if (!input) return;
const card = input.closest('.setting-switch-card');
card?.classList.toggle('is-enabled', input.checked);
const label = input.closest('.setting-switch')?.querySelector('b');
if (label) label.textContent = input.checked ? 'Aktiv' : 'Inaktiv';
};
const syncIPv6Settings = () => {
if (!ipv6Toggle || !ipv6Settings) return;
ipv6Settings.classList.toggle('is-disabled', !ipv6Toggle.checked);
ipv6Settings.setAttribute('aria-disabled', String(!ipv6Toggle.checked));
const textarea = ipv6Settings.querySelector('textarea');
if (textarea) textarea.required = ipv6Toggle.checked;
};
updaterToggle?.addEventListener('change', () => syncToggleCard(updaterToggle));
ipv6Toggle?.addEventListener('change', () => {
syncToggleCard(ipv6Toggle);
syncIPv6Settings();
});
syncToggleCard(updaterToggle);
syncToggleCard(ipv6Toggle);
syncIPv6Settings();
}
for (const form of document.querySelectorAll('[data-target-form]')) {
const active = form.querySelector('[data-target-active]');
const staticFields = form.querySelector('[data-static-ip-fields]');
const ipv4Toggle = form.querySelector('[name="update_ipv4"]');
const ipv6Toggle = form.querySelector('[name="update_ipv6"]');
const staticIPv4 = form.querySelector('[data-static-ipv4]');
const staticIPv6 = form.querySelector('[data-static-ipv6]');
const requirement = form.querySelector('[data-target-requirement]');
const submit = form.querySelector('button[type="submit"]');
const ipv6Enabled = form.dataset.ipv6Enabled !== 'false';
const ipv4Selected = () => ipv4Toggle ? ipv4Toggle.checked : form.dataset.updateIpv4 === 'true';
const ipv6Selected = () => ipv6Enabled && (ipv6Toggle ? ipv6Toggle.checked : form.dataset.updateIpv6 === 'true');
const syncStaticFields = () => {
if (!active || !staticFields) return;
const useIPv4 = ipv4Selected();
const useIPv6 = ipv6Selected();
const hasFamily = useIPv4 || useIPv6;
if (active.checked && !hasFamily) active.checked = false;
active.disabled = !hasFamily;
active.closest('.switch')?.classList.toggle('switch-disabled', !hasFamily);
const useStatic = !active.checked && hasFamily;
staticFields.hidden = !useStatic;
staticFields.setAttribute('aria-hidden', String(!useStatic));
if (staticIPv4) {
staticIPv4.disabled = !useStatic || !useIPv4;
staticIPv4.required = useStatic && useIPv4;
}
if (staticIPv6) {
staticIPv6.disabled = !useStatic || !useIPv6;
staticIPv6.required = useStatic && useIPv6;
}
const missingStaticIP = useStatic && (
(useIPv4 && !staticIPv4?.value.trim()) ||
(useIPv6 && !staticIPv6?.value.trim())
);
if (submit) submit.disabled = missingStaticIP;
if (requirement) requirement.hidden = !missingStaticIP;
};
active?.addEventListener('change', syncStaticFields);
for (const familyToggle of [ipv4Toggle, ipv6Toggle]) {
familyToggle?.addEventListener('change', () => {
if (!familyToggle.checked && active) active.checked = false;
syncStaticFields();
});
}
staticIPv4?.addEventListener('input', syncStaticFields);
staticIPv6?.addEventListener('input', syncStaticFields);
syncStaticFields();
}
for (const zone of document.querySelectorAll('[data-access-zone]')) {
const access = zone.querySelector('[data-zone-access]');
const manage = zone.querySelector('[data-zone-manage]');
const create = zone.querySelector('[data-zone-create]');
access?.addEventListener('change', () => {
if (!access.checked) {
if (manage) manage.checked = false;
if (create) create.checked = false;
}
});
manage?.addEventListener('change', () => {
if (manage.checked && access) access.checked = true;
if (!manage.checked && create) create.checked = false;
});
create?.addEventListener('change', () => {
if (create.checked) {
if (access) access.checked = true;
if (manage) manage.checked = true;
}
});
}
for (const target of document.querySelectorAll('[data-access-target]')) {
const access = target.querySelector('[data-target-access]');
const manage = target.querySelector('[data-target-manage]');
access?.addEventListener('change', () => {
if (!access.checked && manage) manage.checked = false;
});
manage?.addEventListener('change', () => {
if (manage.checked && access) access.checked = true;
});
}
const copyButtons = document.querySelectorAll('[data-copy-target]');
for (const button of copyButtons) {
button.addEventListener('click', async () => {
const target = document.getElementById(button.dataset.copyTarget);
if (!target) return;
try {
await navigator.clipboard.writeText(target.textContent);
const original = button.textContent;
button.textContent = 'Kopiert';
window.setTimeout(() => { button.textContent = original; }, 2000);
} catch {
button.textContent = 'Manuell kopieren';
}
});
}
const countdowns = [...document.querySelectorAll('[data-countdown]')];
if (countdowns.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) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
};
const refreshSchedule = async () => {
const now = Date.now();
if (now - lastRefresh < 2500) return;
lastRefresh = now;
try {
const response = await fetch('/api/v1/scheduler', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
if (!response.ok) return;
const state = await response.json();
nextRun = Number(state.next_run_unix || 0);
running = Boolean(state.running);
enabled = state.enabled !== false;
} catch {
// The visible countdown keeps its last known state when the status request fails.
}
};
const tick = () => {
if (!enabled) {
for (const node of countdowns) {
node.textContent = 'Inaktiv';
node.setAttribute('aria-label', 'Automatischer Updater ist deaktiviert');
}
return;
}
const now = Math.floor(Date.now() / 1000);
const remaining = Math.max(0, nextRun - now);
const label = running ? 'Update läuft' : (nextRun > 0 ? formatRemaining(remaining) : '--:--');
for (const node of countdowns) {
node.textContent = label;
node.setAttribute('aria-label', running ? 'Update läuft' : `Nächste Aktualisierung in ${remaining} Sekunden`);
}
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
};
tick();
refreshSchedule();
window.setInterval(tick, 1000);
window.setInterval(refreshSchedule, 30000);
})();
+396
View File
@@ -0,0 +1,396 @@
:root {
--bg: #f3f5f7;
--surface: #ffffff;
--surface-subtle: #f8fafc;
--sidebar: #101827;
--sidebar-hover: #1b2739;
--text: #182230;
--muted: #647386;
--line: #dce2e8;
--line-strong: #c8d1db;
--accent: #2563eb;
--accent-dark: #1d4ed8;
--accent-soft: #eaf1ff;
--success: #0f766e;
--success-soft: #e5f7f3;
--warning: #9a6700;
--warning-soft: #fff6d8;
--danger: #b4233c;
--danger-soft: #fff0f2;
--radius: 10px;
--radius-sm: 7px;
--shadow: 0 1px 2px rgba(16, 24, 39, .04), 0 8px 24px rgba(16, 24, 39, .035);
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
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; }
button, summary { cursor: pointer; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }
h1, h2, h3, p, dl, dd { margin-top: 0; }
h1 { margin-bottom: 6px; font-size: clamp(25px, 3vw, 31px); line-height: 1.15; letter-spacing: -.035em; }
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; letter-spacing: -.015em; }
h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.skip-link { position: fixed; top: -50px; left: 12px; z-index: 100; padding: 8px 12px; border-radius: 6px; background: #fff; color: var(--text); box-shadow: var(--shadow); }
.skip-link:focus { top: 12px; }
.admin-layout { display: grid; min-height: 100vh; grid-template-columns: 232px minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; padding: 18px 14px 14px; background: var(--sidebar); color: #d7deea; }
.brand { display: flex; align-items: center; gap: 10px; padding: 3px 5px 17px; color: #fff; }
.brand:hover { color: #fff; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #41506a; border-radius: 8px; background: #192438; color: #8eb3ff; font-family: var(--mono); font-size: 11px; font-weight: 800; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 13px; line-height: 1.2; }
.brand small { margin-top: 2px; color: #7f8ca1; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
.instance-state { display: flex; align-items: center; gap: 7px; margin: 0 5px 17px; padding: 7px 9px; border: 1px solid #26344a; border-radius: 6px; color: #9eabc0; font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
.instance-state span { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52, 211, 153, .12); }
.instance-state.is-inactive { border-color: #3a3540; color: #958b98; }
.instance-state.is-inactive span { background: #7d8796; box-shadow: 0 0 0 3px rgba(125,135,150,.12); }
.sidebar nav { display: grid; gap: 3px; }
.sidebar nav a { display: flex; min-height: 38px; align-items: center; gap: 11px; padding: 0 10px; border-radius: 6px; color: #9eabc0; font-size: 12px; font-weight: 650; }
.sidebar nav a:hover { background: var(--sidebar-hover); color: #fff; }
.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; }
.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 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; }
.workspace { min-width: 0; }
.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; }
.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%); }
.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; }
.page-kicker, .section-label, .metric-label { display: block; margin-bottom: 5px; color: var(--muted); font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.back-link { display: inline-block; margin-bottom: 12px; color: var(--muted); font-size: 11px; font-weight: 650; }
.back-link:hover { color: var(--accent); }
.toast-region { position: fixed; z-index: 1000; bottom: max(18px, env(safe-area-inset-bottom)); left: 50%; width: min(460px, calc(100% - 28px)); pointer-events: none; transform: translateX(-50%); }
.toast { --toast-tone: #60a5fa; --toast-tone-soft: rgba(96, 165, 250, .14); position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) 30px; gap: 11px; align-items: center; min-height: 62px; overflow: hidden; padding: 11px 10px 11px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: rgba(16, 24, 39, .96); color: #f8fafc; box-shadow: 0 20px 45px rgba(15, 23, 42, .24), 0 4px 12px rgba(15, 23, 42, .14); backdrop-filter: blur(16px); pointer-events: auto; animation: toast-in .42s cubic-bezier(.21, 1, .36, 1) both; }
.toast::before { position: absolute; inset: 0 0 auto; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.28), transparent); content: ""; }
.toast-success { --toast-tone: #34d399; --toast-tone-soft: rgba(52, 211, 153, .14); }
.toast-error { --toast-tone: #fb7185; --toast-tone-soft: rgba(251, 113, 133, .15); }
.toast-icon { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--toast-tone) 35%, transparent); border-radius: 10px; background: var(--toast-tone-soft); color: var(--toast-tone); font-family: var(--mono); font-size: 13px; font-weight: 900; }
.toast-copy { min-width: 0; }
.toast-eyebrow { display: block; margin-bottom: 2px; color: var(--toast-tone); font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.toast-copy p { margin: 0; color: #eef2f7; font-size: 12px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
.toast-close { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #8e9caf; font-size: 19px; line-height: 1; transition: background .14s, color .14s; }
.toast-close:hover { background: rgba(255,255,255,.08); color: #fff; }
.toast-close:focus-visible { outline: 3px solid rgba(96, 165, 250, .32); outline-offset: 1px; }
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--toast-tone); transform-origin: left; animation: toast-progress 4.8s linear both; }
.toast-error .toast-progress { animation-duration: 6.5s; }
.toast.is-paused .toast-progress { animation-play-state: paused; }
.toast.is-leaving { animation: toast-out .2s ease-in both; }
@keyframes toast-in { from { opacity: 0; transform: translateY(18px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(10px) scale(.98); } }
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.button { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; gap: 7px; padding: 0 13px; border: 1px solid transparent; border-radius: 6px; background: var(--surface-subtle); color: var(--text); font-size: 12px; font-weight: 700; line-height: 1; transition: border-color .14s, background .14s, box-shadow .14s; }
.button:hover { color: var(--text); box-shadow: 0 2px 6px rgba(16,24,39,.08); }
.button:disabled { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; cursor: not-allowed; }
.button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid rgba(37, 99, 235, .22); outline-offset: 2px; }
.button-primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.button-primary:hover { border-color: var(--accent-dark); background: var(--accent-dark); color: #fff; }
.button-quiet { border-color: var(--line-strong); background: #fff; }
.button-danger, .button-danger-soft { border-color: #efb8c2; background: var(--danger-soft); color: var(--danger); }
.button-danger:hover, .button-danger-soft:hover { border-color: #dc8c9b; background: #ffe4e8; color: #961b31; }
.text-danger { color: var(--danger); font-size: 12px; font-weight: 700; }
.metric-grid { display: grid; grid-template-columns: .72fr 1.15fr 1.15fr .9fr; gap: 10px; margin-bottom: 12px; }
.metric-card { display: flex; min-height: 122px; flex-direction: column; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.metric-card strong { margin-top: auto; overflow: hidden; color: var(--text); font-size: 26px; line-height: 1.15; letter-spacing: -.04em; text-overflow: ellipsis; white-space: nowrap; }
.metric-card small { margin-top: 5px; color: var(--muted); font-size: 10px; }
.metric-card .address-value { font-size: clamp(12px, 1.45vw, 18px); letter-spacing: -.035em; }
.metric-card .address-value.compact { font-size: clamp(10px, 1.12vw, 14px); }
.metric-card-disabled { border-color: #d7dde4; background: #f1f3f6; box-shadow: none; }
.metric-card-disabled strong { color: #788596; }
.countdown-card { border-top: 3px solid var(--accent); padding-top: 13px; }
.countdown-card.metric-card-disabled { border-top-color: #9aa5b2; }
.countdown { font-size: 28px !important; font-variant-numeric: tabular-nums; }
.mono { font-family: var(--mono); }
.panel, .form-panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.panel { padding: 17px; }
.form-panel { padding: 22px; }
.compact-panel { max-width: 930px; }
.panel-heading, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.panel-heading a { color: var(--accent); font-size: 11px; font-weight: 700; }
.panel-links, .heading-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.panel-links { justify-content: flex-end; }
.panel-description { max-width: 760px; margin: -4px 0 15px; color: var(--muted); font-size: 12px; }
.panel-divider { height: 1px; margin: 18px 0; background: var(--line); }
.dashboard-grid, .split-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 12px; margin-bottom: 12px; }
.split-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.data-list { display: grid; }
.data-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
.data-row:first-child { border-top: 0; }
.data-row strong, .data-row small { display: block; }
.data-row strong { font-size: 12px; font-weight: 650; }
.data-row small { margin-top: 2px; color: var(--muted); font-size: 10px; }
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #a8b2bf; }
.state-dot.status-good { background: var(--success); }
.state-dot.status-warn { background: var(--warning); }
.state-dot.status-bad { background: var(--danger); }
.status-pill { display: inline-flex; width: fit-content; min-height: 22px; align-items: center; padding: 0 7px; border-radius: 99px; background: #eef1f4; color: #556476; font-size: 9px; font-weight: 800; white-space: nowrap; }
.status-good { color: var(--success) !important; }
.status-warn { color: var(--warning) !important; }
.status-bad { color: var(--danger) !important; }
.status-pill.status-good { background: var(--success-soft); }
.status-pill.status-warn { background: var(--warning-soft); }
.status-pill.status-bad { background: var(--danger-soft); }
.domain-list { display: grid; }
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
.domain-list > a:first-child { border-top: 0; }
.domain-list strong, .domain-list small { display: block; }
.domain-list strong { font-size: 12px; }
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.audit-panel { margin-top: 12px; }
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
.audit-list li:first-child { border-top: 0; }
.audit-list time, .audit-list code { color: var(--muted); font-family: var(--mono); font-size: 9px; }
.audit-list strong { font-size: 11px; font-weight: 600; }
.domain-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.zone-card:hover { border-color: #b6c4d4; }
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
.zone-card dt, .zone-card dd { color: var(--muted); font-size: 10px; }
.zone-card dd { color: var(--text); text-align: right; }
.inline-error { margin-bottom: 12px; color: var(--danger); font-size: 10px; }
.card-link { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); color: var(--accent); font-size: 11px; font-weight: 750; }
.empty-board { grid-column: 1 / -1; padding: 54px 20px; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: rgba(255,255,255,.55); text-align: center; }
.empty-board .empty-icon { width: 46px; height: 38px; margin: 0 auto 13px; }
.empty-board h2 { margin-bottom: 5px; }
.empty-board p { max-width: 560px; margin: 0 auto 16px; color: var(--muted); font-size: 12px; }
.empty-state { padding: 32px 12px; color: var(--muted); font-size: 11px; text-align: center; }
.empty-state a { color: var(--accent); font-weight: 700; }
.zone-summary { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.zone-summary div { padding: 12px 14px; border-left: 1px solid var(--line); }
.zone-summary div:first-child { border-left: 0; }
.zone-summary span, .zone-summary strong { display: block; }
.zone-summary span { color: var(--muted); font-family: var(--mono); font-size: 8px; text-transform: uppercase; }
.zone-summary strong { margin-top: 4px; font-size: 11px; }
.bulk-ttl-panel { margin: -10px 0 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-subtle); }
.bulk-ttl-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 190px) auto; gap: 12px; align-items: end; }
.bulk-ttl-form > div { display: grid; gap: 3px; }
.bulk-ttl-form strong { font-size: 11px; }
.bulk-ttl-form small { color: var(--muted); font-size: 9px; }
.targets-section { margin-bottom: 18px; }
.record-count { color: var(--muted); font-family: var(--mono); font-size: 10px; }
.target-list { display: grid; gap: 7px; }
.target-row { display: grid; grid-template-columns: minmax(0,1fr) auto; overflow: hidden; border: 1px solid var(--line); border-left: 3px solid #b9c3ce; border-radius: var(--radius-sm); background: var(--surface); }
.target-row.is-active { border-left-color: var(--success); }
.target-main { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 12px 14px; }
.target-main > div { min-width: 0; }
.target-status { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #aeb8c3; }
.target-status.on { background: var(--success); box-shadow: 0 0 0 3px rgba(15,118,110,.1); }
.target-main h3 { overflow-wrap: anywhere; }
.target-main p { display: flex; gap: 7px; align-items: center; overflow: hidden; margin: 0; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.family-chip { display: inline-flex; flex: none; padding: 2px 5px; border-radius: 4px; background: #e9eef4; color: #45576b; font-family: var(--mono); font-size: 8px; font-weight: 800; }
.target-meta { display: flex; min-width: 210px; align-items: center; justify-content: flex-end; gap: 10px; padding: 12px 14px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
.target-edit { grid-column: 1 / -1; border-top: 1px solid var(--line); }
.target-edit > summary { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; color: #52647a; font-size: 10px; font-weight: 700; list-style: none; }
.target-edit > summary::-webkit-details-marker, details > summary::-webkit-details-marker { display: none; }
.target-edit[open] > summary { background: #f8fafc; }
.edit-inner { display: grid; grid-template-columns: minmax(0,1fr) 260px; gap: 14px; padding: 14px; border-top: 1px solid var(--line); background: var(--surface-subtle); }
.target-form, .unlink-form { display: grid; gap: 11px; }
.danger-actions { display: grid; align-content: start; gap: 11px; }
.unlink-form { align-content: start; padding: 12px; border: 1px solid #efc6cd; border-radius: var(--radius-sm); background: #fffafb; }
.unlink-form strong { color: var(--danger); font-size: 11px; }
.unlink-form p { margin-bottom: 0; color: #865764; font-size: 10px; }
.target-requirement { margin: 0; padding: 8px 10px; border-left: 3px solid var(--danger); background: #fff3f5; color: #8f2f42; font-size: 10px; }
.target-requirement[hidden] { display: none; }
.row-error { grid-column: 1 / -1; margin: 0; padding: 8px 14px; border-top: 1px solid #f3d5da; background: #fff8f9; color: var(--danger); font-size: 10px; }
.archive-panel { margin-bottom: 18px; border-left: 3px solid #8a98a9; }
.archive-list { display: grid; }
.archive-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-top: 1px solid #edf0f3; }
.archive-row:first-child { border-top: 0; }
.archive-row strong, .archive-row small { display: block; }
.archive-row strong { font-size: 11px; }
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
.admin-actions-grid { margin-top: 18px; }
.stacked-form { display: grid; gap: 13px; }
.wide-form { width: 100%; max-width: 850px; }
.settings-form { display: grid; gap: 12px; }
.settings-mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; max-width: 930px; }
.setting-switch-card { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 18px; align-items: center; min-height: 116px; padding: 17px; border: 1px solid var(--line); border-left: 3px solid #aab4c0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); cursor: pointer; transition: border-color .18s, background .18s, transform .18s; }
.setting-switch-card:hover { border-color: #bcc7d3; transform: translateY(-1px); }
.setting-switch-card.is-enabled { border-left-color: var(--success); background: linear-gradient(110deg, #fff 65%, #f0faf7); }
.setting-switch-copy, .setting-switch-copy strong, .setting-switch-copy small { display: block; }
.setting-switch-copy strong { margin-bottom: 4px; color: var(--text); font-size: 15px; }
.setting-switch-copy small { max-width: 430px; color: var(--muted); font-size: 10px; font-weight: 500; line-height: 1.45; }
.setting-switch { flex-direction: column; gap: 5px; }
.setting-switch b { min-width: 48px; color: var(--muted); font-family: var(--mono); font-size: 8px; text-align: center; text-transform: uppercase; }
.setting-switch-card.is-enabled .setting-switch b { color: var(--success); }
.settings-fields { max-width: 930px; }
.endpoint-setting { transition: opacity .18s; }
.endpoint-setting.is-disabled { opacity: .48; }
.endpoint-setting.is-disabled textarea { background: #f0f2f5; }
label { display: grid; gap: 5px; color: #37475a; font-size: 11px; font-weight: 700; }
label small { color: var(--muted); font-size: 9px; font-weight: 500; line-height: 1.45; }
input, textarea, select { width: 100%; min-height: 39px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: #fff; color: var(--text); font-size: 12px; transition: border-color .14s, box-shadow .14s; }
textarea { resize: vertical; font-family: var(--mono); font-size: 10px; line-height: 1.55; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
.cname-edit-grid { align-items: start; }
.field-note { display: block; margin-top: -6px; color: var(--muted); font-size: 9px; line-height: 1.45; }
.form-actions { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; }
.toggle-row { display: flex; flex-wrap: wrap; gap: 11px 16px; align-items: center; }
.switch { display: inline-flex; width: auto; align-items: center; gap: 7px; color: var(--text); font-size: 10px; font-weight: 700; }
.switch input { position: absolute; width: 1px; min-height: 1px; opacity: 0; }
.switch > span { position: relative; width: 28px; height: 16px; border-radius: 99px; background: #b9c3cf; transition: background .14s; }
.switch > span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 10px; height: 10px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .14s; }
.switch input:checked + span { background: var(--success); }
.switch input:checked + span::after { transform: translateX(12px); }
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
.switch-disabled { color: var(--muted); opacity: .65; }
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
.info-box strong { color: #315e8b; }
.info-box span { color: #526d86; }
.technical-summary { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; align-content: center; padding: 11px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
.technical-summary span { color: var(--muted); font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
.technical-summary strong { font-size: 10px; text-align: right; }
.inset-form { margin-top: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
.auth-card, .token-card { width: min(500px, 100%); padding: 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: 0 18px 45px rgba(16,24,39,.1); }
.auth-card h1, .token-card h1 { margin: 20px 0 7px; font-size: 25px; }
.auth-card .lede, .token-card > p { margin-bottom: 20px; color: var(--muted); font-size: 12px; }
.auth-brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-size: 12px; font-weight: 800; }
.auth-brand span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 6px; background: var(--sidebar); color: #9dbbff; font-family: var(--mono); font-size: 9px; }
.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; }
.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; }
.warning-box { margin-top: 14px; padding: 10px 11px; border: 1px solid #ead18e; border-radius: 6px; background: var(--warning-soft); color: #74540b; font-size: 10px; }
.security-facts ul { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
.security-facts li { display: grid; gap: 2px; padding-left: 10px; border-left: 2px solid #9fcac4; }
.security-facts strong { font-size: 11px; }
.security-facts span { color: var(--muted); font-size: 10px; }
.user-table { display: grid; }
.user-table-head, .user-table-row { display: grid; grid-template-columns: minmax(160px,1.2fr) .55fr .55fr minmax(120px,.8fr) 20px; gap: 10px; align-items: center; }
.user-table-head { padding: 4px 7px 8px; color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 800; text-transform: uppercase; }
.user-table-row { padding: 10px 7px; border-top: 1px solid #edf0f3; }
.user-table-row:hover { background: var(--surface-subtle); color: var(--text); }
.user-table-row strong, .user-table-row small { display: block; }
.user-table-row strong { font-size: 11px; }
.user-table-row small, .muted-cell { color: var(--muted); font-size: 9px; }
.role-chip { display: inline-flex; width: fit-content; padding: 3px 6px; border-radius: 4px; background: #edf1f5; color: #536477; font-size: 9px; font-weight: 800; }
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
.row-arrow { color: var(--accent); }
.access-panel { margin-top: 12px; }
.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; }
.access-zone-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 12px; background: var(--surface-subtle); }
.access-zone-head small { color: var(--muted); font-size: 9px; }
.access-zone-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px 14px; }
.access-targets { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 1px; border-top: 1px solid var(--line); background: var(--line); }
.access-target { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px; align-items: center; min-height: 62px; padding: 8px 10px; background: #fff; color: var(--text); font-size: 10px; font-weight: 600; }
.access-target-main { display: flex; width: auto; grid-template-columns: none; align-items: center; gap: 8px; }
.access-target-main input { width: 15px; min-height: 15px; flex: none; accent-color: var(--accent); }
.access-target strong, .access-target small { display: block; }
.access-target small { color: var(--muted); font-size: 8px; }
.access-manage-switch { white-space: nowrap; }
.error-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
.error-section { margin-bottom: 12px; }
.error-list { display: grid; }
.error-item { display: grid; grid-template-columns: 26px minmax(0,1fr) auto; gap: 10px; align-items: start; padding: 11px 0; border-top: 1px solid #edf0f3; }
.error-item:first-child { border-top: 0; }
.error-item > div { min-width: 0; }
.error-item strong, .error-item small { display: block; }
.error-item strong { font-size: 11px; }
.error-item p { margin: 3px 0; color: var(--danger); font-size: 10px; overflow-wrap: anywhere; }
.error-item small { color: var(--muted); font-family: var(--mono); font-size: 8px; }
.error-item > a { color: var(--accent); font-size: 10px; font-weight: 700; }
.error-mark { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 7px; background: var(--danger-soft); color: var(--danger); font-family: var(--mono); font-size: 10px; font-weight: 900; }
.run-error-list .error-item { grid-template-columns: 10px minmax(0,1fr); align-items: center; }
.error-empty { display: flex; max-width: 720px; align-items: center; gap: 14px; }
.error-empty-icon { display: grid; width: 38px; height: 38px; flex: none; place-items: center; border-radius: 10px; background: var(--success-soft); color: var(--success); font-weight: 900; }
.error-empty h2 { margin-bottom: 3px; }
.error-empty p { margin: 0; color: var(--muted); font-size: 11px; }
@media (max-width: 1000px) {
.admin-layout { grid-template-columns: 190px minmax(0,1fr); }
.sidebar { padding-inline: 10px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
.edit-inner { grid-template-columns: 1fr; }
.settings-mode-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.admin-layout { display: block; }
.sidebar { position: static; height: auto; padding: 10px 12px; }
.brand { padding-bottom: 9px; }
.instance-state { display: none; }
.sidebar nav { display: flex; overflow-x: auto; padding-bottom: 2px; }
.sidebar nav a { min-width: fit-content; }
.sidebar-account { display: none; }
.workspace-bar { min-height: 45px; padding-inline: 14px; }
.app-shell { width: min(100% - 24px, 1280px); padding-top: 20px; }
.page-heading { align-items: flex-start; flex-direction: column; gap: 12px; }
.page-heading > form, .page-heading > .button { width: 100%; }
.page-heading .button { width: 100%; }
.heading-actions { width: 100%; }
.heading-actions form, .heading-actions .button { width: 100%; }
.form-grid, .metric-grid, .zone-summary { grid-template-columns: 1fr; }
.bulk-ttl-form { grid-template-columns: 1fr; }
.bulk-ttl-form .button { width: 100%; }
.zone-summary div { border-top: 1px solid var(--line); border-left: 0; }
.zone-summary div:first-child { border-top: 0; }
.target-row { grid-template-columns: 1fr; }
.target-meta { min-width: 0; justify-content: space-between; padding-top: 0; }
.target-edit { grid-column: 1; }
.audit-list li { grid-template-columns: 1fr auto; gap: 3px 10px; }
.audit-list strong { grid-column: 1 / -1; grid-row: 1; }
.user-table-head { display: none; }
.user-table-row { grid-template-columns: minmax(0,1fr) auto auto; gap: 7px; }
.user-table-row > :nth-child(4) { display: none; }
.archive-row { align-items: flex-start; flex-direction: column; }
.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-zone-controls { width: 100%; }
.access-target { grid-template-columns: 1fr; }
.error-summary-grid { grid-template-columns: 1fr; }
.error-item { grid-template-columns: 26px minmax(0,1fr); }
.error-item > a { grid-column: 2; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
.toast, .toast-progress { animation: none !important; }
}
+58
View File
@@ -0,0 +1,58 @@
{{define "dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div>
<span class="page-kicker">Systemstatus</span>
<h1>Übersicht</h1>
<p>Status der verbundenen DNS-Zonen und des automatischen Update-Dienstes.</p>
</div>
{{if .IsAdmin}}
<div class="heading-actions">
<form action="/run" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
</form>
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
</div>
{{end}}
</header>
<section class="metric-grid" aria-label="DynDNS-Status">
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
</section>
<section class="dashboard-grid">
{{if .IsAdmin}}
<article class="panel">
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
{{if .Dashboard.RecentRuns}}
<div class="data-list">
{{range .Dashboard.RecentRuns}}
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
{{end}}
</div>
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
</article>
{{end}}
<article class="panel">
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
{{if .Dashboard.Zones}}
<div class="domain-list">
{{range .Dashboard.Zones}}
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
{{end}}
</div>
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
</article>
</section>
{{if and .IsAdmin .Dashboard.RecentEvents}}
<section class="panel audit-panel">
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
</section>
{{end}}
{{end}}
+128
View File
@@ -0,0 +1,128 @@
{{define "domain_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">DNS-Zone</span><h1>{{.Zone.Name}}</h1><p>{{if .IsAdmin}}DynDNS-Ziele und statische CNAME-Aliase dieser Zone.{{else}}Dir zugewiesene DynDNS-Ziele dieser Zone.{{end}}</p></div>
{{if .IsAdmin}}<form action="/domains/{{.Zone.ID}}/import" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>{{end}}
</header>
<section class="zone-summary" aria-label="Zonenstatus">
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
</section>
{{if .IsAdmin}}
<section class="bulk-ttl-panel" aria-label="TTL für verwaltete Records setzen">
<form action="/domains/{{.Zone.ID}}/ttl" method="post" class="bulk-ttl-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div><span class="section-label">Sammelaktion</span><strong>TTL für alle verwalteten Records</strong><small>A-/AAAA-Ziele und CNAMEs dieser Oberfläche; andere Hetzner-Records bleiben unverändert.</small></div><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label><button class="button button-quiet" type="submit">TTL für alle setzen</button></form>
</section>
{{end}}
<section class="targets-section">
<div class="section-heading"><div><span class="section-label">Verknüpfte Records</span><h2>Subdomains und Root-Record</h2></div><span class="record-count">{{len .Targets}} Einträge</span></div>
{{if .Targets}}
<div class="target-list">
{{range .Targets}}
{{$target := .}}
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
<article class="target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<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>
<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>
</form>
{{if $.IsAdmin}}
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Verknüpfung trennen</strong>
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Ziel vollständig löschen</strong>
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
</form></div>
{{end}}
</div></details>{{end}}
{{if .LastError}}<p class="row-error">{{.LastError}}</p>{{end}}
</article>
{{end}}
</div>
{{else}}<div class="empty-state">Keine verknüpften A-/AAAA-Records vorhanden. {{if or .IsAdmin .CanCreateTargets}}Lege unten eine neue Subdomain an.{{end}}</div>{{end}}
</section>
{{if .IsAdmin}}
<section class="targets-section">
<div class="section-heading"><div><span class="section-label">Statische Records</span><h2>CNAME-Aliase</h2></div><span class="record-count">{{len .CNAMERecords}} Einträge</span></div>
{{if .CNAMERecords}}
<div class="target-list">
{{range .CNAMERecords}}
{{$cname := .}}
<article class="target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">CNAME</span><span>→ {{.Destination}}</span>{{if .Description}}<span>· {{.Description}}</span>{{end}}</p></div></div>
<div class="target-meta"><span class="status-pill {{if .Active}}status-good{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
<details class="target-edit"><summary>Bearbeiten <span>⌄</span></summary><div class="edit-inner">
<form action="/cnames/{{.ID}}/save" method="post" class="target-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> CNAME aktiv</label></div>
<div class="form-grid cname-edit-grid"><label>CNAME-Ziel <input name="destination" value="{{.Destination}}" maxlength="253" required></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $cname.TTL)}}<option value="{{$cname.TTL}}" selected>{{$cname.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $cname.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><small class="field-note">Vollständiger Domainname; der abschließende Punkt wird ergänzt.</small>
<label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label>
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
</form>
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Aus WebUI entfernen</strong>
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Ziel vollständig löschen</strong>
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
</form></div>
</div></details>
</article>
{{end}}
</div>
{{else}}<div class="empty-state">Keine CNAME-Aliase vorhanden. Lege unten einen neuen Alias an.</div>{{end}}
</section>
{{end}}
{{if and .IsAdmin .ArchivedTargets}}
<section class="panel archive-panel">
<div class="panel-heading"><div><span class="section-label">Archiv</span><h2>Getrennte Ziele</h2></div><span class="record-count">{{len .ArchivedTargets}} Einträge</span></div>
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
<div class="archive-list">
{{range .ArchivedTargets}}
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
{{end}}
</div>
</section>
{{end}}
{{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>
</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>
<form action="/cnames" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><div class="form-grid"><label>Alias <input name="record_name" required placeholder="www"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><label>CNAME-Ziel <input name="destination" required placeholder="app.example.net."><small>Vollständiger Domainname; ein abschließender Punkt ist optional.</small></label></div><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><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" checked><span></span> CNAME aktivieren</label></div><button class="button button-primary" type="submit">CNAME anlegen</button></form>
</article>
<article class="panel"><div class="panel-heading"><div><span class="section-label">Zonenzugang</span><h2>Verbindung verwalten</h2></div></div>
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Token entschlüsseln</button></form></details>
<div class="panel-divider"></div>
<details><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
</article>
{{end}}
</section>
{{end}}
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "domain_new"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">Hetzner-Verbindung</span><h1>Domain hinzufügen</h1><p>Nach erfolgreicher Anmeldung werden alle vorhandenen A- und AAAA-Records der Zone automatisch importiert und als inaktive DynDNS-Ziele angezeigt.</p></div></header>
<section class="form-panel compact-panel">
<form action="/domains" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
</form>
</section>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "domains"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><span class="page-kicker">DNS-Verwaltung</span><h1>Domains</h1><p>{{if .IsAdmin}}Hetzner-Zonen, Zugangszustand und DynDNS-Ziele verwalten.{{else}}Freigegebene Domains und Subdomains verwalten.{{end}}</p></div>
{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}
</header>
<section class="domain-board">
{{if .Zones}}
{{range .Zones}}
<article class="zone-card">
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
<h2>{{.Name}}</h2>
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
</article>
{{end}}
{{else}}
<div class="empty-board"><span class="empty-icon">DNS</span><h2>{{if .IsAdmin}}Keine Domain verbunden{{else}}Keine Berechtigung zugewiesen{{end}}</h2><p>{{if .IsAdmin}}Füge eine Hetzner-Zone hinzu. A- und AAAA-Records werden beim Verbinden automatisch importiert.{{else}}Ein Admin kann dir eine vollständige Domain oder einzelne Subdomains freigeben.{{end}}</p>{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}</div>
{{end}}
</section>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "errors"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
<form action="/errors/clear" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
</form>
</header>
{{if eq .ErrorCount 0}}
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
{{else}}
<section class="error-summary-grid" aria-label="Fehlerübersicht">
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
</section>
{{if .ErrorTargets}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
<div class="error-list">
{{range .ErrorTargets}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
{{end}}
{{if .ErrorZones}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
<div class="error-list">
{{range .ErrorZones}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
{{end}}
{{if .ErrorRuns}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
<div class="error-list run-error-list">
{{range .ErrorRuns}}
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
{{end}}
</div>
</section>
{{end}}
{{end}}
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "layout"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · Hetzner DynDNS</title>
<link rel="stylesheet" href="/static/styles.css?v=features-5">
<script src="/static/app.js?v=features-5" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{{if and (ne .Page "setup") (ne .Page "login")}}
<div class="admin-layout">
<aside class="sidebar">
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
<span class="brand-mark">DD</span>
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
</a>
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
<nav aria-label="Hauptnavigation">
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
{{if .IsAdmin}}
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</a>
{{end}}
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}06{{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>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
</form>
</div>
</aside>
<div class="workspace">
<header class="workspace-bar">
<div><span>DynDNS</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>
<main id="main" class="app-shell">
{{template "content" .}}
</main>
</div>
</div>
{{else}}
<main id="main" class="auth-shell">
{{template "content" .}}
</main>
{{end}}
{{if .Flash}}
<div class="toast-region">
<div class="toast toast-{{.Flash.Kind}}" data-toast role="{{if eq .Flash.Kind "error"}}alert{{else}}status{{end}}" aria-atomic="true">
<span class="toast-icon" aria-hidden="true">{{if eq .Flash.Kind "error"}}!{{else if eq .Flash.Kind "info"}}i{{else}}✓{{end}}</span>
<div class="toast-copy">
<span class="toast-eyebrow">{{if eq .Flash.Kind "error"}}Bitte prüfen{{else if eq .Flash.Kind "info"}}Hinweis{{else}}Erledigt{{end}}</span>
<p>{{.Flash.Message}}</p>
</div>
<button class="toast-close" type="button" data-toast-close aria-label="Meldung schließen">×</button>
<span class="toast-progress" aria-hidden="true"></span>
</div>
</div>
{{end}}
</body>
</html>
{{end}}
+18
View File
@@ -0,0 +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>
<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>
</label>
<label>Passwort
<input name="password" type="password" autocomplete="current-password" 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}}
+8
View File
@@ -0,0 +1,8 @@
{{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>
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "settings"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><span class="page-kicker">Scheduler</span><h1>Updater-Konfiguration</h1><p>Dienststatus, Intervall und IP-Familien zentral steuern.</p></div>
</header>
<form action="/settings" method="post" class="settings-form" data-settings-form>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<section class="settings-mode-grid" aria-label="Updater-Schalter">
<label class="setting-switch-card {{if .Settings.UpdaterEnabled}}is-enabled{{end}}">
<span class="setting-switch-copy"><span class="section-label">Automatik</span><strong>DynDNS-Dienst</strong><small>Stoppt geplante und automatisch ausgelöste Aktualisierungen vollständig.</small></span>
<span class="switch setting-switch"><input type="checkbox" name="updater_enabled" data-updater-toggle {{if .Settings.UpdaterEnabled}}checked{{end}}><span></span><b>{{if .Settings.UpdaterEnabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
</label>
<label class="setting-switch-card {{if .Settings.IPv6Enabled}}is-enabled{{end}}">
<span class="setting-switch-copy"><span class="section-label">IP-Familie</span><strong>IPv6-Aktualisierung</strong><small>Deaktiviert IPv6-Ermittlung und AAAA-Updates, ohne Zielkonfigurationen zu löschen.</small></span>
<span class="switch setting-switch"><input type="checkbox" name="ipv6_enabled" data-ipv6-toggle {{if .Settings.IPv6Enabled}}checked{{end}}><span></span><b>{{if .Settings.IPv6Enabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
</label>
</section>
<section class="form-panel compact-panel settings-fields">
<div class="stacked-form wide-form">
<div class="form-grid">
<label>Abfrageintervall in Sekunden
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
</label>
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
</div>
<div class="form-grid endpoint-grid">
<label>IPv4-Endpunkte
<textarea name="ipv4_endpoints" rows="4" spellcheck="false" required>{{formatEndpoints .Settings.IPv4Endpoints}}</textarea>
<small>Eine HTTPS-URL pro Zeile; Reihenfolge entspricht der Fallback-Reihenfolge.</small>
</label>
<label class="endpoint-setting {{if not .Settings.IPv6Enabled}}is-disabled{{end}}" data-ipv6-settings aria-disabled="{{if .Settings.IPv6Enabled}}false{{else}}true{{end}}">IPv6-Endpunkte
<textarea name="ipv6_endpoints" rows="4" spellcheck="false" {{if .Settings.IPv6Enabled}}required{{end}}>{{formatEndpoints .Settings.IPv6Endpoints}}</textarea>
<small>Werden nur verwendet, wenn IPv6 oben aktiviert ist.</small>
</label>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Konfiguration speichern</button><a class="button button-quiet" href="/">Zur Übersicht</a></div>
</div>
</section>
</form>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{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">
<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>
</label>
<label>Anzeigename
<input name="display_name" type="text" value="{{index .Form "display_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>
</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>
</form>
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
</section>
{{end}}
+4
View File
@@ -0,0 +1,4 @@
{{define "token"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{define "user_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div>
<a class="back-link" href="/users">← Benutzer</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>
</div>
<span class="status-pill {{if .ManagedUser.Active}}status-good{{else}}status-bad{{end}}">{{if .ManagedUser.Active}}aktiv{{else}}deaktiviert{{end}}</span>
</header>
<section class="split-grid">
<article class="panel">
<div class="panel-heading"><div><span class="section-label">Konto</span><h2>Rolle und Status</h2></div></div>
<form action="/users/{{.ManagedUser.ID}}/state" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Anzeigename <input name="display_name" value="{{.ManagedUser.DisplayName}}" maxlength="80" required></label>
<label>Rolle <select name="role"><option value="member" {{if eq .ManagedUser.Role "member"}}selected{{end}}>Mitglied</option><option value="admin" {{if eq .ManagedUser.Role "admin"}}selected{{end}}>Admin</option></select></label>
<label class="switch"><input type="checkbox" name="active" {{if .ManagedUser.Active}}checked{{end}}><span></span> Konto aktiv</label>
<button class="button button-primary" type="submit">Kontostatus speichern</button>
</form>
</article>
<article class="panel">
<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>
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
</form>
</article>
</section>
{{if eq .ManagedUser.Role "member"}}
<section class="panel access-panel">
<div class="panel-heading"><div><span class="section-label">Berechtigungen</span><h2>Domain- und Subdomain-Zugriffe</h2></div></div>
<p class="access-intro">Lege getrennt fest, was sichtbar ist, was bearbeitet werden darf und in welchen Domains das Mitglied selbst neue Subdomains anlegen kann.</p>
<form action="/users/{{.ManagedUser.ID}}/access" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="access-list">
{{range .AccessZones}}
{{$accessZone := .}}
<article class="access-zone" data-access-zone>
<div class="access-zone-head">
<div><h3>{{$accessZone.Zone.Name}}</h3><small>{{len $accessZone.Targets}} bekannte DynDNS-Ziele</small></div>
<div class="access-zone-controls">
<label class="switch"><input type="checkbox" name="zone_{{$accessZone.Zone.ID}}" data-zone-access {{if $accessZone.DomainGranted}}checked{{end}}><span></span> Ganze Domain sehen</label>
<label class="switch"><input type="checkbox" name="manage_zone_{{$accessZone.Zone.ID}}" data-zone-manage {{if $accessZone.DomainManaged}}checked{{end}}><span></span> Alle Ziele bearbeiten</label>
<label class="switch"><input type="checkbox" name="create_zone_{{$accessZone.Zone.ID}}" data-zone-create {{if $accessZone.CanCreateTargets}}checked{{end}}><span></span> Subdomains anlegen</label>
</div>
</div>
{{if $accessZone.Targets}}
<div class="access-targets">
{{range $accessZone.Targets}}
<div class="access-target" data-access-target>
<label class="access-target-main"><input type="checkbox" name="target_{{.ID}}" data-target-access {{if index $accessZone.GrantedTargetIDs .ID}}checked{{end}}><span><strong>{{.FQDN}}</strong><small>{{families .}} · {{if .Active}}aktiv{{else}}inaktiv{{end}}</small></span></label>
<label class="switch access-manage-switch"><input type="checkbox" name="manage_target_{{.ID}}" data-target-manage {{if index $accessZone.ManagedTargetIDs .ID}}checked{{end}}><span></span> Bearbeiten</label>
</div>
{{end}}
</div>
{{else}}<p class="muted-cell">Keine A-/AAAA-Records importiert.</p>{{end}}
</article>
{{end}}
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Zugriffsrechte speichern</button></div>
</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>
{{end}}
{{end}}
+13
View File
@@ -0,0 +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">
<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>
<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>
</form>
</section>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
{{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>
<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}}