feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled

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

No files matched your search

+655
View File
@@ -0,0 +1,655 @@
package web
import (
"context"
"errors"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/net/idna"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/pihole"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type piHoleView struct {
Integration model.PiHoleIntegration
Records []piHoleRecordView
ArchivedRecords []piHoleRecordView
FetchError string
}
type piHoleRecordView struct {
LinkID int64
Active bool
Raw string
IP string
Hostnames string
Valid bool
ArchivedAt time.Time
}
func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *model.Session) {
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
if err != nil {
s.serverError(w, err)
return
}
var view *piHoleView
if len(integrations) > 0 {
integration := integrations[0]
current := piHoleView{Integration: integration}
client, password, accessErr := s.piHoleAccess(r.Context(), integration.ID)
var links []model.PiHoleRecordLink
if accessErr == nil {
var hosts []string
hosts, accessErr = client.ListHosts(r.Context(), password)
if accessErr == nil {
links, accessErr = s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts)
}
}
if accessErr != nil {
links, _ = s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
}
for _, link := range links {
record := parsePiHoleRecord(link.Value)
record.LinkID = link.ID
record.Active = link.Active
record.ArchivedAt = link.ArchivedAt
if link.ArchivedAt.IsZero() {
current.Records = append(current.Records, record)
} else {
current.ArchivedRecords = append(current.ArchivedRecords, record)
}
}
if accessErr != nil {
current.FetchError = safeMessage(accessErr)
current.Integration.LastError = current.FetchError
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, current.FetchError)
} else {
current.Integration.LastError = ""
current.Integration.LastSyncAt = time.Now().UTC()
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
}
sortPiHoleRecords(current.Records)
sortPiHoleRecords(current.ArchivedRecords)
view = &current
}
s.render(w, "pihole", viewData{
Title: "Pi-hole",
Page: "pihole",
CSRF: session.CSRFToken,
Flash: notice(r),
Now: time.Now(),
User: userFromSession(session),
IsAdmin: true,
PiHoleView: view,
})
}
func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
integrations, err := s.store.ListPiHoleIntegrations(r.Context())
if err != nil {
s.serverError(w, err)
return
}
if len(integrations) > 0 {
s.redirectNotice(w, r, "/pihole", "Es ist bereits ein Pi-hole verbunden. Bearbeite die bestehende Verbindung oder entferne sie zuerst.", "error")
return
}
const name = "Pi-hole"
password := strings.TrimSpace(r.FormValue("password"))
if password == "" || len(password) > 1024 {
s.redirectNotice(w, r, "/pihole", "Ein gültiges Pi-hole-Anwendungspasswort ist erforderlich.", "error")
return
}
insecureTLS := checked(r, "insecure_tls")
client, err := pihole.New(r.FormValue("base_url"), insecureTLS)
if err != nil {
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
return
}
ciphertext, nonce, err := s.cipher.Encrypt(password)
if err != nil {
s.serverError(w, err)
return
}
integration, err := s.store.CreatePiHoleIntegration(r.Context(), model.PiHoleIntegration{Name: name, BaseURL: client.BaseURL(), InsecureTLS: insecureTLS}, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce})
if err != nil {
message := "Pi-hole-Verbindung konnte nicht gespeichert werden: " + safeMessage(err)
if errors.Is(err, store.ErrPiHoleAlreadyConfigured) {
message = "Es ist bereits ein Pi-hole verbunden."
}
s.redirectNotice(w, r, "/pihole", message, "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), integration.ID, "")
_ = s.store.AddAudit(r.Context(), "pihole.connected", "Pi-hole verbunden: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(integration.ID), "Pi-hole verbunden und lokale DNS-Einträge eingelesen.")
}
func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
password := strings.TrimSpace(r.FormValue("password"))
if password == "" {
password, err = s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
s.serverError(w, err)
return
}
}
if len(password) > 1024 {
s.redirectNotice(w, r, piHoleURL(id), "Das Anwendungspasswort ist zu lang.", "error")
return
}
integration.InsecureTLS = checked(r, "insecure_tls")
client, err := pihole.New(r.FormValue("base_url"), integration.InsecureTLS)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Neue Pi-hole-Konfiguration nicht gespeichert: "+safeMessage(err), "error")
return
}
integration.BaseURL = client.BaseURL()
ciphertext, nonce, err := s.cipher.Encrypt(password)
if err != nil {
s.serverError(w, err)
return
}
if err := s.store.UpdatePiHoleIntegration(r.Context(), integration, model.PiHoleCredential{Ciphertext: ciphertext, Nonce: nonce}); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung konnte nicht gespeichert werden: "+safeMessage(err), "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.updated", "Pi-hole-Verbindung geändert: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole-Verbindung geprüft und gespeichert.")
}
func (s *Server) piHolePassword(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
s.redirectNotice(w, r, "/pihole#pihole-connection", "Admin-Passwort nicht korrekt; Anwendungspasswort bleibt verborgen.", "error")
return
}
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
s.serverError(w, err)
return
}
_ = s.store.SetSessionReauth(r.Context(), session.ID, time.Now().UTC().Add(5*time.Minute))
_ = s.store.AddAudit(r.Context(), "pihole.credential_revealed", "Pi-hole-Anwendungspasswort angezeigt durch "+session.Username)
s.render(w, "token", viewData{
Title: "Pi-hole-Passwort anzeigen",
Page: "pihole",
CSRF: session.CSRFToken,
SecretTitle: "Pi-hole-Anwendungspasswort",
SecretScope: "Pi-hole: " + integration.BaseURL,
SecretValue: password,
SecretBackURL: "/pihole#pihole-connection",
User: userFromSession(session),
IsAdmin: true,
Now: time.Now(),
})
}
func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
client, password, err := s.piHoleAccess(r.Context(), id)
if err == nil {
_, err = client.ListHosts(r.Context(), password)
}
if err != nil {
message := safeMessage(err)
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
s.redirectNotice(w, r, piHoleURL(id), "Pi-hole konnte nicht neu eingelesen werden: "+message, "error")
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
s.redirectNotice(w, r, piHoleURL(id), "Lokale DNS-Einträge wurden neu eingelesen.")
}
func (s *Server) piHoleRemove(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
_ = s.store.AddAudit(r.Context(), "pihole.removed", "Pi-hole-Verbindung entfernt: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung entfernt; lokale DNS-Einträge auf dem Pi-hole blieben unverändert.")
}
func (s *Server) piHoleDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
integration, err := s.store.GetPiHoleIntegration(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
client, password, err := s.piHoleAccess(r.Context(), id)
var hosts []string
if err == nil {
hosts, err = client.ListHosts(r.Context(), password)
}
if err == nil {
err = client.DeleteHosts(r.Context(), password, hosts)
}
if err != nil {
s.piHoleOperationError(w, r, id, "Pi-hole und lokale DNS-Einträge konnten nicht vollständig gelöscht werden", err)
return
}
if err := s.store.DeletePiHoleIntegration(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
_ = s.store.AddAudit(r.Context(), "pihole.deleted", "Pi-hole-Verbindung und lokale DNS-Einträge gelöscht: "+integration.Name+" durch "+session.Username)
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung und alle lokalen DNS-Einträge wurden vollständig gelöscht.")
}
func (s *Server) piHoleRecordCreate(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
value, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
active := checked(r, "active")
if active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
accessErr = client.AddHost(r.Context(), password, value)
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht angelegt werden", accessErr)
return
}
}
if err := s.store.LinkPiHoleRecord(r.Context(), id, value, active); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_created", "Lokalen DNS-Eintrag angelegt: "+value+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag angelegt"+piHoleActivitySuffix(active)+".")
}
func (s *Server) piHoleRecordUpdate(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
previous, err := validatePiHoleRawRecord(link.Value)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
replacement, err := normalizePiHoleRecord(r.FormValue("ip"), r.FormValue("hostnames"))
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
active := checked(r, "active")
if link.Active || active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
switch {
case link.Active && active:
accessErr = client.ReplaceHost(r.Context(), password, previous, replacement)
case link.Active:
accessErr = client.DeleteHost(r.Context(), password, previous)
default:
accessErr = client.AddHost(r.Context(), password, replacement)
}
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht geändert werden", accessErr)
return
}
}
if err := s.store.UpdatePiHoleRecordLink(r.Context(), id, link.ID, replacement, active); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_updated", "Lokalen DNS-Eintrag geändert: "+previous+" → "+replacement+" (aktiv: "+strconv.FormatBool(active)+") durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag gespeichert"+piHoleActivitySuffix(active)+".")
}
func (s *Server) piHoleRecordDelete(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
value, err := validatePiHoleRawRecord(link.Value)
if err != nil {
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
if link.Active {
client, password, accessErr := s.piHoleAccess(r.Context(), id)
if accessErr == nil {
accessErr = client.DeleteHost(r.Context(), password, value)
}
if accessErr != nil {
s.piHoleOperationError(w, r, id, "Eintrag konnte nicht gelöscht werden", accessErr)
return
}
}
if err := s.store.DeletePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.serverError(w, err)
return
}
_ = s.store.MarkPiHoleSynced(r.Context(), id, "")
_ = s.store.AddAudit(r.Context(), "pihole.record_deleted", "Lokalen DNS-Eintrag gelöscht: "+value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Lokaler DNS-Eintrag bei Pi-hole gelöscht.")
}
func (s *Server) piHoleRecordArchive(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
if err := s.store.ArchivePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht aus der WebUI entfernt werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "pihole.record_archived", "Lokalen DNS-Eintrag aus der WebUI entfernt: "+link.Value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Eintrag aus der WebUI entfernt; bei Pi-hole blieb er unverändert.")
}
func (s *Server) piHoleRecordRestore(w http.ResponseWriter, r *http.Request, session *model.Session) {
if err := s.parseForm(w, r); err != nil {
return
}
if !s.validSessionCSRF(r, session) {
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
return
}
id, ok := pathID(w, r)
if !ok {
return
}
link, ok := s.piHoleRecordLink(w, r, id)
if !ok {
return
}
if err := s.store.RestorePiHoleRecordLink(r.Context(), id, link.ID); err != nil {
s.redirectNotice(w, r, piHoleURL(id), "Eintrag konnte nicht wiederhergestellt werden: "+safeMessage(err), "error")
return
}
_ = s.store.AddAudit(r.Context(), "pihole.record_restored", "Lokalen DNS-Eintrag wieder in der WebUI verknüpft: "+link.Value+" durch "+session.Username)
s.redirectNotice(w, r, piHoleURL(id), "Eintrag wieder mit der WebUI verknüpft.")
}
func (s *Server) piHoleRecordLink(w http.ResponseWriter, r *http.Request, integrationID int64) (model.PiHoleRecordLink, bool) {
linkID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("link_id")), 10, 64)
if err != nil || linkID <= 0 {
http.Error(w, "Ungültiger Pi-hole-Eintrag", http.StatusBadRequest)
return model.PiHoleRecordLink{}, false
}
link, err := s.store.GetPiHoleRecordLink(r.Context(), integrationID, linkID)
if err != nil {
http.NotFound(w, r)
return model.PiHoleRecordLink{}, false
}
return link, true
}
func piHoleActivitySuffix(active bool) string {
if active {
return " und bei Pi-hole aktiviert"
}
return " und bei Pi-hole deaktiviert"
}
func (s *Server) piHoleAccess(ctx context.Context, id int64) (*pihole.Client, string, error) {
integration, credential, err := s.store.GetPiHoleIntegrationWithCredential(ctx, id)
if err != nil {
return nil, "", err
}
password, err := s.cipher.Decrypt(credential.Ciphertext, credential.Nonce)
if err != nil {
return nil, "", err
}
client, err := pihole.New(integration.BaseURL, integration.InsecureTLS)
return client, password, err
}
func (s *Server) piHoleOperationError(w http.ResponseWriter, r *http.Request, id int64, prefix string, err error) {
message := piHoleErrorMessage(err)
_ = s.store.MarkPiHoleSynced(r.Context(), id, message)
s.redirectNotice(w, r, piHoleURL(id), prefix+": "+message, "error")
}
func piHoleErrorMessage(err error) string {
var apiErr *pihole.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden && strings.Contains(strings.ToLower(apiErr.Message), "read-only") {
return "Pi-hole erlaubt dieser API-Sitzung nur Lesezugriff. Aktiviere für das Anwendungspasswort webserver.api.app_sudo=true und stelle sicher, dass misc.readOnly=false ist"
}
return safeMessage(err)
}
func normalizePiHoleRecord(rawIP, rawHostnames string) (string, error) {
address, err := netip.ParseAddr(strings.TrimSpace(rawIP))
if err != nil {
return "", errors.New("Bitte eine gültige IPv4- oder IPv6-Adresse angeben")
}
address = address.Unmap()
hostnames, err := normalizePiHoleHostnames(rawHostnames)
if err != nil {
return "", err
}
return address.String() + " " + strings.Join(hostnames, " "), nil
}
func normalizePiHoleHostnames(raw string) ([]string, error) {
values := strings.Fields(strings.ToLower(strings.TrimSpace(raw)))
if len(values) == 0 || len(values) > 20 {
return nil, errors.New("Bitte mindestens einen und höchstens 20 Hostnamen angeben")
}
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSuffix(value, ".")
ascii, err := idna.Lookup.ToASCII(value)
if err != nil || ascii == "" || len(ascii) > 253 {
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return nil, errors.New("Bitte gültige lokale Hostnamen angeben")
}
for _, character := range label {
if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '-' {
return nil, errors.New("Hostnamen dürfen nur Buchstaben, Zahlen, Punkte und Bindestriche enthalten")
}
}
}
if !seen[ascii] {
seen[ascii] = true
result = append(result, ascii)
}
}
return result, nil
}
func validatePiHoleRawRecord(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" || len(value) > 2048 {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
for _, character := range value {
if unicode.IsControl(character) {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
}
fields := strings.Fields(value)
if len(fields) < 2 {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
if _, err := netip.ParseAddr(fields[0]); err != nil {
return "", errors.New("Ungültiger Pi-hole-DNS-Eintrag")
}
if _, err := normalizePiHoleHostnames(strings.Join(fields[1:], " ")); err != nil {
return "", err
}
return value, nil
}
func parsePiHoleRecord(raw string) piHoleRecordView {
view := piHoleRecordView{Raw: raw}
fields := strings.Fields(raw)
if len(fields) < 2 {
view.Hostnames = raw
return view
}
if _, err := netip.ParseAddr(fields[0]); err != nil {
view.Hostnames = strings.Join(fields[1:], " ")
return view
}
view.IP = fields[0]
view.Hostnames = strings.Join(fields[1:], " ")
view.Valid = true
return view
}
func sortPiHoleRecords(records []piHoleRecordView) {
sort.Slice(records, func(left, right int) bool {
if records[left].Active != records[right].Active {
return records[left].Active
}
return strings.ToLower(records[left].Hostnames) < strings.ToLower(records[right].Hostnames)
})
}
func piHoleURL(_ int64) string {
return "/pihole#local-dns"
}