feat: add secure Pi-hole DNS management

This commit is contained in:
admin committed 2026-07-13 02:49:41 +02:00
1 parent 1a6b4d0939
commit fd0bb569f6
18 files changed
+2159 -34

No files matched your search

+8 -2
View File
@@ -1,6 +1,6 @@
# Hetzner DynDNS
Weboberfläche für mehrere Hetzner-DNS-Zonen, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt.
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.
## Starten
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
```yaml
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.1.0
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.2.0
restart: unless-stopped
ports:
- "9090:8080"
@@ -63,5 +63,11 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
- lokales Archiv statt Löschen von Hetzner-Records
- frei einstellbares Update-Intervall mit Countdown
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
- 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
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.
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"`.
Port 9090 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
+2
View File
@@ -32,6 +32,8 @@ 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
+4 -1
View File
@@ -1,12 +1,15 @@
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:v1.1.0
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:
+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)
}
}
}
+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}}