Compare commits

...
5 Commits
Author SHA1 Message Date
admin 1c32ca0671 feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled
2026-07-13 02:49:41 +02:00
admin 1a6b4d0939 chore: expose web interface on port 9090 2026-07-12 22:38:54 +02:00
admin 17d517e9dc chore: pin compose image version 2026-07-12 22:34:38 +02:00
admin c8728746f1 refactor: reduce deployment to one compose file
Container-Image veröffentlichen / container (push) Has been cancelled
2026-07-12 22:17:04 +02:00
admin 888d9a54a7 docs: use registry instead of production repository 2026-07-12 21:52:07 +02:00
27 changed files with 2295 additions and 356 deletions

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
# 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
@@ -1,11 +0,0 @@
# 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
+1 -1
View File
@@ -15,7 +15,7 @@ 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
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
-170
View File
@@ -1,170 +0,0 @@
# 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
```
+54 -57
View File
@@ -1,76 +1,73 @@
# Hetzner DynDNS Dashboard
# Hetzner DynDNS
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.
Weboberfläche für mehrere Hetzner-DNS-Zonen, eine Pi-hole-v6-Verbindung, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt; lokale DNS-Einträge lassen sich direkt über die Pi-hole-API verwalten.
## Was es kann
## Starten
- 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
Die Datei [`compose.yml`](compose.yml) in Portainer, Dockge oder einem anderen Docker-Webinterface als Stack einfügen und starten.
## Schnellstart
1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen:
Alternativ im ausgecheckten Repository:
```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
docker compose up -d
```
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`.
Danach aufrufen:
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.
```text
http://IP-DES-DOCKER-HOSTS:9090
```
3. Starten:
Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch in getrennten Docker-Volumes angelegt. Es werden keine `.env`-Datei und kein manuell erzeugtes Secret benötigt.
## Das Compose
```yaml
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.0
restart: unless-stopped
ports:
- "9090:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
dyndns-data:
dyndns-secrets:
```
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden.
## Aktualisieren
In `compose.yml` die Versionsnummer beim Image ändern, anschließend das Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
```bash
docker compose up -d --build
docker compose pull
docker compose up -d
```
4. Öffne `http://<host-ip>:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token.
Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden.
## Produktivbetrieb
## Funktionen
Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut:
- mehrere Domains und getrennt verschlüsselte Hetzner-API-Tokens
- automatischer Import vorhandener A-/AAAA-Records
- DynDNS pro Subdomain und IP-Familie aktivierbar
- lokales Archiv statt Löschen von Hetzner-Records
- frei einstellbares Update-Intervall mit Countdown
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
```
Der Container muss die verbundene Pi-hole-Adresse direkt im internen Netz erreichen können. Pi-hole-Sitzungen werden nur für die jeweilige Aktion erstellt und danach aktiv beendet; dauerhaft gespeichert wird ausschließlich das mit dem Master-Key verschlüsselte Anwendungspasswort. Der Master-Key liegt getrennt mit Dateimodus `0600`; Datenverzeichnis und SQLite-Datei werden auf `0700` beziehungsweise `0600` gehärtet. Das Compose-Setup verwendet außerdem ein schreibgeschütztes Root-Dateisystem und ein begrenztes `/tmp`-Tmpfs. HTTPS mit gültigem Zertifikat ist empfohlen.
Die vollständige Anleitung für Gitea Actions, HTTPS-Reverse-Proxy, Installation, Backups, Updates und Rollbacks steht in [PRODUCTION.md](PRODUCTION.md).
Damit ein Pi-hole-Anwendungspasswort lokale DNS-Einträge ändern darf, muss am Pi-hole `webserver.api.app_sudo=true` gesetzt sein; Pi-hole verwendet hierfür standardmäßig `false`. Zusätzlich darf der globale Schalter `misc.readOnly` nicht aktiviert sein. Bei einem Pi-hole-Docker-Container entspricht das beispielsweise `FTLCONF_webserver_api_app_sudo: "true"` und `FTLCONF_misc_readOnly: "false"`.
## 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**.
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
+3 -1
View File
@@ -32,11 +32,13 @@ func main() {
}
func run(logger *slog.Logger) error {
// Credentials and session data must never inherit a permissive process umask.
syscall.Umask(0o077)
cfg, err := config.Load()
if err != nil {
return err
}
cipher, err := secure.LoadCipher(cfg.MasterKeyFile)
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
if err != nil {
return err
}
-39
View File
@@ -1,39 +0,0 @@
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
+20
View File
@@ -0,0 +1,20 @@
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.0
restart: unless-stopped
ports:
- "9090:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
dyndns-data:
dyndns-secrets:
-32
View File
@@ -1,32 +0,0 @@
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:
+1 -4
View File
@@ -50,10 +50,7 @@ func Load() (Config, error) {
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)")
}
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
cookieSecure := mode == "https"
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
+30
View File
@@ -72,6 +72,36 @@ type CNAMERecord struct {
UpdatedAt time.Time
}
// PiHoleIntegration is one remotely managed Pi-hole v6 instance. API
// passwords are returned separately and never exposed to templates.
type PiHoleIntegration struct {
ID int64
Name string
BaseURL string
InsecureTLS bool
CreatedAt time.Time
UpdatedAt time.Time
LastSyncAt time.Time
LastError string
}
type PiHoleCredential struct {
Ciphertext []byte
Nonce []byte
}
// PiHoleRecordLink tracks whether a local DNS entry is managed by this UI or
// merely retained at Pi-hole after being removed from the active WebUI list.
type PiHoleRecordLink struct {
ID int64
IntegrationID int64
Active bool
Value string
CreatedAt time.Time
UpdatedAt time.Time
ArchivedAt time.Time
}
type User struct {
ID int64
Username string
+251
View File
@@ -0,0 +1,251 @@
package pihole
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const maxResponseBody = 1 << 20
type Client struct {
baseURL string
http *http.Client
}
type APIError struct {
Status int
Key string
Message string
}
func (e *APIError) Error() string {
message := strings.TrimSpace(e.Message)
if message == "" {
message = http.StatusText(e.Status)
}
if e.Key != "" {
return fmt.Sprintf("Pi-hole API %d (%s): %s", e.Status, e.Key, message)
}
return fmt.Sprintf("Pi-hole API %d: %s", e.Status, message)
}
func New(rawURL string, insecureTLS bool) (*Client, error) {
baseURL, err := normalizeBaseURL(rawURL)
if err != nil {
return nil, err
}
dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
transport := &http.Transport{
Proxy: nil,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: insecureTLS, // Explicit admin opt-in for self-signed Pi-hole certificates.
},
}
return &Client{
baseURL: baseURL,
http: &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return errors.New("Pi-hole API redirects are not allowed")
},
},
}, nil
}
func normalizeBaseURL(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", errors.New("Pi-hole-Adresse muss eine vollständige HTTP- oder HTTPS-URL sein")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("Pi-hole-Adresse darf keine Zugangsdaten, Query-Parameter oder Fragmente enthalten")
}
if parsed.Path == "" || parsed.Path == "/" {
parsed.Path = "/api"
} else {
parsed.Path = strings.TrimRight(parsed.Path, "/")
if !strings.HasSuffix(parsed.Path, "/api") {
parsed.Path += "/api"
}
}
parsed.RawPath = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func (c *Client) BaseURL() string { return c.baseURL }
func (c *Client) ListHosts(ctx context.Context, password string) ([]string, error) {
var hosts []string
err := c.withSession(ctx, password, func(sid string) error {
var response struct {
Config struct {
DNS struct {
Hosts []string `json:"hosts"`
} `json:"dns"`
} `json:"config"`
}
if err := c.doJSON(ctx, http.MethodGet, "/config/dns/hosts", sid, nil, &response, http.StatusOK); err != nil {
return err
}
hosts = append([]string(nil), response.Config.DNS.Hosts...)
return nil
})
return hosts, err
}
func (c *Client) AddHost(ctx context.Context, password, value string) error {
return c.withSession(ctx, password, func(sid string) error {
return c.addHost(ctx, sid, value)
})
}
func (c *Client) DeleteHost(ctx context.Context, password, value string) error {
return c.withSession(ctx, password, func(sid string) error {
return c.deleteHost(ctx, sid, value)
})
}
// DeleteHosts removes a complete set of local DNS entries in one authenticated
// Pi-hole session. Missing values are accepted so an interrupted deletion can
// safely be retried.
func (c *Client) DeleteHosts(ctx context.Context, password string, values []string) error {
return c.withSession(ctx, password, func(sid string) error {
for _, value := range values {
if err := c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
return fmt.Errorf("Pi-hole-Eintrag %q konnte nicht gelöscht werden: %w", value, err)
}
}
return nil
})
}
func (c *Client) ReplaceHost(ctx context.Context, password, previous, replacement string) error {
if previous == replacement {
return nil
}
return c.withSession(ctx, password, func(sid string) error {
if err := c.deleteHost(ctx, sid, previous); err != nil {
return err
}
if err := c.addHost(ctx, sid, replacement); err != nil {
if rollbackErr := c.addHost(ctx, sid, previous); rollbackErr != nil {
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen: %v; Wiederherstellung ebenfalls fehlgeschlagen: %w", err, rollbackErr)
}
return fmt.Errorf("neuer Pi-hole-Eintrag fehlgeschlagen; ursprünglicher Eintrag wurde wiederhergestellt: %w", err)
}
return nil
})
}
func (c *Client) addHost(ctx context.Context, sid, value string) error {
return c.doJSON(ctx, http.MethodPut, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusCreated)
}
func (c *Client) deleteHost(ctx context.Context, sid, value string) error {
return c.doJSON(ctx, http.MethodDelete, "/config/dns/hosts/"+url.PathEscape(value), sid, nil, nil, http.StatusNoContent)
}
func (c *Client) withSession(ctx context.Context, password string, operation func(string) error) error {
sid, err := c.authenticate(ctx, password)
if err != nil {
return err
}
defer func() {
logoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = c.doJSON(logoutCtx, http.MethodDelete, "/auth", sid, nil, nil, http.StatusNoContent, http.StatusNotFound)
}()
return operation(sid)
}
func (c *Client) authenticate(ctx context.Context, password string) (string, error) {
var response struct {
Session struct {
Valid bool `json:"valid"`
SID string `json:"sid"`
Message string `json:"message"`
} `json:"session"`
}
payload := struct {
Password string `json:"password"`
}{Password: password}
if err := c.doJSON(ctx, http.MethodPost, "/auth", "", payload, &response, http.StatusOK); err != nil {
return "", err
}
if !response.Session.Valid || strings.TrimSpace(response.Session.SID) == "" {
message := strings.TrimSpace(response.Session.Message)
if message == "" {
message = "Pi-hole hat keine gültige API-Session erstellt"
}
return "", errors.New(message)
}
return response.Session.SID, nil
}
func (c *Client) doJSON(ctx context.Context, method, path, sid string, requestBody, responseBody any, accepted ...int) error {
var body io.Reader
if requestBody != nil {
encoded, err := json.Marshal(requestBody)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "domain-control-webinterface/1")
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}
if sid != "" {
request.Header.Set("X-FTL-SID", sid)
}
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("Pi-hole API nicht erreichbar: %w", err)
}
defer response.Body.Close()
content, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
if err != nil {
return err
}
for _, status := range accepted {
if response.StatusCode == status {
if responseBody != nil && len(content) > 0 {
if err := json.Unmarshal(content, responseBody); err != nil {
return fmt.Errorf("ungültige Antwort der Pi-hole API: %w", err)
}
}
return nil
}
}
var apiResponse struct {
Error struct {
Key string `json:"key"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(content, &apiResponse)
return &APIError{Status: response.StatusCode, Key: apiResponse.Error.Key, Message: apiResponse.Error.Message}
}
+109
View File
@@ -0,0 +1,109 @@
package pihole
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestClientManagesLocalDNSHostsWithShortLivedSessions(t *testing.T) {
t.Parallel()
var mu sync.Mutex
hosts := []string{"192.168.1.10 nas.lan"}
logouts := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/auth" && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"test-sid","validity":1800,"message":"ok","totp":false}}`))
return
}
if r.URL.Path == "/api/auth" && r.Method == http.MethodDelete {
if r.Header.Get("X-FTL-SID") != "test-sid" {
http.Error(w, "missing sid", http.StatusUnauthorized)
return
}
mu.Lock()
logouts++
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
if r.Header.Get("X-FTL-SID") != "test-sid" {
http.Error(w, "missing sid", http.StatusUnauthorized)
return
}
mu.Lock()
defer mu.Unlock()
switch {
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["` + strings.Join(hosts, `","`) + `"]}}}`))
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodPut:
hosts = append(hosts, strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/"))
w.WriteHeader(http.StatusCreated)
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Method == http.MethodDelete:
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
for index, host := range hosts {
if host == value {
hosts = append(hosts[:index], hosts[index+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
http.NotFound(w, r)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
client, err := New(api.URL, false)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
listed, err := client.ListHosts(ctx, "app-password")
if err != nil || len(listed) != 1 || listed[0] != "192.168.1.10 nas.lan" {
t.Fatalf("hosts not listed: %#v err=%v", listed, err)
}
if err := client.AddHost(ctx, "app-password", "192.168.1.20 printer.lan"); err != nil {
t.Fatal(err)
}
if err := client.ReplaceHost(ctx, "app-password", "192.168.1.20 printer.lan", "192.168.1.21 printer.lan"); err != nil {
t.Fatal(err)
}
if err := client.DeleteHost(ctx, "app-password", "192.168.1.21 printer.lan"); err != nil {
t.Fatal(err)
}
if err := client.DeleteHosts(ctx, "app-password", []string{"192.168.1.10 nas.lan", "192.168.1.99 already-gone.lan"}); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if logouts != 5 {
t.Fatalf("expected one logout per operation, got %d", logouts)
}
if len(hosts) != 0 {
t.Fatalf("batch deletion left hosts behind: %#v", hosts)
}
}
func TestNewValidatesAndNormalizesPiHoleURL(t *testing.T) {
t.Parallel()
client, err := New("https://pi.hole/", false)
if err != nil || client.BaseURL() != "https://pi.hole/api" {
t.Fatalf("unexpected normalized URL %q err=%v", client.BaseURL(), err)
}
client, err = New("http://pihole.local/custom", false)
if err != nil || client.BaseURL() != "http://pihole.local/custom/api" {
t.Fatalf("custom path not normalized: %q err=%v", client.BaseURL(), err)
}
for _, invalid := range []string{"pi.hole", "ftp://pi.hole", "https://user:pass@pi.hole", "https://pi.hole?sid=secret"} {
if _, err := New(invalid, false); err == nil {
t.Errorf("invalid URL accepted: %s", invalid)
}
}
}
+43
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/argon2"
@@ -31,6 +32,48 @@ func LoadCipher(path string) (*Cipher, error) {
if err != nil {
return nil, fmt.Errorf("read master key: %w", err)
}
return cipherFromKey(raw)
}
// LoadOrCreateCipher loads an existing master key or creates one on first start.
// The key must live on a persistent volume separate from the application data.
func LoadOrCreateCipher(path string) (*Cipher, error) {
cipher, err := LoadCipher(path)
if err == nil {
return cipher, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create master key directory: %w", err)
}
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("create master key: %w", err)
}
encoded := []byte(base64.RawStdEncoding.EncodeToString(key))
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return LoadCipher(path)
}
if err != nil {
return nil, fmt.Errorf("create master key file: %w", err)
}
if _, err := file.Write(encoded); err != nil {
_ = file.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("write master key: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("close master key file: %w", err)
}
return &Cipher{key: key}, nil
}
func cipherFromKey(raw []byte) (*Cipher, error) {
raw = []byte(strings.TrimSpace(string(raw)))
key, err := base64.RawStdEncoding.DecodeString(string(raw))
if err != nil {
+28
View File
@@ -41,6 +41,34 @@ func TestCipherRoundTripAndFingerprint(t *testing.T) {
}
}
func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "secrets", "master_key")
first, err := LoadOrCreateCipher(path)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if permissions := info.Mode().Perm(); permissions != 0o600 {
t.Fatalf("master key permissions = %o, want 600", permissions)
}
ciphertext, nonce, err := first.Encrypt("persistent token")
if err != nil {
t.Fatal(err)
}
second, err := LoadOrCreateCipher(path)
if err != nil {
t.Fatal(err)
}
plaintext, err := second.Decrypt(ciphertext, nonce)
if err != nil || plaintext != "persistent token" {
t.Fatalf("created key was not reused: plaintext=%q err=%v", plaintext, err)
}
}
func TestPasswordHash(t *testing.T) {
t.Parallel()
hash, err := HashPassword("a secure password")
+348 -4
View File
@@ -21,6 +21,8 @@ type Store struct {
db *sql.DB
}
var ErrPiHoleAlreadyConfigured = errors.New("a Pi-hole integration is already configured")
type ManagedTarget struct {
Target model.Target
Zone model.Zone
@@ -36,9 +38,13 @@ type AccessSelection struct {
}
func Open(ctx context.Context, path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
dataDir := filepath.Dir(path)
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
if err := os.Chmod(dataDir, 0o700); err != nil {
return nil, fmt.Errorf("secure data directory: %w", err)
}
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
@@ -48,6 +54,10 @@ func Open(ctx context.Context, path string) (*Store, error) {
_ = db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
_ = db.Close()
return nil, fmt.Errorf("secure database file: %w", err)
}
store := &Store{db: db}
if err := store.migrate(ctx); err != nil {
_ = db.Close()
@@ -97,6 +107,15 @@ func (s *Store) migrate(ctx context.Context) error {
if err := s.applyMigrationV7(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV8(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV9(ctx, tx); err != nil {
return err
}
if err := s.applyMigrationV10(ctx, tx); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO settings(id, update_interval_seconds, ipv4_endpoints, ipv6_endpoints, updated_at) VALUES(1, ?, ?, ?, ?)`,
300, mustJSON([]string{config.DefaultIPv4Primary, config.DefaultIPv4Fallback}), mustJSON([]string{config.DefaultIPv6Primary, config.DefaultIPv6Fallback}), now()); err != nil {
return err
@@ -172,6 +191,57 @@ func (s *Store) applyMigrationV7(ctx context.Context, tx *sql.Tx) error {
return nil
}
func (s *Store) applyMigrationV8(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 8`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV8); err != nil {
return fmt.Errorf("apply schema v8: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(8, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV9(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 9`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV9); err != nil {
return fmt.Errorf("apply schema v9: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(9, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV10(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 10`).Scan(&count); err != nil {
return err
}
if count != 0 {
return nil
}
if _, err := tx.ExecContext(ctx, schemaV10); err != nil {
return fmt.Errorf("apply schema v10: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(10, ?)`, now()); err != nil {
return err
}
return nil
}
func (s *Store) applyMigrationV3(ctx context.Context, tx *sql.Tx) error {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = 3`).Scan(&count); err != nil {
@@ -351,6 +421,38 @@ const schemaV7 = `
ALTER TABLE cname_records ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
const schemaV8 = `
CREATE TABLE pihole_integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_ciphertext BLOB NOT NULL,
password_nonce BLOB NOT NULL,
insecure_tls INTEGER NOT NULL DEFAULT 0 CHECK (insecure_tls IN (0,1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_sync_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT ''
);
`
const schemaV9 = `
CREATE TABLE pihole_record_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER NOT NULL REFERENCES pihole_integrations(id) ON DELETE CASCADE,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER NOT NULL DEFAULT 0,
UNIQUE(integration_id, value)
);
CREATE INDEX pihole_record_links_integration_archived_idx ON pihole_record_links(integration_id, archived_at);
`
const schemaV10 = `
ALTER TABLE pihole_record_links ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1));
`
func (s *Store) HasAdmin(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1`).Scan(&count)
@@ -556,7 +658,7 @@ func (s *Store) ListAccessibleTargets(ctx context.Context, userID, zoneID int64)
rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.zone_id, t.record_name, t.fqdn, t.update_ipv4, t.update_ipv6, t.active, t.static_ipv4, t.static_ipv6, t.description, t.ttl, t.last_updated_at, t.last_status, t.last_error, t.created_at, t.archived_at
FROM targets t WHERE t.zone_id = ? AND t.archived_at = 0 AND EXISTS (
SELECT 1 FROM access_grants a WHERE a.user_id = ? AND a.zone_id = t.zone_id AND (a.target_id IS NULL OR a.target_id = t.id)
) ORDER BY CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
) ORDER BY t.active DESC, CASE WHEN t.record_name = '@' THEN 0 ELSE 1 END, t.record_name`, zoneID, userID)
if err != nil {
return nil, err
}
@@ -691,8 +793,209 @@ func (s *Store) MarkZoneSynced(ctx context.Context, id int64, lastError string)
return err
}
func (s *Store) CreatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) (model.PiHoleIntegration, error) {
timestamp := now()
result, err := s.db.ExecContext(ctx, `INSERT INTO pihole_integrations(name, base_url, password_ciphertext, password_nonce, insecure_tls, created_at, updated_at)
SELECT ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM pihole_integrations)`,
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), timestamp, timestamp)
if err != nil {
return model.PiHoleIntegration{}, err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return model.PiHoleIntegration{}, ErrPiHoleAlreadyConfigured
}
id, err := result.LastInsertId()
if err != nil {
return model.PiHoleIntegration{}, err
}
return s.GetPiHoleIntegration(ctx, id)
}
func (s *Store) GetPiHoleIntegration(ctx context.Context, id int64) (model.PiHoleIntegration, error) {
return scanPiHoleIntegration(s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE id = ?`, id))
}
func (s *Store) GetPiHoleIntegrationWithCredential(ctx context.Context, id int64) (model.PiHoleIntegration, model.PiHoleCredential, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error, password_ciphertext, password_nonce FROM pihole_integrations WHERE id = ?`, id)
var integration model.PiHoleIntegration
var credential model.PiHoleCredential
var insecureTLS int
var createdAt, updatedAt, lastSyncAt int64
err := row.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError, &credential.Ciphertext, &credential.Nonce)
integration.InsecureTLS = intBool(insecureTLS)
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
return integration, credential, err
}
func (s *Store) ListPiHoleIntegrations(ctx context.Context) ([]model.PiHoleIntegration, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations ORDER BY name COLLATE NOCASE, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var integrations []model.PiHoleIntegration
for rows.Next() {
integration, scanErr := scanPiHoleIntegration(rows)
if scanErr != nil {
return nil, scanErr
}
integrations = append(integrations, integration)
}
return integrations, rows.Err()
}
func (s *Store) UpdatePiHoleIntegration(ctx context.Context, integration model.PiHoleIntegration, credential model.PiHoleCredential) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET name = ?, base_url = ?, password_ciphertext = ?, password_nonce = ?, insecure_tls = ?, updated_at = ?, last_error = '' WHERE id = ?`,
integration.Name, integration.BaseURL, credential.Ciphertext, credential.Nonce, boolInt(integration.InsecureTLS), now(), integration.ID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) MarkPiHoleSynced(ctx context.Context, id int64, lastError string) error {
_, err := s.db.ExecContext(ctx, `UPDATE pihole_integrations SET last_sync_at = ?, last_error = ? WHERE id = ?`, now(), lastError, id)
return err
}
func (s *Store) DeletePiHoleIntegration(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_integrations WHERE id = ?`, id)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) SyncPiHoleRecordLinks(ctx context.Context, integrationID int64, remoteValues []string) ([]model.PiHoleRecordLink, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ?`, integrationID)
if err != nil {
return nil, err
}
existing := make(map[string]model.PiHoleRecordLink)
for rows.Next() {
link, scanErr := scanPiHoleRecordLink(rows)
if scanErr != nil {
_ = rows.Close()
return nil, scanErr
}
existing[link.Value] = link
}
if err := rows.Close(); err != nil {
return nil, err
}
remote := make(map[string]bool, len(remoteValues))
for _, value := range remoteValues {
value = strings.TrimSpace(value)
if value == "" || remote[value] {
continue
}
remote[value] = true
if _, found := existing[value]; !found {
timestamp := now()
if _, err := tx.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at) VALUES(?, 1, ?, ?, ?)`, integrationID, value, timestamp, timestamp); err != nil {
return nil, err
}
}
}
for value, link := range existing {
active := remote[value]
if link.Active != active {
if _, err := tx.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), now(), link.ID, integrationID); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.ListPiHoleRecordLinks(ctx, integrationID)
}
func (s *Store) ListPiHoleRecordLinks(ctx context.Context, integrationID int64) ([]model.PiHoleRecordLink, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE integration_id = ? ORDER BY value COLLATE NOCASE, id`, integrationID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []model.PiHoleRecordLink
for rows.Next() {
link, scanErr := scanPiHoleRecordLink(rows)
if scanErr != nil {
return nil, scanErr
}
links = append(links, link)
}
return links, rows.Err()
}
func (s *Store) GetPiHoleRecordLink(ctx context.Context, integrationID, linkID int64) (model.PiHoleRecordLink, error) {
return scanPiHoleRecordLink(s.db.QueryRowContext(ctx, `SELECT id, integration_id, active, value, created_at, updated_at, archived_at FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID))
}
func (s *Store) LinkPiHoleRecord(ctx context.Context, integrationID int64, value string, active bool) error {
timestamp := now()
_, err := s.db.ExecContext(ctx, `INSERT INTO pihole_record_links(integration_id, active, value, created_at, updated_at, archived_at) VALUES(?, ?, ?, ?, ?, 0)
ON CONFLICT(integration_id, value) DO UPDATE SET active = excluded.active, updated_at = excluded.updated_at, archived_at = 0`, integrationID, boolInt(active), value, timestamp, timestamp)
return err
}
func (s *Store) UpdatePiHoleRecordLink(ctx context.Context, integrationID, linkID int64, value string, active bool) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET active = ?, value = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, boolInt(active), value, now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) ArchivePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = ?, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) RestorePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `UPDATE pihole_record_links SET archived_at = 0, updated_at = ? WHERE id = ? AND integration_id = ?`, now(), linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) DeletePiHoleRecordLink(ctx context.Context, integrationID, linkID int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM pihole_record_links WHERE id = ? AND integration_id = ?`, linkID, integrationID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) ListTargets(ctx context.Context, zoneID int64) ([]model.Target, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, record_name, fqdn, update_ipv4, update_ipv6, active, static_ipv4, static_ipv6, description, ttl, last_updated_at, last_status, last_error, created_at, archived_at FROM targets WHERE zone_id = ? AND archived_at = 0 ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
if err != nil {
return nil, err
}
@@ -819,7 +1122,7 @@ func (s *Store) RestoreTarget(ctx context.Context, id int64) error {
}
func (s *Store) ListCNAMERecords(ctx context.Context, zoneID int64) ([]model.CNAMERecord, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
rows, err := s.db.QueryContext(ctx, `SELECT id, zone_id, active, record_name, fqdn, destination, description, ttl, created_at, updated_at FROM cname_records WHERE zone_id = ? ORDER BY active DESC, CASE WHEN record_name = '@' THEN 0 ELSE 1 END, record_name`, zoneID)
if err != nil {
return nil, err
}
@@ -1016,6 +1319,7 @@ func (s *Store) CountErrors(ctx context.Context) (int, error) {
err := s.db.QueryRowContext(ctx, `SELECT
(SELECT COUNT(*) FROM targets WHERE last_error != '') +
(SELECT COUNT(*) FROM zones WHERE last_error != '') +
(SELECT COUNT(*) FROM pihole_integrations WHERE last_error != '') +
(SELECT COUNT(*) FROM update_runs WHERE status IN ('error', 'partial'))`).Scan(&count)
return count, err
}
@@ -1032,6 +1336,9 @@ func (s *Store) ClearErrors(ctx context.Context) error {
if _, err := tx.ExecContext(ctx, `UPDATE zones SET last_error = '' WHERE last_error != ''`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE pihole_integrations SET last_error = '' WHERE last_error != ''`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM update_runs WHERE status IN ('error', 'partial')`); err != nil {
return err
}
@@ -1044,6 +1351,23 @@ func (s *Store) ClearErrors(ctx context.Context) error {
return tx.Commit()
}
func (s *Store) ListPiHoleIntegrationsWithErrors(ctx context.Context) ([]model.PiHoleIntegration, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, base_url, insecure_tls, created_at, updated_at, last_sync_at, last_error FROM pihole_integrations WHERE last_error != '' ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var integrations []model.PiHoleIntegration
for rows.Next() {
integration, scanErr := scanPiHoleIntegration(rows)
if scanErr != nil {
return nil, scanErr
}
integrations = append(integrations, integration)
}
return integrations, rows.Err()
}
func (s *Store) AddAudit(ctx context.Context, kind, message string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), kind, message)
return err
@@ -1159,6 +1483,26 @@ func scanZone(scanner interface{ Scan(...any) error }) (model.Zone, error) {
return zone, err
}
func scanPiHoleIntegration(scanner interface{ Scan(...any) error }) (model.PiHoleIntegration, error) {
var integration model.PiHoleIntegration
var insecureTLS int
var createdAt, updatedAt, lastSyncAt int64
err := scanner.Scan(&integration.ID, &integration.Name, &integration.BaseURL, &insecureTLS, &createdAt, &updatedAt, &lastSyncAt, &integration.LastError)
integration.InsecureTLS = intBool(insecureTLS)
integration.CreatedAt, integration.UpdatedAt, integration.LastSyncAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(lastSyncAt)
return integration, err
}
func scanPiHoleRecordLink(scanner interface{ Scan(...any) error }) (model.PiHoleRecordLink, error) {
var link model.PiHoleRecordLink
var active int
var createdAt, updatedAt, archivedAt int64
err := scanner.Scan(&link.ID, &link.IntegrationID, &active, &link.Value, &createdAt, &updatedAt, &archivedAt)
link.Active = intBool(active)
link.CreatedAt, link.UpdatedAt, link.ArchivedAt = fromUnix(createdAt), fromUnix(updatedAt), fromUnix(archivedAt)
return link, err
}
func scanTarget(scanner interface{ Scan(...any) error }) (model.Target, error) {
var target model.Target
var updateIPv4, updateIPv6, active int
+116 -5
View File
@@ -2,6 +2,8 @@ package store
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
@@ -10,6 +12,32 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
)
func TestOpenHardensDataDirectoryAndDatabasePermissions(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
databasePath := filepath.Join(dataDir, "app.db")
database, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
dirInfo, err := os.Stat(dataDir)
if err != nil {
t.Fatal(err)
}
dbInfo, err := os.Stat(databasePath)
if err != nil {
t.Fatal(err)
}
if dirInfo.Mode().Perm() != 0o700 || dbInfo.Mode().Perm() != 0o600 {
t.Fatalf("insecure storage permissions: dir=%o database=%o", dirInfo.Mode().Perm(), dbInfo.Mode().Perm())
}
}
func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -45,10 +73,14 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
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})
other, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "aaa", FQDN: "aaa.example.de", UpdateIPv4: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
orderedTargets, err := database.ListTargets(ctx, zone.ID)
if err != nil || len(orderedTargets) != 2 || orderedTargets[0].ID != home.ID || orderedTargets[1].ID != other.ID {
t.Fatalf("active domain target was not sorted before inactive target: %#v err=%v", orderedTargets, err)
}
if err := database.ReplaceAccessGrants(ctx, member.ID, AccessSelection{TargetIDs: []int64{home.ID}, ManageTargetIDs: []int64{home.ID}}); err != nil {
t.Fatal(err)
@@ -74,7 +106,7 @@ func TestUserAccessCanBeScopedToTargetOrDomain(t *testing.T) {
t.Fatal(err)
}
targets, err = database.ListAccessibleTargets(ctx, member.ID, zone.ID)
if err != nil || len(targets) != 2 {
if err != nil || len(targets) != 2 || targets[0].ID != home.ID || targets[1].ID != other.ID {
t.Fatalf("domain grant did not include all targets: %d, %v", len(targets), err)
}
canCreate, err := database.CanCreateTarget(ctx, member.ID, zone.ID)
@@ -136,6 +168,82 @@ func TestInactiveUserSessionIsRejected(t *testing.T) {
}
}
func TestPiHoleIntegrationsPersistEncryptedCredentialsAndStatus(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() })
created, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole Zuhause", BaseURL: "https://pi.hole/api", InsecureTLS: true}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")})
if err != nil {
t.Fatal(err)
}
if _, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Zweites Pi-hole", BaseURL: "https://second.pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("encrypted"), Nonce: []byte("nonce")}); !errors.Is(err, ErrPiHoleAlreadyConfigured) {
t.Fatalf("second Pi-hole integration was not rejected: %v", err)
}
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, created.ID)
if err != nil || loaded.Name != "Pi-hole Zuhause" || loaded.BaseURL != "https://pi.hole/api" || !loaded.InsecureTLS || string(credential.Ciphertext) != "encrypted" {
t.Fatalf("Pi-hole integration not persisted: %#v %#v err=%v", loaded, credential, err)
}
loaded.Name = "Pi-hole Büro"
loaded.InsecureTLS = false
if err := database.UpdatePiHoleIntegration(ctx, loaded, model.PiHoleCredential{Ciphertext: []byte("new-encrypted"), Nonce: []byte("new-nonce")}); err != nil {
t.Fatal(err)
}
if err := database.MarkPiHoleSynced(ctx, loaded.ID, "connection failed"); err != nil {
t.Fatal(err)
}
integrations, err := database.ListPiHoleIntegrations(ctx)
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole Büro" || integrations[0].LastError != "connection failed" || integrations[0].LastSyncAt.IsZero() {
t.Fatalf("Pi-hole integration status not listed: %#v err=%v", integrations, err)
}
links, err := database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.20 nas.lan", "192.168.1.30 printer.lan"})
if err != nil || len(links) != 2 || !links[0].Active || !links[1].Active {
t.Fatalf("Pi-hole record links not synchronized: %#v err=%v", links, err)
}
firstID, secondID := links[0].ID, links[1].ID
if err := database.ArchivePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
t.Fatal(err)
}
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
if err != nil || len(links) != 2 || links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record link not archived: %#v err=%v", links, err)
}
if err := database.RestorePiHoleRecordLink(ctx, loaded.ID, firstID); err != nil {
t.Fatal(err)
}
if err := database.UpdatePiHoleRecordLink(ctx, loaded.ID, firstID, "192.168.1.21 nas.lan", false); err != nil {
t.Fatal(err)
}
if err := database.DeletePiHoleRecordLink(ctx, loaded.ID, secondID); err != nil {
t.Fatal(err)
}
links, err = database.ListPiHoleRecordLinks(ctx, loaded.ID)
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.21 nas.lan" || links[0].Active || !links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record link lifecycle failed: %#v err=%v", links, err)
}
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, []string{"192.168.1.21 nas.lan"})
if err != nil || len(links) != 1 || !links[0].Active {
t.Fatalf("remote Pi-hole record did not become active: %#v err=%v", links, err)
}
links, err = database.SyncPiHoleRecordLinks(ctx, loaded.ID, nil)
if err != nil || len(links) != 1 || links[0].Active {
t.Fatalf("missing remote Pi-hole record was deleted instead of retained as inactive: %#v err=%v", links, err)
}
if err := database.DeletePiHoleIntegration(ctx, loaded.ID); err != nil {
t.Fatal(err)
}
if _, err := database.GetPiHoleIntegration(ctx, loaded.ID); err == nil {
t.Fatal("deleted Pi-hole integration still exists")
}
if links, err := database.ListPiHoleRecordLinks(ctx, loaded.ID); err != nil || len(links) != 0 {
t.Fatalf("Pi-hole record links were not removed with integration: %#v err=%v", links, err)
}
}
func TestIPv6TargetsCanBeDisabledAndTargetCanBeDeleted(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -263,7 +371,7 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
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})
created, err := database.CreateCNAMERecord(ctx, model.CNAMERecord{ZoneID: zone.ID, Active: true, RecordName: "aaa", FQDN: "aaa.example.de", Destination: "app.example.net.", Description: "Website", TTL: 300})
if err != nil {
t.Fatal(err)
}
@@ -277,18 +385,21 @@ func TestCNAMERecordsCanBeStoredAndReplacedFromProviderImport(t *testing.T) {
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 {
if err := database.ReplaceCNAMERecords(ctx, zone.ID, []model.CNAMERecord{{ZoneID: zone.ID, RecordName: "zzz", FQDN: "zzz.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)
}
if records[0].RecordName != "zzz" || records[1].RecordName != "aaa" {
t.Fatalf("active CNAME was not sorted before inactive CNAME: %#v", records)
}
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 {
if byName["aaa"].Active || !byName["zzz"].Active || byName["zzz"].Destination != "assets.example.net." || byName["zzz"].TTL != 120 {
t.Fatalf("inactive/local CNAME lifecycle was not preserved: %#v", byName)
}
}
+655
View File
@@ -0,0 +1,655 @@
package web
import (
"context"
"errors"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/net/idna"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type piHoleView struct {
Integration model.PiHoleIntegration
Records []piHoleRecordView
ArchivedRecords []piHoleRecordView
FetchError string
}
type piHoleRecordView struct {
LinkID int64
Active bool
Raw string
IP string
Hostnames string
Valid bool
ArchivedAt time.Time
}
func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
if err != nil {
s.serverError(w, err)
return
}
var view *piHoleView
if len(integrations) > 0 {
integration := integrations[0]
current := piHoleView{Integration: integration}
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
var links []model.PiHoleRecordLink
if accessErr == nil {
var hosts []string
hosts, accessErr = client.ListHosts(r.Context(), password)
if accessErr == nil {
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
}
}
if accessErr != nil {
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
}
for _, link := range links {
record := parsePiHoleRecord(link.Value)
record.LinkID = link.ID
record.Active = link.Active
record.ArchivedAt = link.ArchivedAt
if link.ArchivedAt.IsZero() {
current.Records = append(current.Records, record)
} else {
current.ArchivedRecords = append(current.ArchivedRecords, record)
}
}
if accessErr != nil {
current.FetchError = safeMessage(accessErr)
current.Integration.LastError = current.FetchError
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError)
} else {
current.Integration.LastError = ""
current.Integration.LastSyncAt = time.Now().UTC()
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
}
sortPiHoleRecords(current.Records)
sortPiHoleRecords(current.ArchivedRecords)
view = &current
}
s.render(w, "pihole", viewData{
Title: "Pi-hole",
Page: "pihole",
CSRF: session.CSRFToken,
Flash: notice(r),
Now: time.Now(),
User: userFromSession(session),
IsAdmin: true,
PiHoleView: view,
})
}
func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
if err != nil {
s.serverError(w, err)
return
}
if len(integrations) > 0 {
s.redirectNotice(w, r, "/pihole", "Es ist bereits ein Pi-hole verbunden. Bearbeite die bestehende Verbindung oder entferne sie zuerst.", "error")
return
}
const name = "Pi-hole"
password := strings.TrimSpace(r.FormValue("password"))
if password == "" || len(password) > 1024 {
s.redirectNotice(w, r, "/pihole", "Ein gültiges Pi-hole-Anwendungspasswort ist erforderlich.", "error")
return
}
insecureTLS := checked(r, "insecure_tls")
client, err := pihole.New(r.FormValue("base_url"), insecureTLS)
if err != nil {
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
return
}
ciphertext, nonce, err := s.cipher.Encrypt(password)
if err != nil {
s.serverError(w, err)
return
}
integration, err := s.store.CreatePiHoleIntegration(r.Context(), model.PiHoleIntegration{Name: name, BaseURL: client.BaseURL(), InsecureTLS: insecureTLS}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
if err != nil {
message := "Pi-hole-Verbindung konnte nicht gespeichert werden: " + safeMessage(err)
if errors.Is(err, store.ErrPiHoleAlreadyConfigured) {
message = "Es ist bereits ein Pi-hole verbunden."
}
s.redirectNotice(w, r, "/pihole", message, "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.")
}
func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
password := strings.TrimSpace(r.FormValue("password"))
if password == "" {
password, err = s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
s.serverError(w, err)
return
}
}
if len(password) > 1024 {
s.redirectNotice(w, r, piHoleURL(id), "Das Anwendungspasswort ist zu lang.", "error")
return
}
integration.InsecureTLS = checked(r, "insecure_tls")
client, err := pihole.New(r.FormValue("base_url"), integration.InsecureTLS)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
return
}
integration.BaseURL = client.BaseURL()
ciphertext, nonce, err := s.cipher.Encrypt(password)
if err != nil {
s.serverError(w, err)
return
}
if err := s.store.UpdatePiHoleIntegration(r.Context(), integration, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce}); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.")
}
func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
return
}
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
s.serverError(w, err)
return
}
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
_ = s.store.AddAudit(r.Context(), "pihole.credential_revealed", "Pi-hole-Anwendungspasswort angezeigt durch "+session.Username)
s.render(w, "token", viewData{
Title: "Pi-hole-Passwort anzeigen",
Page: "pihole",
CSRF: session.CSRFToken,
SecretTitle: "Pi-hole-Anwendungspasswort",
SecretScope: "Pi-hole: " + integration.BaseURL,
SecretValue: password,
SecretBackURL: "/pihole#pihole-connection",
User: userFromSession(session),
IsAdmin: true,
Now: time.Now(),
})
}
func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
client, password, err := s.piHoleAccess(r.Context(), id)
if err == nil {
_, err = client.ListHosts(r.Context(), password)
}
if err != nil {
message := safeMessage(err)
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
}
func (s *Server) piHoleRemove(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
_ = s.store.AddAudit(r.Context(), "pihole.removed", "Pi-hole-Verbindung entfernt: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung entfernt; lokale DNS-Einträge auf dem Pi-hole blieben unverändert.")
}
func (s *Server) piHoleDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
client, password, err := s.piHoleAccess(r.Context(), id)
var hosts []string
if err == nil {
hosts, err = client.ListHosts(r.Context(), password)
}
if err == nil {
err = client.DeleteHosts(r.Context(), password, hosts)
}
if err != nil {
s.piHoleOperationError(w, r, id, "Pi-hole und lokale DNS-Einträge konnten nicht vollständig gelöscht werden", err)
return
}
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
_ = s.store.AddAudit(r.Context(), "pihole.deleted", "Pi-hole-Verbindung und lokale DNS-Einträge gelöscht: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung und alle lokalen DNS-Einträge wurden vollständig gelöscht.")
}
func (s *Server) piHoleRecordCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
value, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
active := checked(r, "active")
if active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
accessErr = client.AddHost(r.Context(), password, value)
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht angelegt werden", accessErr)
return
}
}
if err := s.store.LinkPiHoleRecord(r.Context(), id, value, active); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_created", "Lokalen DNS-Eintrag angelegt: "+value+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag angelegt"+piHoleActivitySuffix(active)+".")
}
func (s *Server) piHoleRecordUpdate(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
previous, err := validatePiHoleRawRecord(link.Value)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
replacement, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
active := checked(r, "active")
if link.Active || active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
switch {
case link.Active && active:
accessErr = client.ReplaceHost(r.Context(), password, previous, replacement)
case link.Active:
accessErr = client.DeleteHost(r.Context(), password, previous)
default:
accessErr = client.AddHost(r.Context(), password, replacement)
}
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht geändert werden", accessErr)
return
}
}
if err := s.store.UpdatePiHoleRecordLink(r.Context(), id, link.ID, replacement, active); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_updated", "Lokalen DNS-Eintrag geändert: "+previous+" → "+replacement+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag gespeichert"+piHoleActivitySuffix(active)+".")
}
func (s *Server) piHoleRecordDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
value, err := validatePiHoleRawRecord(link.Value)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
if link.Active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
accessErr = client.DeleteHost(r.Context(), password, value)
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht gelöscht werden", accessErr)
return
}
}
if err := s.store.DeletePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_deleted", "Lokalen DNS-Eintrag gelöscht: "+value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag bei Pi-hole gelöscht.")
}
func (s *Server) piHoleRecordArchive(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
if err := s.store.ArchivePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht aus der WebUI entfernt werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "pihole.record_archived", "Lokalen DNS-Eintrag aus der WebUI entfernt: "+link.Value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Eintrag aus der WebUI entfernt; bei Pi-hole blieb er unverändert.")
}
func (s *Server) piHoleRecordRestore(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
if err := s.store.RestorePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht wiederhergestellt werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "pihole.record_restored", "Lokalen DNS-Eintrag wieder in der WebUI verknüpft: "+link.Value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Eintrag wieder mit der WebUI verknüpft.")
}
func (s *Server) piHoleRecordLink(w http.ResponseWriter, r *http.Request, integrationID int64) (model.PiHoleRecordLink, bool) {
linkID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("link_id")), 10, 64)
if err != nil || linkID <= 0 {
http.Error(w, "Ungültiger Pi-hole-Eintrag", http.StatusBadRequest)
return model.PiHoleRecordLink{}, false
}
link, err := s.store.GetPiHoleRecordLink(r.Context(), integrationID, linkID)
if err != nil {
http.NotFound(w, r)
return model.PiHoleRecordLink{}, false
}
return link, true
}
func piHoleActivitySuffix(active bool) string {
if active {
return " und bei Pi-hole aktiviert"
}
return " und bei Pi-hole deaktiviert"
}
func (s *Server) piHoleAccess(ctx context.Context, id int64) (*pihole.Client, string, error) {
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(ctx, id)
if err != nil {
return nil, "", err
}
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return nil, "", err
}
client, err := pihole.New(integration.BaseURL, integration.InsecureTLS)
return client, password, err
}
func (s *Server) piHoleOperationError(w http.ResponseWriter, r *http.Request, id int64, prefix string, err error) {
message := piHoleErrorMessage(err)
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
s.redirectNotice(w, r, piHoleURL(id), prefix+": "+message, "error")
}
func piHoleErrorMessage(err error) string {
var apiErr *pihole.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden && strings.Contains(strings.ToLower(apiErr.Message), "read-only") {
return "Pi-hole erlaubt dieser API-Sitzung nur Lesezugriff. Aktiviere für das Anwendungspasswort webserver.api.app_sudo=true und stelle sicher, dass misc.readOnly=false ist"
}
return safeMessage(err)
}
func normalizePiHoleRecord(rawIP, rawHostnames string) (string, error) {
address, err := netip.ParseAddr(strings.TrimSpace(rawIP))
if err != nil {
return "", errors.New("Bitte eine gültige IPv4- oder IPv6-Adresse angeben")
}
address = address.Unmap()
hostnames, err := normalizePiHoleHostnames(rawHostnames)
if err != nil {
return "", err
}
return address.String() + " " + strings.Join(hostnames, " "), nil
}
func normalizePiHoleHostnames(raw string) ([]string, error) {
values := strings.Fields(strings.ToLower(strings.TrimSpace(raw)))
if len(values) == 0 || len(values) > 20 {
return nil, errors.New("Bitte mindestens einen und höchstens 20 Hostnamen angeben")
}
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSuffix(value, ".")
ascii, err := idna.Lookup.ToASCII(value)
if err != nil || ascii == "" || len(ascii) > 253 {
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
}
for _, character := range label {
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '-' {
return nil, errors.New("Hostnamen dürfen nur Buchstaben, Zahlen, Punkte und Bindestriche enthalten")
}
}
}
if !seen[ascii] {
seen[ascii] = true
result = append(result, ascii)
}
}
return result, nil
}
func validatePiHoleRawRecord(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" || len(value) > 2048 {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
for _, character := range value {
if unicode.IsControl(character) {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
}
fields := strings.Fields(value)
if len(fields) < 2 {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
if _, err := netip.ParseAddr(fields[0]); err != nil {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
if _, err := normalizePiHoleHostnames(strings.Join(fields[1:], " ")); err != nil {
return "", err
}
return value, nil
}
func parsePiHoleRecord(raw string) piHoleRecordView {
view := piHoleRecordView{Raw: raw}
fields := strings.Fields(raw)
if len(fields) < 2 {
view.Hostnames = raw
return view
}
if _, err := netip.ParseAddr(fields[0]); err != nil {
view.Hostnames = strings.Join(fields[1:], " ")
return view
}
view.IP = fields[0]
view.Hostnames = strings.Join(fields[1:], " ")
view.Valid = true
return view
}
func sortPiHoleRecords(records []piHoleRecordView) {
sort.Slice(records, func(left, right int) bool {
if records[left].Active != records[right].Active {
return records[left].Active
}
return strings.ToLower(records[left].Hostnames) < strings.ToLower(records[right].Hostnames)
})
}
func piHoleURL(_ int64) string {
return "/pihole#local-dns"
}
+43 -4
View File
@@ -57,7 +57,10 @@ type viewData struct {
ArchivedTargets []model.Target
CNAMERecords []model.CNAMERecord
Settings model.Settings
Token string
SecretTitle string
SecretScope string
SecretValue string
SecretBackURL string
Form map[string]string
Users []model.User
ManagedUser model.User
@@ -68,7 +71,9 @@ type viewData struct {
ErrorTargets []model.Target
ErrorZones []model.Zone
ErrorRuns []model.UpdateRun
ErrorPiHoles []model.PiHoleIntegration
ErrorCount int
PiHoleView *piHoleView
}
type flashMessage struct {
@@ -124,6 +129,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.cnameSave))
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.cnameRemove))
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.cnameDelete))
mux.Handle("GET /pihole", s.adminOnly(s.piHolePage))
mux.Handle("POST /pihole/connect", s.adminOnly(s.piHoleConnect))
mux.Handle("POST /pihole/{id}/save", s.adminOnly(s.piHoleSave))
mux.Handle("POST /pihole/{id}/password", s.adminOnly(s.piHolePassword))
mux.Handle("POST /pihole/{id}/refresh", s.adminOnly(s.piHoleRefresh))
mux.Handle("POST /pihole/{id}/remove", s.adminOnly(s.piHoleRemove))
mux.Handle("POST /pihole/{id}/delete", s.adminOnly(s.piHoleDelete))
mux.Handle("POST /pihole/{id}/records", s.adminOnly(s.piHoleRecordCreate))
mux.Handle("POST /pihole/{id}/records/update", s.adminOnly(s.piHoleRecordUpdate))
mux.Handle("POST /pihole/{id}/records/delete", s.adminOnly(s.piHoleRecordDelete))
mux.Handle("POST /pihole/{id}/records/archive", s.adminOnly(s.piHoleRecordArchive))
mux.Handle("POST /pihole/{id}/records/restore", s.adminOnly(s.piHoleRecordRestore))
mux.Handle("GET /settings", s.adminOnly(s.settingsPage))
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
mux.Handle("POST /run", s.adminOnly(s.runNow))
@@ -640,7 +657,18 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
}
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
_ = s.store.AddAudit(r.Context(), "credential.revealed", "API-Token angezeigt: "+zone.Name)
s.render(w, "token", viewData{Title: "API-Token anzeigen", Page: "domains", CSRF: session.CSRFToken, Zone: zone, Token: token, User: userFromSession(session), IsAdmin: true, Now: time.Now()})
s.render(w, "token", viewData{
Title: "API-Token anzeigen",
Page: "domains",
CSRF: session.CSRFToken,
SecretTitle: "Hetzner API-Token",
SecretScope: "Zone: " + zone.Name,
SecretValue: token,
SecretBackURL: domainURL(zone.ID),
User: userFromSession(session),
IsAdmin: true,
Now: time.Now(),
})
}
func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1228,7 +1256,12 @@ func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *mod
s.serverError(w, err)
return
}
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorCount: len(targets) + len(zones) + len(runs), Now: time.Now()})
piHoles, err := s.store.ListPiHoleIntegrationsWithErrors(r.Context())
if err != nil {
s.serverError(w, err)
return
}
s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorPiHoles: piHoles, ErrorCount: len(targets) + len(zones) + len(runs) + len(piHoles), Now: time.Now()})
}
func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) {
@@ -1727,16 +1760,21 @@ func (s *Server) redirectNotice(w http.ResponseWriter, r *http.Request, target,
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if s.config.CookieSecure {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func loadTemplates() (map[string]*template.Template, error) {
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
functions := template.FuncMap{
"formatTime": func(value time.Time) string {
if value.IsZero() {
@@ -1755,6 +1793,7 @@ func loadTemplates() (map[string]*template.Template, error) {
"statusClass": statusClass,
"ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} },
"supportedTTL": supportedTTL,
"contains": strings.Contains,
"families": func(target model.Target) string {
var result []string
if target.UpdateIPv4 {
+425
View File
@@ -2,13 +2,22 @@ package web
import (
"bytes"
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestInputNormalizers(t *testing.T) {
@@ -70,6 +79,71 @@ func TestInputNormalizers(t *testing.T) {
}
}
func TestPiHoleReadOnlyErrorExplainsRequiredWritePermission(t *testing.T) {
t.Parallel()
message := piHoleErrorMessage(&pihole.APIError{Status: http.StatusForbidden, Key: "forbidden", Message: "Unable to change configuration (read-only)"})
for _, expected := range []string{"webserver.api.app_sudo=true", "misc.readOnly=false"} {
if !strings.Contains(message, expected) {
t.Errorf("actionable Pi-hole read-only message is missing %q: %s", expected, message)
}
}
}
func TestSecurityHeadersProtectSensitiveResponses(t *testing.T) {
t.Parallel()
server := &Server{config: config.Config{CookieSecure: true}}
handler := server.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
for name, expected := range map[string]string{
"Content-Security-Policy": "default-src 'self'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Strict-Transport-Security": "max-age=31536000",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
} {
if !strings.Contains(response.Header().Get(name), expected) {
t.Errorf("security header %s=%q does not contain %q", name, response.Header().Get(name), expected)
}
}
}
func TestPiHoleRecordNormalization(t *testing.T) {
t.Parallel()
value, err := normalizePiHoleRecord("192.168.1.20", "NAS.LAN nas")
if err != nil || value != "192.168.1.20 nas.lan nas" {
t.Fatalf("Pi-hole IPv4 record normalization failed: %q err=%v", value, err)
}
value, err = normalizePiHoleRecord("2001:0db8::20", "drucker.lan")
if err != nil || value != "2001:db8::20 drucker.lan" {
t.Fatalf("Pi-hole IPv6 record normalization failed: %q err=%v", value, err)
}
for _, invalid := range [][2]string{{"not-an-ip", "nas.lan"}, {"192.168.1.20", "bad_name"}, {"192.168.1.20", ""}} {
if _, err := normalizePiHoleRecord(invalid[0], invalid[1]); err == nil {
t.Errorf("invalid Pi-hole record accepted: %#v", invalid)
}
}
if raw, err := validatePiHoleRawRecord("192.168.1.20 nas.lan"); err != nil || raw != "192.168.1.20 nas.lan" {
t.Fatalf("valid raw Pi-hole record rejected: %q err=%v", raw, err)
}
}
func TestPiHoleRecordsSortActiveEntriesFirst(t *testing.T) {
t.Parallel()
records := []piHoleRecordView{
{Hostnames: "aaa.lan", Active: false},
{Hostnames: "zzz.lan", Active: true},
{Hostnames: "bbb.lan", Active: true},
}
sortPiHoleRecords(records)
if records[0].Hostnames != "bbb.lan" || records[1].Hostnames != "zzz.lan" || records[2].Hostnames != "aaa.lan" {
t.Fatalf("Pi-hole records not sorted by active state and hostname: %#v", records)
}
}
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
t.Parallel()
if got := unixSeconds(time.Time{}); got != 0 {
@@ -182,6 +256,11 @@ func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testi
t.Errorf("domain detail is missing %q", expected)
}
}
for _, removed := range []string{"Entfernt nur die lokale DynDNS-Verknüpfung", "Löscht das Ziel und alle zugehörigen A-/AAAA-Records", "Der CNAME-Record bei Hetzner bleibt unverändert"} {
if strings.Contains(body, removed) {
t.Errorf("domain record action still contains removed description %q", removed)
}
}
}
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
@@ -214,3 +293,349 @@ func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
t.Error("globally-disabled IPv6 checkbox remained enabled")
}
}
func TestPiHolePageRendersConnectionAndLocalDNSControls(t *testing.T) {
t.Parallel()
templates, err := loadTemplates()
if err != nil {
t.Fatalf("load templates: %v", err)
}
var rendered bytes.Buffer
data := viewData{
Title: "Pi-hole",
Page: "pihole",
CSRF: "csrf",
IsAdmin: true,
User: model.User{Username: "admin", DisplayName: "Administrator", Role: "admin"},
PiHoleView: &piHoleView{
Integration: model.PiHoleIntegration{ID: 7, Name: "Pi-hole", BaseURL: "https://pi.hole/api"},
Records: []piHoleRecordView{{LinkID: 11, Active: true, Raw: "192.168.1.20 nas.lan", IP: "192.168.1.20", Hostnames: "nas.lan", Valid: true}},
ArchivedRecords: []piHoleRecordView{{LinkID: 12, Active: false, Raw: "192.168.1.30 old.lan", IP: "192.168.1.30", Hostnames: "old.lan", Valid: true, ArchivedAt: time.Now()}},
},
}
if err := templates["pihole"].ExecuteTemplate(&rendered, "pihole", data); err != nil {
t.Fatalf("render Pi-hole page: %v", err)
}
body := rendered.String()
for _, expected := range []string{`href="/pihole"`, `aria-current="page"`, `action="/pihole/7/records"`, `action="/pihole/7/records/update"`, `action="/pihole/7/records/archive"`, `action="/pihole/7/records/restore"`, `action="/pihole/7/records/delete"`, `action="/pihole/7/remove"`, `action="/pihole/7/delete"`, `action="/pihole/7/save"`, `action="/pihole/7/password"`, `name="active"`, `name="link_id" value="11"`, `name="insecure_tls"`, `data-dirty-form`, `data-dirty-submit disabled aria-disabled="true"`, `Anwendungspasswort anzeigen`, `Passwort entschlüsseln`, `Nur aus der WebUI entfernen`, `Anwendungspasswort`, `nas.lan`, `old.lan`, `192.168.1.20`} {
if !strings.Contains(body, expected) {
t.Errorf("Pi-hole page is missing %q", expected)
}
}
if strings.Contains(body, "app-password") {
t.Fatal("Pi-hole application password leaked into rendered page")
}
if strings.Contains(body, `action="/pihole/connect"`) {
t.Fatal("connected Pi-hole page still offered a second integration")
}
if strings.Contains(body, ">A / AAAA<") {
t.Fatal("new Pi-hole record card still renders the removed A / AAAA badge")
}
if strings.Contains(body, "Schreibzugriff für das Anwendungspasswort") || strings.Contains(body, "Pi-hole benötigt webserver.api.app_sudo") {
t.Fatal("Pi-hole page still renders the permanent write-permission warning")
}
for _, removed := range []string{"Trennt nur die lokale Verknüpfung. Der DNS-Eintrag bei Pi-hole bleibt bestehen", "Löscht den Eintrag aus der WebUI und unmittelbar bei Pi-hole"} {
if strings.Contains(body, removed) {
t.Errorf("Pi-hole record action still contains removed description %q", removed)
}
}
}
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
t.Parallel()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"handler-sid","validity":1800,"message":"ok","totp":false}}`))
case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet && r.Header.Get("X-FTL-SID") == "handler-sid":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.20 nas.lan"]}}}`))
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete && r.Header.Get("X-FTL-SID") == "handler-sid":
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
keyFile := filepath.Join(dir, "master-key")
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)
}
server := &Server{store: database, cipher: cipher}
form := url.Values{
"csrf": {"csrf-token"},
"base_url": {api.URL},
"password": {"separate-app-password"},
}
request := httptest.NewRequest(http.MethodPost, "/pihole/connect", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
server.piHoleConnect(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
if response.Code != http.StatusSeeOther {
t.Fatalf("unexpected connect response: status=%d body=%s", response.Code, response.Body.String())
}
integrations, err := database.ListPiHoleIntegrations(ctx)
if err != nil || len(integrations) != 1 || integrations[0].Name != "Pi-hole" || integrations[0].BaseURL != api.URL+"/api" {
t.Fatalf("integration not stored after validation: %#v err=%v", integrations, err)
}
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
if err != nil {
t.Fatal(err)
}
decrypted, err := cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil || decrypted != "separate-app-password" || strings.Contains(string(credential.Ciphertext), "separate-app-password") {
t.Fatalf("application password not safely encrypted: decrypted=%q err=%v", decrypted, err)
}
}
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
adminHash, err := secure.HashPassword("correct admin password")
if err != nil {
t.Fatal(err)
}
if err := database.CreateAdmin(ctx, "admin", "Administrator", adminHash); err != nil {
t.Fatal(err)
}
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
keyFile := filepath.Join(dir, "master-key")
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)
}
const secret = "super-secret-pihole-app-password"
ciphertext, nonce, err := cipher.Encrypt(secret)
if err != nil {
t.Fatal(err)
}
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
if err != nil {
t.Fatal(err)
}
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
server := &Server{store: database, cipher: cipher, templates: templates}
session := &model.Session{UserID: 1, CSRFToken: "csrf-token", Username: "admin", DisplayName: "Administrator", Role: "admin"}
requestFor := func(password, csrf string) *http.Request {
form := url.Values{"csrf": {csrf}, "password": {password}}
request := httptest.NewRequest(http.MethodPost, "/pihole/1/password", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("id", "1")
return request
}
wrong := httptest.NewRecorder()
server.piHolePassword(wrong, requestFor("wrong admin password", "csrf-token"), session)
if wrong.Code != http.StatusSeeOther || strings.Contains(wrong.Body.String(), secret) || strings.Contains(wrong.Header().Get("Location"), secret) {
t.Fatalf("wrong admin password exposed secret or returned wrong status: status=%d location=%q", wrong.Code, wrong.Header().Get("Location"))
}
invalidCSRF := httptest.NewRecorder()
server.piHolePassword(invalidCSRF, requestFor("correct admin password", "wrong-csrf"), session)
if invalidCSRF.Code != http.StatusForbidden || strings.Contains(invalidCSRF.Body.String(), secret) {
t.Fatalf("invalid CSRF exposed secret or returned wrong status: status=%d", invalidCSRF.Code)
}
revealed := httptest.NewRecorder()
server.piHolePassword(revealed, requestFor("correct admin password", "csrf-token"), session)
if revealed.Code != http.StatusOK || !strings.Contains(revealed.Body.String(), secret) || !strings.Contains(revealed.Body.String(), "Pi-hole-Anwendungspasswort") {
t.Fatalf("valid reauthentication did not reveal secret: status=%d body=%s", revealed.Code, revealed.Body.String())
}
if cacheControl := revealed.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") {
t.Fatalf("secret response is cacheable: Cache-Control=%q", cacheControl)
}
loaded, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integration.ID)
if err != nil || loaded.ID != integration.ID || strings.Contains(string(credential.Ciphertext), secret) {
t.Fatalf("Pi-hole credential was not retained as ciphertext: %#v err=%v", loaded, err)
}
events, err := database.RecentAudit(ctx, 10)
if err != nil {
t.Fatal(err)
}
foundRevealAudit := false
for _, event := range events {
if strings.Contains(event.Message, secret) {
t.Fatalf("Pi-hole secret leaked into audit log: %#v", event)
}
if event.Kind == "pihole.credential_revealed" {
foundRevealAudit = true
}
}
if !foundRevealAudit {
t.Fatal("Pi-hole credential reveal was not audited")
}
}
func TestPiHoleRecordCanBeRemovedFromAndRestoredToWebUI(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() })
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: "https://pi.hole/api"}, model.PiHoleCredential{Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce")})
if err != nil {
t.Fatal(err)
}
const value = "192.168.1.20 nas.lan"
if _, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{value}); err != nil {
t.Fatal(err)
}
server := &Server{store: database}
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
requestFor := func(path string) *http.Request {
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}}
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("id", "1")
return request
}
response := httptest.NewRecorder()
server.piHoleRecordArchive(response, requestFor("/pihole/1/records/archive"), session)
if response.Code != http.StatusSeeOther {
t.Fatalf("unexpected archive response: status=%d body=%s", response.Code, response.Body.String())
}
links, err := database.ListPiHoleRecordLinks(ctx, integration.ID)
if err != nil || len(links) != 1 || links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record was not removed from active WebUI list: %#v err=%v", links, err)
}
response = httptest.NewRecorder()
server.piHoleRecordRestore(response, requestFor("/pihole/1/records/restore"), session)
if response.Code != http.StatusSeeOther {
t.Fatalf("unexpected restore response: status=%d body=%s", response.Code, response.Body.String())
}
links, err = database.ListPiHoleRecordLinks(ctx, integration.ID)
if err != nil || len(links) != 1 || !links[0].ArchivedAt.IsZero() {
t.Fatalf("Pi-hole record was not restored to active WebUI list: %#v err=%v", links, err)
}
}
func TestPiHoleRecordActiveSwitchDeletesAndRecreatesRemoteHost(t *testing.T) {
t.Parallel()
remoteHosts := map[string]bool{"192.168.1.20 nas.lan": true}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/auth" && r.Method == http.MethodPost:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"session":{"valid":true,"sid":"switch-sid","message":"ok"}}`))
case r.URL.Path == "/api/auth" && r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
case strings.HasPrefix(r.URL.Path, "/api/config/dns/hosts/") && r.Header.Get("X-FTL-SID") == "switch-sid":
value := strings.TrimPrefix(r.URL.Path, "/api/config/dns/hosts/")
switch r.Method {
case http.MethodPut:
remoteHosts[value] = true
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if !remoteHosts[value] {
http.NotFound(w, r)
return
}
delete(remoteHosts, value)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
}))
defer api.Close()
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
keyFile := filepath.Join(dir, "master-key")
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("app-password")
if err != nil {
t.Fatal(err)
}
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
if err != nil {
t.Fatal(err)
}
links, err := database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.20 nas.lan"})
if err != nil || len(links) != 1 {
t.Fatalf("record link setup failed: %#v err=%v", links, err)
}
server := &Server{store: database, cipher: cipher}
session := &model.Session{CSRFToken: "csrf-token", Username: "admin"}
update := func(ip string, active bool) *httptest.ResponseRecorder {
form := url.Values{"csrf": {"csrf-token"}, "link_id": {"1"}, "ip": {ip}, "hostnames": {"nas.lan"}}
if active {
form.Set("active", "on")
}
request := httptest.NewRequest(http.MethodPost, "/pihole/1/records/update", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("id", "1")
response := httptest.NewRecorder()
server.piHoleRecordUpdate(response, request, session)
return response
}
if response := update("192.168.1.20", false); response.Code != http.StatusSeeOther {
t.Fatalf("unexpected deactivate response: status=%d body=%s", response.Code, response.Body.String())
}
link, err := database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
if err != nil || link.Active || remoteHosts[link.Value] {
t.Fatalf("deactivated record remained active: %#v remote=%#v err=%v", link, remoteHosts, err)
}
if response := update("192.168.1.21", true); response.Code != http.StatusSeeOther {
t.Fatalf("unexpected activate response: status=%d body=%s", response.Code, response.Body.String())
}
link, err = database.GetPiHoleRecordLink(ctx, integration.ID, links[0].ID)
if err != nil || !link.Active || link.Value != "192.168.1.21 nas.lan" || !remoteHosts[link.Value] {
t.Fatalf("reactivated record was not recreated: %#v remote=%#v err=%v", link, remoteHosts, err)
}
}
+19
View File
@@ -179,6 +179,25 @@
});
}
for (const form of document.querySelectorAll('[data-dirty-form]')) {
const submit = form.querySelector('[data-dirty-submit]');
if (!submit) continue;
const state = () => new URLSearchParams(new FormData(form)).toString();
const initialState = state();
const syncDirtyState = () => {
const dirty = state() !== initialState;
submit.disabled = !dirty;
submit.setAttribute('aria-disabled', String(!dirty));
};
form.addEventListener('input', syncDirtyState);
form.addEventListener('change', syncDirtyState);
form.addEventListener('submit', (event) => {
syncDirtyState();
if (submit.disabled) event.preventDefault();
});
syncDirtyState();
}
const copyButtons = document.querySelectorAll('[data-copy-target]');
for (const button of copyButtons) {
button.addEventListener('click', async () => {
+33 -1
View File
@@ -234,6 +234,33 @@ h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
.admin-actions-grid { margin-top: 18px; }
.pihole-kicker { color: #a4121a; }
.button-pihole { border-color: #a4121a; background: #a4121a; color: #fff; }
.button-pihole:hover { border-color: #861016; background: #861016; color: #fff; }
.button-pihole:disabled, .button-pihole:disabled:hover { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; }
.button-pihole:focus-visible { outline: 3px solid rgba(164,18,26,.2); outline-offset: 2px; }
.pihole-summary { border-top: 3px solid #a4121a; }
.target-row.pihole-target-row { border-left-color: #b9c3ce; }
.target-row.pihole-target-row.is-active { border-left-color: #a4121a; }
.target-status.pihole-on { background: #a4121a; box-shadow: 0 0 0 3px rgba(164,18,26,.1); }
.pihole-status-pill { background: #fff0f1; color: #98131b; }
.pihole-switch input:checked + span { background: #a4121a; }
.pihole-record-form { gap: 6px; }
.pihole-record-form label:not(.switch) { gap: 2px; }
.pihole-record-form .form-grid { gap: 8px; align-items: start; }
.pihole-record-form .toggle-row { min-height: 24px; }
.pihole-record-form input { min-height: 43px; }
.pihole-record-save { min-height: 36px; justify-self: start; padding-inline: 11px; font-size: 11px; }
.pihole-target-row .edit-inner { gap: 10px; padding-block: 11px; }
.pihole-action-grid { grid-template-columns: repeat(2, minmax(0,1fr)); margin-top: 14px; }
.pihole-connection-error { margin-bottom: 18px; padding: 12px; border: 1px solid #efc7ce; border-left: 3px solid #a4121a; border-radius: var(--radius-sm); background: #fff8f9; }
.pihole-connection-error strong { color: var(--danger); font-size: 11px; }
.pihole-connection-error p { margin: 3px 0 0; color: #865764; font-size: 10px; overflow-wrap: anywhere; }
.pihole-connect-panel { max-width: 720px; margin-top: 20px; border-top: 3px solid #a4121a; scroll-margin-top: 18px; }
.pihole-api-badge { padding: 4px 7px; border-radius: 4px; background: #fff0f1; color: #98131b; font-family: var(--mono); font-size: 9px; font-weight: 800; }
.pihole-empty { margin-bottom: 18px; }
.pihole-empty-icon { border-color: #e4b7ba; background: #fff2f3; color: #9d1018; }
.stacked-form { display: grid; gap: 13px; }
.wide-form { width: 100%; max-width: 850px; }
.settings-form { display: grid; gap: 12px; }
@@ -268,6 +295,9 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.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.pihole-switch > span { width: 32px; height: 20px; }
.switch.pihole-switch > span::after { top: 4px; left: 4px; width: 12px; height: 12px; }
.switch.pihole-switch input:checked + span::after { transform: translateX(12px); }
.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; }
@@ -321,7 +351,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.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 { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
@@ -349,6 +379,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
.edit-inner { grid-template-columns: 1fr; }
.settings-mode-grid { grid-template-columns: 1fr; }
.pihole-action-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
@@ -388,6 +419,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
.error-summary-grid { grid-template-columns: 1fr; }
.error-item { grid-template-columns: 26px minmax(0,1fr); }
.error-item > a { grid-column: 2; }
.pihole-action-grid { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
@@ -41,12 +41,10 @@
<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}}
@@ -79,12 +77,10 @@
<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>
+14 -2
View File
@@ -1,7 +1,7 @@
{{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>
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle 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>
@@ -9,11 +9,12 @@
</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>
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder DNS- oder Pi-hole-Fehler 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>Pi-hole</span><strong>{{len .ErrorPiHoles}}</strong></article>
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
</section>
@@ -28,6 +29,17 @@
</section>
{{end}}
{{if .ErrorPiHoles}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Lokales Netzwerk</span><h2>Pi-hole-Probleme</h2></div><span class="status-pill status-bad">{{len .ErrorPiHoles}}</span></div>
<div class="error-list">
{{range .ErrorPiHoles}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}</small></div><a href="/pihole#local-dns">Öffnen →</a></article>
{{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>
+11 -10
View File
@@ -5,29 +5,30 @@
<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>
<title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-12">
<script src="/static/app.js?v=features-12" 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">
<a class="brand" href="/" aria-label="DNS Control Startseite">
<span class="brand-mark">DD</span>
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
<span><strong>DNS Control</strong><small>Hetzner + Pi-hole</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>
<a href="/pihole" {{if eq .Page "pihole"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Pi-hole</a>
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Updater</a>
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Fehler</a>
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">06</span>Benutzer</a>
{{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>
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}07{{else}}03{{end}}</span>Sicherheit</a>
</nav>
<div class="sidebar-account">
<span class="account-avatar">{{initial .User.DisplayName}}</span>
@@ -40,7 +41,7 @@
</aside>
<div class="workspace">
<header class="workspace-bar">
<div><span>DynDNS</span><strong>/ {{.Title}}</strong></div>
<div><span>DNS Control</span><strong>/ {{.Title}}</strong></div>
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
</header>
<main id="main" class="app-shell">
+90
View File
@@ -0,0 +1,90 @@
{{define "pihole"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><span class="page-kicker pihole-kicker">Lokales Netzwerk</span><h1>Pi-hole DNS</h1><p>Lokale A- und AAAA-Auflösungen ähnlich wie deine öffentlichen Domains verwalten.</p></div>
{{if .PiHoleView}}
<form action="/pihole/{{.PiHoleView.Integration.ID}}/refresh" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>
{{else}}
<a class="button button-pihole" href="#pihole-connect">Pi-hole verbinden</a>
{{end}}
</header>
{{if .PiHoleView}}
{{$view := .PiHoleView}}
{{$integration := $view.Integration}}
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime $integration.LastSyncAt}}</strong></div>
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
</section>
{{if $view.FetchError}}
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
{{end}}
<section class="targets-section" id="local-dns">
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
{{if $view.Records}}
<div class="target-list">
{{range $view.Records}}
<article class="target-row pihole-target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}pihole-on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.Hostnames}}</h3><p><span class="family-chip">{{if contains .IP ":"}}AAAA{{else}}A{{end}}</span><span>{{.IP}}</span></p></div></div>
<div class="target-meta"><span>Pi-hole DNS</span><span class="status-pill {{if .Active}}pihole-status-pill{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
{{if .Valid}}
<form action="/pihole/{{$integration.ID}}/records/update" method="post" class="target-form pihole-record-form" data-dirty-form>
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}">
<div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> Eintrag aktiv</label></div>
<div class="form-grid"><label>IP-Adresse<input name="ip" value="{{.IP}}" inputmode="text" required></label><label>Hostname(n)<input name="hostnames" value="{{.Hostnames}}" required></label></div>
<button class="button button-pihole button-compact pihole-record-save" type="submit" data-dirty-submit disabled aria-disabled="true">Speichern</button>
</form>
{{else}}
<p class="inline-error">Der vorhandene Pi-hole-Wert kann nicht strukturiert bearbeitet werden: <code>{{.Raw}}</code></p>
{{end}}
<div class="danger-actions">
<form action="/pihole/{{$integration.ID}}/records/archive" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Nur aus der WebUI entfernen</strong><button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button></form>
<form action="/pihole/{{$integration.ID}}/records/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><strong>Eintrag vollständig löschen</strong><button class="button button-danger-soft" type="submit">Eintrag löschen</button></form>
</div>
</div></details>
</article>
{{end}}
</div>
{{else}}<div class="empty-state">Keine verknüpften lokalen DNS-Einträge vorhanden. Lege unten einen neuen Eintrag an.</div>{{end}}
</section>
{{if $view.ArchivedRecords}}
<section class="panel archive-panel">
<div class="panel-heading"><div><span class="section-label pihole-kicker">Archiv</span><h2>Aus der WebUI entfernte Einträge</h2></div><span class="record-count">{{len $view.ArchivedRecords}} Einträge</span></div>
<p class="panel-description">Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.</p>
<div class="archive-list">
{{range $view.ArchivedRecords}}
<div class="archive-row"><span><strong>{{.Hostnames}}</strong><small>{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/pihole/{{$integration.ID}}/records/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="link_id" value="{{.LinkID}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
{{end}}
</div>
</section>
{{end}}
<section class="action-grid admin-actions-grid pihole-action-grid">
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
</article>
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="url" value="{{$integration.BaseURL}}" required><small>Basis-URL oder vollständiger <code>/api</code>-Pfad.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
<div class="panel-divider"></div>
<details><summary class="button button-quiet">Anwendungspasswort anzeigen</summary><form action="/pihole/{{$integration.ID}}/password" 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">Passwort entschlüsseln</button></form></details>
<div class="panel-divider"></div>
<details><summary class="text-danger">Pi-hole-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/pihole/{{$integration.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 Zugang und lokale Verknüpfungen. Alle DNS-Einträge bei Pi-hole bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/pihole/{{$integration.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{$.CSRF}}"><strong>Pi-hole vollständig leeren</strong><p>Löscht alle lokalen DNS-Einträge bei Pi-hole und entfernt anschließend die Verbindung aus der WebUI.</p><button class="button button-danger-soft" type="submit">Verbindung und Einträge löschen</button></form></div></details>
</article>
</section>
{{else}}
<section class="empty-board pihole-empty"><span class="empty-icon pihole-empty-icon">PH</span><h2>Noch kein Pi-hole verbunden</h2><p>Verbinde genau einen Pi-hole über die v6-API. Danach lassen sich lokale DNS-Einträge wie Records im Domain-Tab verwalten.</p><a class="button button-pihole" href="#pihole-connect">Pi-hole verbinden</a></section>
<section class="form-panel pihole-connect-panel" id="pihole-connect">
<div class="panel-heading"><div><span class="section-label pihole-kicker">API-Verbindung</span><h2>Pi-hole verbinden</h2></div><span class="pihole-api-badge">API v6</span></div>
<p class="panel-description">Verwende ein eigenes Pi-hole-Anwendungspasswort. Es wird mit dem Master-Key dieser Anwendung verschlüsselt.</p>
<form action="/pihole/connect" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="url" placeholder="https://pi.hole" required><small><code>/api</code> wird automatisch ergänzt.</small></label><label>Anwendungspasswort <input name="password" type="password" autocomplete="new-password" required><small>Keinen Session-Token eintragen.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls"><span></span> TLS-Zertifikat nicht prüfen</label><div class="warning-box">Nur für ein selbstsigniertes Zertifikat in einem vertrauenswürdigen internen Netz verwenden. Bei HTTP wird das Passwort auf dem Netzwerkweg unverschlüsselt übertragen.</div><button class="button button-pihole" type="submit">Verbindung testen und speichern</button></form>
</section>
{{end}}
{{end}}
+1 -1
View File
@@ -1,4 +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>
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>{{.SecretTitle}}</h1><p><strong>{{.SecretScope}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="secret-value">{{.SecretValue}}</code><button class="button button-primary" type="button" data-copy-target="secret-value">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="{{.SecretBackURL}}">Zurückkehren</a></section>
{{end}}