659 lines
21 KiB
Go
659 lines
21 KiB
Go
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/diagnostics"
|
|
"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, FetchError: integration.LastError}
|
|
// Navigation and removing an obsolete connection must work offline.
|
|
links, err := s.store.ListPiHoleRecordLinks(r.Context(), integration.ID)
|
|
if err != nil {
|
|
s.serverError(w, err)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
hosts, err := client.ListHosts(r.Context(), password)
|
|
if 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
|
|
}
|
|
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), integration.ID, hosts); err != nil {
|
|
s.serverError(w, err)
|
|
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
|
|
}
|
|
hosts, err := client.ListHosts(r.Context(), password)
|
|
if 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
|
|
}
|
|
if _, err := s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts); err != nil {
|
|
s.serverError(w, err)
|
|
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
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
|
defer cancel()
|
|
client, password, err := s.piHoleAccess(ctx, id)
|
|
var hosts []string
|
|
if err == nil {
|
|
hosts, err = client.ListHosts(ctx, password)
|
|
}
|
|
if err == nil {
|
|
_, err = s.store.SyncPiHoleRecordLinks(r.Context(), id, hosts)
|
|
}
|
|
if err != nil {
|
|
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_failed", Target: "pihole", Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
|
|
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
|
|
}
|
|
diagnostics.Record(diagnostics.Event{Code: "pihole_refresh_ok", Target: "pihole"})
|
|
_ = 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"
|
|
}
|