diff --git a/README.md b/README.md index 659b37f..d3634e4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/dyndns-web/main.go b/cmd/dyndns-web/main.go index 999dbea..530f351 100644 --- a/cmd/dyndns-web/main.go +++ b/cmd/dyndns-web/main.go @@ -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 diff --git a/compose.yml b/compose.yml index 6bc1c0f..3d5263e 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/internal/model/model.go b/internal/model/model.go index 58fa1c8..54dd17f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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 diff --git a/internal/pihole/client.go b/internal/pihole/client.go new file mode 100644 index 0000000..df0a4ac --- /dev/null +++ b/internal/pihole/client.go @@ -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} +} diff --git a/internal/pihole/client_test.go b/internal/pihole/client_test.go new file mode 100644 index 0000000..479b02f --- /dev/null +++ b/internal/pihole/client_test.go @@ -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) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index fbddf76..021a632 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b7f587a..ea5f930 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) } } diff --git a/internal/web/pihole.go b/internal/web/pihole.go new file mode 100644 index 0000000..08d4b5d --- /dev/null +++ b/internal/web/pihole.go @@ -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 = ¤t + } + 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" +} diff --git a/internal/web/server.go b/internal/web/server.go index 7ad358e..7c890a7 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 92f4e4f..23c3a8f 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 75c93cc..4111121 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -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 () => { diff --git a/internal/web/static/styles.css b/internal/web/static/styles.css index 55d4942..dd7e383 100644 --- a/internal/web/static/styles.css +++ b/internal/web/static/styles.css @@ -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) { diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html index 389df13..148a9f4 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/templates/domain_detail.html @@ -41,12 +41,10 @@
{{end}} @@ -79,12 +77,10 @@
diff --git a/internal/web/templates/errors.html b/internal/web/templates/errors.html index d2f3b3f..40981f2 100644 --- a/internal/web/templates/errors.html +++ b/internal/web/templates/errors.html @@ -1,7 +1,7 @@ {{define "errors"}}{{template "layout" .}}{{end}} {{define "content"}}
-
← ÜbersichtDiagnose

Fehlerzentrale

Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.

+
← ÜbersichtDiagnose

Fehlerzentrale

Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.

@@ -9,11 +9,12 @@
{{if eq .ErrorCount 0}} -

Keine Fehler gespeichert

Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.

+

Keine Fehler gespeichert

Aktuell sind weder DNS- oder Pi-hole-Fehler noch problematische Update-Läufe vorhanden.

{{else}}
Ziele{{len .ErrorTargets}}
Zonen{{len .ErrorZones}}
+
Pi-hole{{len .ErrorPiHoles}}
Update-Läufe{{len .ErrorRuns}}
@@ -28,6 +29,17 @@ {{end}} +{{if .ErrorPiHoles}} +
+

Pi-hole-Probleme

{{len .ErrorPiHoles}}
+
+ {{range .ErrorPiHoles}} +
{{.Name}}

{{.LastError}}

{{.BaseURL}} · letzte Prüfung {{formatTime .LastSyncAt}}
Öffnen →
+ {{end}} +
+
+{{end}} + {{if .ErrorZones}}

Zonenprobleme

