fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
package web
import (
"encoding/json"
"net/http"
"net/netip"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
)
// Only this allowlisted report is downloadable, never raw Docker logs, stored
// error messages, audit messages, configuration, credentials or session data.
func (s *Server) downloadDiagnostics(w http.ResponseWriter, r *http.Request, _ *model.Session) {
settings, err := s.store.GetSettings(r.Context())
if err != nil {
s.serverError(w, err)
return
}
runs, err := s.store.RecentRuns(r.Context(), 100)
if err != nil {
s.serverError(w, err)
return
}
type run struct {
ID int64 `json:"id"`
StartedAt time.Time `json:"started_at"`
Status string `json:"status"`
}
report := struct {
Format int `json:"format"`
Version string `json:"version"`
ExportedAt time.Time `json:"exported_at"`
Network *diagnostics.Network `json:"current_network"`
UpdaterEnabled bool `json:"updater_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
IntervalSeconds int `json:"interval_seconds"`
LastIPv4 string `json:"last_ipv4,omitempty"`
LastIPv6 string `json:"last_ipv6,omitempty"`
Runs []run `json:"recent_runs"`
Events []diagnostics.Event `json:"events"`
}{
Format: 1, Version: buildinfo.Version, ExportedAt: time.Now().UTC(), Network: diagnostics.CurrentNetwork(),
UpdaterEnabled: settings.UpdaterEnabled, IPv6Enabled: settings.IPv6Enabled, IntervalSeconds: settings.UpdateIntervalSeconds,
Events: diagnostics.Snapshot(),
}
if ip, err := netip.ParseAddr(settings.LastIPv4); err == nil && ip.Zone() == "" {
report.LastIPv4 = ip.String()
}
if ip, err := netip.ParseAddr(settings.LastIPv6); err == nil && ip.Zone() == "" {
report.LastIPv6 = ip.String()
}
for _, item := range runs {
status := "unknown"
switch item.Status {
case "running", "ok", "error", "partial":
status = item.Status
}
report.Runs = append(report.Runs, run{item.ID, item.StartedAt, status})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="domain-control-diagnostics.json"`)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
_ = encoder.Encode(report)
}
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestPiHolePageAndRemovalNeverContactUnavailablePiHole(t *testing.T) {
t.Parallel()
var requests atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "unavailable", 503) }))
defer api.Close()
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
integration, err := database.CreatePiHoleIntegration(ctx, model.PiHoleIntegration{Name: "Pi-hole", BaseURL: api.URL + "/api"}, model.PiHoleCredential{Ciphertext: []byte("secret"), Nonce: []byte("secret")})
if err != nil {
t.Fatal(err)
}
if _, err = database.SyncPiHoleRecordLinks(ctx, integration.ID, []string{"192.168.1.10 nas.lan"}); err != nil {
t.Fatal(err)
}
if err = database.MarkPiHoleSynced(ctx, integration.ID, "connection timed out"); err != nil {
t.Fatal(err)
}
templates, err := loadTemplates()
if err != nil {
t.Fatal(err)
}
server := &Server{store: database, templates: templates, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
session := &model.Session{CSRFToken: "csrf", Username: "admin", Role: "admin"}
response := httptest.NewRecorder()
server.piHolePage(response, httptest.NewRequest(http.MethodGet, "/pihole", nil), session)
if response.Code != 200 || !strings.Contains(response.Body.String(), "nas.lan") || !strings.Contains(response.Body.String(), "Lokale Verbindung entfernen") {
t.Fatalf("offline page unavailable: %d", response.Code)
}
id := strconv.FormatInt(integration.ID, 10)
request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/remove", strings.NewReader(url.Values{"csrf": {"csrf"}}.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("id", id)
response = httptest.NewRecorder()
server.piHoleRemove(response, request, session)
if response.Code != http.StatusSeeOther {
t.Fatalf("offline removal failed: %d", response.Code)
}
integrations, err := database.ListPiHoleIntegrations(ctx)
if err != nil || len(integrations) != 0 {
t.Fatal("connection not removed")
}
if requests.Load() != 0 {
t.Fatalf("offline page/removal contacted Pi-hole %d times", requests.Load())
}
}
func TestDiagnosticDownloadExcludesStoredSecretsAndFreeText(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)
}
defer database.Close()
const secret = "git-hetzner-pihole-cookie-password-secret"
if err = database.SaveSoftwareUpdateCredential(ctx, secret, []byte(secret), []byte(secret)); err != nil {
t.Fatal(err)
}
if err = database.AddAudit(ctx, "arbitrary", secret); err != nil {
t.Fatal(err)
}
id, err := database.CreateUpdateRun(ctx)
if err != nil {
t.Fatal(err)
}
if err = database.FinishUpdateRun(ctx, id, "error", secret, secret, "https://user:"+secret+"@host/?token="+secret); err != nil {
t.Fatal(err)
}
server := &Server{store: database, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
response := httptest.NewRecorder()
server.downloadDiagnostics(response, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil), &model.Session{Role: "admin"})
if response.Code != 200 || !strings.Contains(response.Header().Get("Content-Disposition"), "attachment") || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("invalid download: %d", response.Code)
}
if strings.Contains(response.Body.String(), secret) {
t.Fatal("secret included in download")
}
if !strings.Contains(response.Body.String(), `"status": "error"`) {
t.Fatal("run status missing")
}
anonymous := httptest.NewRecorder()
server.Handler().ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, "/errors/diagnostics", nil))
if anonymous.Code == 200 {
t.Fatal("anonymous diagnostic access accepted")
}
}
+29 -25
View File
@@ -13,6 +13,7 @@ import (
"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"
@@ -44,18 +45,12 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
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)
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)
@@ -68,15 +63,6 @@ func (s *Server) piHolePage(w http.ResponseWriter, r *http.Request, session *mod
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
@@ -122,7 +108,8 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
s.redirectNotice(w, r, "/pihole", err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
hosts, err := client.ListHosts(r.Context(), password)
if err != nil {
s.redirectNotice(w, r, "/pihole", "Pi-hole-Verbindung fehlgeschlagen: "+safeMessage(err), "error")
return
}
@@ -140,6 +127,10 @@ func (s *Server) piHoleConnect(w http.ResponseWriter, r *http.Request, session *
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.")
@@ -180,7 +171,8 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
s.redirectNotice(w, r, piHoleURL(id), err.Error(), "error")
return
}
if _, err := client.ListHosts(r.Context(), password); err != nil {
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
}
@@ -194,6 +186,10 @@ func (s *Server) piHoleSave(w http.ResponseWriter, r *http.Request, session *mod
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.")
@@ -254,16 +250,24 @@ func (s *Server) piHoleRefresh(w http.ResponseWriter, r *http.Request, session *
if !ok {
return
}
client, password, err := s.piHoleAccess(r.Context(), id)
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
client, password, err := s.piHoleAccess(ctx, id)
var hosts []string
if err == nil {
_, err = client.ListHosts(r.Context(), password)
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.")
}
+51 -23
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/netip"
"net/url"
"slices"
"strconv"
"strings"
"sync"
@@ -144,22 +145,22 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /", s.authenticated(s.dashboard))
mux.Handle("GET /domains", s.authenticated(s.domains))
mux.Handle("GET /domains/new", s.adminOnly(s.domainNew))
mux.Handle("POST /domains", s.adminOnly(s.domainCreate))
mux.Handle("POST /domains", s.adminOnly(s.dnsChange(s.domainCreate)))
mux.Handle("GET /domains/{id}", s.authenticated(s.domainDetail))
mux.Handle("POST /domains/{id}/import", s.adminOnly(s.domainImport))
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.domainTTLSave))
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.domainRemove))
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.domainDelete))
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.domainToken))
mux.Handle("POST /targets", s.authenticated(s.targetCreate))
mux.Handle("POST /targets/{id}/save", s.authenticated(s.targetSave))
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.targetDelete))
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.targetArchive))
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.targetRestore))
mux.Handle("POST /cnames", s.adminOnly(s.cnameCreate))
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("POST /domains/{id}/import", s.adminOnly(s.dnsChange(s.domainImport)))
mux.Handle("POST /domains/{id}/ttl", s.adminOnly(s.dnsChange(s.domainTTLSave)))
mux.Handle("POST /domains/{id}/remove", s.adminOnly(s.dnsChange(s.domainRemove)))
mux.Handle("POST /domains/{id}/delete", s.adminOnly(s.dnsChange(s.domainDelete)))
mux.Handle("POST /domains/{id}/token", s.adminOnly(s.dnsChange(s.domainToken)))
mux.Handle("POST /targets", s.authenticated(s.dnsChange(s.targetCreate)))
mux.Handle("POST /targets/{id}/save", s.authenticated(s.dnsChange(s.targetSave)))
mux.Handle("POST /targets/{id}/delete", s.adminOnly(s.dnsChange(s.targetDelete)))
mux.Handle("POST /targets/{id}/archive", s.adminOnly(s.dnsChange(s.targetArchive)))
mux.Handle("POST /targets/{id}/restore", s.adminOnly(s.dnsChange(s.targetRestore)))
mux.Handle("POST /cnames", s.adminOnly(s.dnsChange(s.cnameCreate)))
mux.Handle("POST /cnames/{id}/save", s.adminOnly(s.dnsChange(s.cnameSave)))
mux.Handle("POST /cnames/{id}/remove", s.adminOnly(s.dnsChange(s.cnameRemove)))
mux.Handle("POST /cnames/{id}/delete", s.adminOnly(s.dnsChange(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))
@@ -173,13 +174,14 @@ func (s *Server) Handler() http.Handler {
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 /settings", s.adminOnly(s.dnsChange(s.settingsSave)))
mux.Handle("POST /run", s.adminOnly(s.runNow))
mux.Handle("GET /system", s.adminOnly(s.softwareUpdatePage))
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
mux.Handle("GET /errors/diagnostics", s.adminOnly(s.downloadDiagnostics))
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
mux.Handle("GET /account", s.authenticated(s.accountPage))
@@ -818,7 +820,7 @@ func (s *Server) targetCreate(w http.ResponseWriter, r *http.Request, session *m
if !target.Active && hasStaticIP(target) {
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
if syncErr != nil {
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
s.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
return
}
@@ -932,7 +934,7 @@ func (s *Server) targetSave(w http.ResponseWriter, r *http.Request, session *mod
if !target.Active && hasStaticIP(target) {
updated, status, syncErr := s.scheduler.SyncStaticTarget(r.Context(), target)
if syncErr != nil {
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), false)
_ = s.store.UpdateTargetResult(r.Context(), target.ID, "Fehler", safeMessage(syncErr), updated)
s.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error")
return
}
@@ -1261,11 +1263,13 @@ func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request, session *m
s.redirectNotice(w, r, "/settings", "Für aktiviertes IPv6 ist mindestens ein HTTPS-Endpoint erforderlich.", "error")
return
}
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
return
if !slices.Equal(ipv4, current.IPv4Endpoints) {
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv4); err != nil {
s.redirectNotice(w, r, "/settings", "IPv4-Endpoint ungültig: "+safeMessage(err), "error")
return
}
}
if ipv6Enabled {
if ipv6Enabled && !slices.Equal(ipv6, current.IPv6Endpoints) {
if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil {
s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error")
return
@@ -1364,7 +1368,7 @@ func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, ses
cancel()
if err != nil {
if errors.Is(err, appupdate.ErrCredentialMissing) {
s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info")
s.redirectNotice(w, r, "/system#update-access", appupdate.ErrCredentialMissing.Error(), "info")
return
}
s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error")
@@ -1847,6 +1851,30 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.")
}
// The lock spans reading the old target, remote changes and saving the new
// configuration. Locking individual HTTP calls still permits stale worker writes.
func (s *Server) dnsChange(next func(http.ResponseWriter, *http.Request, *model.Session)) func(http.ResponseWriter, *http.Request, *model.Session) {
return func(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ültiges CSRF-Token", http.StatusForbidden)
return
}
waitCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
unlock, err := s.scheduler.LockChanges(waitCtx)
if err != nil {
w.Header().Set("Retry-After", "5")
http.Error(w, "Eine DNS-Aktualisierung läuft noch. Bitte die Änderung in Kürze erneut senden.", http.StatusServiceUnavailable)
return
}
defer unlock()
next(w, r, session)
}
}
func (s *Server) authenticated(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := s.session(r.Context(), r)
+111 -4
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"mime/multipart"
"net/http"
@@ -27,6 +28,76 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
func TestDNSMutationHoldsUpdaterLockAndRequiresCSRF(t *testing.T) {
worker := scheduler.New(nil, nil, nil, nil, nil)
server := &Server{scheduler: worker}
session := &model.Session{CSRFToken: "valid"}
called := false
handler := server.dnsChange(func(w http.ResponseWriter, r *http.Request, _ *model.Session) {
called = true
if _, err := worker.Run(r.Context()); !errors.Is(err, scheduler.ErrRunInProgress) {
t.Fatalf("updater entered mutation: %v", err)
}
w.WriteHeader(http.StatusNoContent)
})
for _, csrf := range []string{"wrong", "valid"} {
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf="+csrf))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler(response, request, session)
if csrf == "wrong" && (called || response.Code != http.StatusForbidden) {
t.Fatal("invalid CSRF entered mutation")
}
if csrf == "valid" && (!called || response.Code != http.StatusNoContent) {
t.Fatal("valid mutation was blocked")
}
}
waitCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
unlock, err := worker.LockChanges(waitCtx)
if err != nil {
t.Fatal("mutation did not release lock")
}
defer unlock()
called = false
request := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("csrf=valid"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx, stop := context.WithCancel(context.Background())
stop()
response := httptest.NewRecorder()
handler(response, request.WithContext(ctx), session)
if called || response.Code != http.StatusServiceUnavailable {
t.Fatal("canceled edit entered lock")
}
}
func TestUnchangedEndpointsCanBeSavedWhileDNSIsUnavailable(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
settings, err := database.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(ctx, 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
// No resolver/provider: unchanged endpoints must not require a network call.
server := &Server{store: database, scheduler: scheduler.New(database, nil, nil, nil, nil)}
form := url.Values{"csrf": {"valid"}, "interval": {"600"}, "ipv4_endpoints": {strings.Join(settings.IPv4Endpoints, "\n")}, "ipv6_endpoints": {strings.Join(settings.IPv6Endpoints, "\n")}}
request := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
server.dnsChange(server.settingsSave)(response, request, &model.Session{CSRFToken: "valid", Role: "admin"})
settings, err = database.GetSettings(ctx)
if err != nil || response.Code != http.StatusSeeOther || settings.UpdaterEnabled || settings.UpdateIntervalSeconds != 600 {
t.Fatalf("offline settings save failed: status=%d enabled=%v err=%v", response.Code, settings.UpdaterEnabled, err)
}
}
func TestInputNormalizers(t *testing.T) {
t.Parallel()
username, err := normalizeUsername("Alice_Admin")
@@ -293,7 +364,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
`data-dashboard-ipv6`,
`data-recent-runs aria-live="polite" aria-busy="true"`,
`Aktualisierung läuft`,
`features-29`,
`features-30`,
`data-local-time="1788001920"`,
`dashboard-zone-item`,
`DNS-Zone · example.de`,
@@ -414,6 +485,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
{method: http.MethodGet, path: "/users"},
{method: http.MethodGet, path: "/users/new"},
{method: http.MethodGet, path: "/system"},
{method: http.MethodGet, path: "/errors/diagnostics"},
{method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"},
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
}
@@ -541,7 +613,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Privater Zugang gespeichert", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
if !strings.Contains(page.String(), expected) {
t.Errorf("software update page is missing %q", expected)
}
@@ -551,7 +623,7 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
if err := templates["system"].ExecuteTemplate(&freshPage, "system", freshData); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Zugang fehlt", "Update-Zugang einrichten"} {
for _, expected := range []string{"Nach Update suchen", "Installiert", "Status", "Bereit", "Privater Zugang (optional)"} {
if !strings.Contains(freshPage.String(), expected) {
t.Errorf("fresh software update page is missing %q", expected)
}
@@ -857,14 +929,23 @@ func TestPiHoleDisconnectedPageCentersConnectFormWithoutEmptyState(t *testing.T)
func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T) {
t.Parallel()
var phase atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if phase.Load() == 2 {
http.Error(w, "Pi-hole unavailable", http.StatusServiceUnavailable)
return
}
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"]}}}`))
if phase.Load() == 1 {
_, _ = w.Write([]byte(`{"config":{"dns":{"hosts":["192.168.1.21 nas.lan"]}}}`))
} else {
_, _ = 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:
@@ -909,6 +990,10 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
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)
}
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
if err != nil || len(links) != 1 || links[0].Value != "192.168.1.20 nas.lan" {
t.Fatalf("initial import not persisted: %#v %v", links, err)
}
_, credential, err := database.GetPiHoleIntegrationWithCredential(ctx, integrations[0].ID)
if err != nil {
t.Fatal(err)
@@ -917,6 +1002,28 @@ func TestPiHoleConnectValidatesRemoteAndEncryptsApplicationPassword(t *testing.T
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)
}
// A successful refresh persists the new state without a subsequent page
// load; an outage must retain that state instead of treating it as empty.
for _, step := range []int32{1, 2} {
phase.Store(step)
id := strconv.FormatInt(integrations[0].ID, 10)
request := httptest.NewRequest(http.MethodPost, "/pihole/"+id+"/refresh", strings.NewReader("csrf=csrf-token"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("id", id)
response := httptest.NewRecorder()
server.piHoleRefresh(response, request, &model.Session{CSRFToken: "csrf-token", Username: "admin"})
if response.Code != http.StatusSeeOther {
t.Fatalf("refresh response: %d", response.Code)
}
links, err := database.ListPiHoleRecordLinks(ctx, integrations[0].ID)
if err != nil || len(links) != 2 || links[0].Active || !links[1].Active || links[1].Value != "192.168.1.21 nas.lan" {
t.Fatalf("refresh lost cached records in phase %d: %#v %v", step, links, err)
}
integration, err := database.GetPiHoleIntegration(ctx, integrations[0].ID)
if err != nil || (integration.LastError != "") != (step == 2) {
t.Fatalf("refresh status incorrect: %#v %v", integration, err)
}
}
}
func TestPiHolePasswordRevealRequiresAdminPasswordAndDoesNotAuditSecret(t *testing.T) {
+2 -1
View File
@@ -2,10 +2,11 @@
{{define "content"}}
<header class="page-heading">
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle DynDNS-, Zonen- und Pi-hole-Probleme sowie fehlgeschlagene oder partielle Update-Läufe.</p></div>
<div class="form-actions"><a class="button button-quiet" href="/errors/diagnostics" download>Diagnose-Logs herunterladen</a>
<form action="/errors/clear" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
</form>
</form></div>
</header>
{{if eq .ErrorCount 0}}
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · DNS Control</title>
<link rel="stylesheet" href="/static/styles.css?v=features-29">
<script src="/static/app.js?v=features-29" defer></script>
<link rel="stylesheet" href="/static/styles.css?v=features-30">
<script src="/static/app.js?v=features-30" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
+4 -2
View File
@@ -12,7 +12,7 @@
{{$integration := $view.Integration}}
<section class="zone-summary pihole-summary" aria-label="Pi-hole-Status">
<div><span>Provider</span><strong>Pi-hole API v6</strong></div>
<div><span>Letzter Import</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
<div><span>Letzte Prüfung</span><strong><time data-local-time="{{unixSeconds $integration.LastSyncAt}}">{{formatTime $integration.LastSyncAt}}</time></strong></div>
<div><span>Verknüpfte Einträge</span><strong>{{len $view.Records}}</strong></div>
<div><span>Getrennte Einträge</span><strong>{{len $view.ArchivedRecords}}</strong></div>
</section>
@@ -21,6 +21,8 @@
<div class="pihole-connection-error"><strong>Pi-hole konnte nicht neu eingelesen werden</strong><p>{{$view.FetchError}}</p></div>
{{end}}
<p class="muted">Gespeicherter Stand. „Records neu einlesen“ prüft die Verbindung und aktualisiert die Einträge. Die Verbindung lässt sich auch ohne erreichbaren Pi-hole <a href="#pihole-connection">verwalten und entfernen</a>.</p>
<section class="targets-section" id="local-dns">
<div class="section-heading"><div><span class="section-label pihole-kicker">Verknüpfte Records</span><h2>Lokale DNS-Einträge</h2></div><span class="record-count">{{len $view.Records}} Einträge</span></div>
{{if $view.Records}}
@@ -67,7 +69,7 @@
<article class="panel"><div class="panel-heading"><div><span class="section-label pihole-kicker">Record anlegen</span><h2>Neuer lokaler DNS-Eintrag</h2></div></div>
<form action="/pihole/{{$integration.ID}}/records" method="post" class="stacked-form pihole-record-form pihole-create-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="toggle-row"><label class="switch pihole-switch"><input type="checkbox" name="active" checked><span></span> Eintrag aktivieren</label></div><div class="form-grid"><label>Hostname(n)<input name="hostnames" placeholder="nas.lan" required></label><label>IP-Adresse<input name="ip" inputmode="text" placeholder="192.168.1.20 oder fd00::20" required></label></div><button class="button button-pihole button-compact pihole-record-save" type="submit">Record anlegen</button></form>
</article>
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Verbunden{{end}}</span></div>
<article class="panel" id="pihole-connection"><div class="panel-heading"><div><span class="section-label pihole-kicker">API-Zugang</span><h2>Verbindung verwalten</h2></div><span class="status-pill {{if $view.FetchError}}status-bad{{else}}pihole-status-pill{{end}}">{{if $view.FetchError}}Nicht erreichbar{{else}}Zuletzt erreichbar{{end}}</span></div>
<p class="panel-description">Das Anwendungspasswort bleibt verschlüsselt und standardmäßig verborgen. Eine Anzeige erfordert erneut dein Admin-Passwort.</p>
<form action="/pihole/{{$integration.ID}}/save" method="post" class="stacked-form" data-dirty-form><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Pi-hole-Adresse <input name="base_url" type="text" inputmode="url" autocapitalize="none" spellcheck="false" value="{{$integration.BaseURL}}" required><small>IPv4-Adresse oder Basis-URL; <code>/api</code> wird automatisch ergänzt.</small></label><label>Neues Anwendungspasswort <input name="password" type="password" autocomplete="new-password"><small>Leer lassen, um das gespeicherte Passwort beizubehalten.</small></label><label class="switch pihole-switch"><input type="checkbox" name="insecure_tls" {{if $integration.InsecureTLS}}checked{{end}}><span></span> TLS-Zertifikat nicht prüfen</label><button class="button button-pihole" type="submit" data-dirty-submit disabled aria-disabled="true">Verbindung prüfen und speichern</button></form>
<div class="panel-divider"></div>
+8 -7
View File
@@ -10,12 +10,12 @@
<div class="software-version-copy">
<div class="software-version-values">
<div class="software-version-cell"><span>Installiert</span><strong>{{.SoftwareUpdate.CurrentVersion}}</strong></div>
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}–{{end}}</strong></div>
<div class="software-version-cell is-latest"><span>{{if .SoftwareUpdate.UpdateAvailable}}Verfügbar{{else}}Status{{end}}</span><strong>{{if .SoftwareUpdate.UpdateAvailable}}{{.SoftwareUpdate.LatestVersion}}{{else if .SoftwareUpdate.LastError}}Unbekannt{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}–{{end}}</strong></div>
</div>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if .SoftwareUpdate.LastCheckedAt}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<p class="software-version-state">{{if .SoftwareUpdate.UpdateAvailable}}Eine neue stabile Version ist verfügbar.{{else if .SoftwareUpdate.LastError}}Die letzte Prüfung ist fehlgeschlagen; der Dienst läuft normal weiter.{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Kein neues Update verfügbar.{{else}}Noch nicht nach Updates gesucht.{{end}}</p>
<div class="software-version-meta"><span>Letzte Prüfung <b><time data-local-time="{{unixSeconds .SoftwareUpdate.LastCheckedAt}}">{{formatTime .SoftwareUpdate.LastCheckedAt}}</time></b></span><span>Update-Agent <b>{{if not .SoftwareUpdate.AgentAvailable}}nicht erreichbar{{else if not .SoftwareUpdate.AgentCompatible}}Compose nötig{{else}}bereit{{if .SoftwareUpdate.AgentVersion}} · {{.SoftwareUpdate.AgentVersion}}{{end}}{{end}}</b></span></div>
{{if .SoftwareUpdate.LastError}}<div class="update-inline-warning"><span>!</span><p><strong>Prüfung fehlgeschlagen</strong><small>{{.SoftwareUpdate.LastError}}</small></p></div>{{end}}
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die benötigte Update-Funktion noch nicht. Für Updates ohne Anmeldung muss auch der Agent diese Funktion unterstützen.</small></p></div>{{end}}
</div>
<div class="software-version-action">
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
@@ -28,7 +28,7 @@
{{else if .SoftwareUpdate.UpdateAvailable}}
<span class="status-pill status-warn">Agent nicht erreichbar</span>
{{else}}
<span class="status-pill {{if or .SoftwareUpdate.LastError (not .SoftwareUpdate.CredentialConfigured) .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if not .SoftwareUpdate.CredentialConfigured}}Zugang fehlt{{else if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if .SoftwareUpdate.LastCheckedAt}}Aktuell{{else}}Bereit{{end}}</span>
<span class="status-pill {{if or .SoftwareUpdate.LastError .SoftwareUpdate.ComposeSyncRequired}}status-warn{{else}}status-good{{end}}">{{if .SoftwareUpdate.LastError}}Prüfung fehlgeschlagen{{else if .SoftwareUpdate.ComposeSyncRequired}}Compose aktualisieren{{else if not .SoftwareUpdate.LastCheckedAt.IsZero}}Aktuell{{else}}Bereit{{end}}</span>
{{end}}
</div>
</section>
@@ -43,16 +43,17 @@
</section>
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
<p>Öffentliche Releases und Container-Images werden ohne Anmeldung geladen. Für private Repositories oder Images kannst du einen Zugang hinterlegen; er wird nur bei Bedarf verwendet.</p>
{{if .SoftwareUpdate.CredentialConfigured}}
<div class="update-access-state">
<span class="update-access-state-icon" aria-hidden="true">✓</span>
<div><span class="section-label">Update-Zugang</span><h2>Mit Gitea verbunden</h2><p>Angemeldet als <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
<span class="status-pill status-good"><i aria-hidden="true"></i> Verbunden</span>
<div><span class="section-label">Update-Zugang</span><h2>Privater Zugang gespeichert</h2><p>Hinterlegt für <strong>@{{.SoftwareUpdate.CredentialUsername}}</strong></p></div>
<span class="status-pill status-good"><i aria-hidden="true"></i> Gespeichert</span>
</div>
<details class="update-credential-editor">
<summary><span><strong>Zugang ändern</strong><small>Benutzername oder Passwort/Token ersetzen</small></span><i aria-hidden="true">⌄</i></summary>
{{else}}
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Update-Zugang einrichten</h2></div></div>
<div class="panel-heading"><div><span class="section-label">Zugang</span><h2>Privater Zugang (optional)</h2></div></div>
{{end}}
<form action="/system/update-credential" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">