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/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 || !s.verifyPassword(r.Context(), 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" }