feat: add secure Pi-hole DNS management
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
1a6b4d0939
commit
1c32ca0671
18 files changed
+2159
-34
No files matched your search
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user