{{len .ErrorZones}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 9acbc09..6bf4a1a 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -5,29 +5,30 @@ - {{.Title}} · Hetzner DynDNS - - + {{.Title}} · DNS Control + + {{if and (ne .Page "setup") (ne .Page "login")}}
-
DynDNS/ {{.Title}}
+
DNS Control/ {{.Title}}
{{if eq .Mode "lan"}}LAN · HTTP{{else}}HTTPS{{end}}
diff --git a/internal/web/templates/pihole.html b/internal/web/templates/pihole.html new file mode 100644 index 0000000..3a91322 --- /dev/null +++ b/internal/web/templates/pihole.html @@ -0,0 +1,90 @@ +{{define "pihole"}}{{template "layout" .}}{{end}} +{{define "content"}} +
+
Lokales Netzwerk

Pi-hole DNS

Lokale A- und AAAA-Auflösungen ähnlich wie deine öffentlichen Domains verwalten.

+ {{if .PiHoleView}} + + {{else}} + Pi-hole verbinden + {{end}} +
+ +{{if .PiHoleView}} +{{$view := .PiHoleView}} +{{$integration := $view.Integration}} +
+
ProviderPi-hole API v6
+
Letzter Import{{formatTime $integration.LastSyncAt}}
+
Verknüpfte Einträge{{len $view.Records}}
+
Getrennte Einträge{{len $view.ArchivedRecords}}
+
+ +{{if $view.FetchError}} +
Pi-hole konnte nicht neu eingelesen werden

{{$view.FetchError}}

+{{end}} + +
+

Lokale DNS-Einträge

{{len $view.Records}} Einträge
+ {{if $view.Records}} +
+ {{range $view.Records}} +
+

{{.Hostnames}}

{{if contains .IP ":"}}AAAA{{else}}A{{end}}{{.IP}}

+
Pi-hole DNS{{if .Active}}Aktiv{{else}}Inaktiv{{end}}
+
Einstellungen ⌄
+ {{if .Valid}} +
+ +
+
+ +
+ {{else}} +

Der vorhandene Pi-hole-Wert kann nicht strukturiert bearbeitet werden: {{.Raw}}

+ {{end}} +
+ + +
+
+
+ {{end}} +
+ {{else}}
Keine verknüpften lokalen DNS-Einträge vorhanden. Lege unten einen neuen Eintrag an.
{{end}} +
+ +{{if $view.ArchivedRecords}} +
+

Aus der WebUI entfernte Einträge

{{len $view.ArchivedRecords}} Einträge
+

Diese lokalen DNS-Einträge bestehen weiterhin bei Pi-hole, sind aber nicht mehr mit der aktiven WebUI-Liste verknüpft.

+
+ {{range $view.ArchivedRecords}} +
{{.Hostnames}}{{if contains .IP ":"}}AAAA{{else}}A{{end}} · {{.IP}} · {{if .Active}}aktiv{{else}}inaktiv{{end}} · getrennt {{formatTime .ArchivedAt}}
+ {{end}} +
+
+{{end}} + +
+

Neuer lokaler DNS-Eintrag

+
+
+

Verbindung verwalten

{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}
+

Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.

+
+
+
Anwendungspasswort anzeigen
+
+
Pi-hole-Verbindung entfernen
+
+
+ +{{else}} +
PH

Noch kein Pi-hole verbunden

Verbinde genau einen Pi-hole über die v6-API. Danach lassen sich lokale DNS-Einträge wie Records im Domain-Tab verwalten.

Pi-hole verbinden
+
+

Pi-hole verbinden

API v6
+

Verwende ein eigenes Pi-hole-Anwendungspasswort. Es wird mit dem Master-Key dieser Anwendung verschlüsselt.

+
Nur für ein selbstsigniertes Zertifikat in einem vertrauenswürdigen internen Netz verwenden. Bei HTTP wird das Passwort auf dem Netzwerkweg unverschlüsselt übertragen.
+
+{{end}} +{{end}} diff --git a/internal/web/templates/token.html b/internal/web/templates/token.html index 569daa2..5158fe7 100644 --- a/internal/web/templates/token.html +++ b/internal/web/templates/token.html @@ -1,4 +1,4 @@ {{define "token"}}{{template "layout" .}}{{end}} {{define "content"}} -
Sensible Zugangsdaten

API-Token

Zone: {{.Zone.Name}}. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.

{{.Token}}

Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.

Zur Domain zurückkehren
+
Sensible Zugangsdaten

{{.SecretTitle}}

{{.SecretScope}}. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.

{{.SecretValue}}

Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.

Zurückkehren
{{end}}