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

+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)