package web import ( "context" "crypto/subtle" "embed" "encoding/json" "errors" "fmt" "html/template" "io/fs" "log/slog" "net" "net/http" "net/netip" "net/url" "strconv" "strings" "sync" "time" "golang.org/x/net/idna" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/appupdate" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/config" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/scheduler" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store" ) //go:embed templates/*.html static/* var embeddedAssets embed.FS type Server struct { config config.Config store *store.Store cipher *secure.Cipher scheduler *scheduler.Scheduler updates *appupdate.Manager logger *slog.Logger templates map[string]*template.Template limiter *loginLimiter dummyHash string authSlots chan struct{} } type viewData struct { Title string Page string Mode string CSRF string Flash *flashMessage Now time.Time User model.User IsAdmin bool Dashboard model.Dashboard Zones []model.Zone Zone model.Zone Targets []model.Target ArchivedTargets []model.Target CNAMERecords []model.CNAMERecord Settings model.Settings SecretTitle string SecretScope string SecretValue string SecretBackURL string Form map[string]string Users []model.User ManagedUser model.User AccessZones []accessZone UpdaterEnabled bool CanCreateTargets bool ManageTargetIDs map[int64]bool ErrorTargets []model.Target ErrorZones []model.Zone ErrorRuns []model.UpdateRun ErrorPiHoles []model.PiHoleIntegration ErrorCount int PiHoleView *piHoleView SoftwareUpdate appupdate.State } type flashMessage struct { Message string Kind string } type accessZone struct { Zone model.Zone Targets []model.Target DomainGranted bool DomainManaged bool CanCreateTargets bool GrantedTargetIDs map[int64]bool ManagedTargetIDs map[int64]bool } type schedulerRunResponse struct { ID int64 `json:"id"` StartedAtUnix int64 `json:"started_at_unix"` Status string `json:"status"` Summary string `json:"summary"` } type schedulerResponse struct { NextRunUnix int64 `json:"next_run_unix"` Running bool `json:"running"` Enabled bool `json:"enabled"` IPv6Enabled bool `json:"ipv6_enabled"` LastIPv4 string `json:"last_ipv4"` LastIPv6 string `json:"last_ipv6"` RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"` } func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) { templates, err := loadTemplates() if err != nil { return nil, err } return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() static, _ := fs.Sub(embeddedAssets, "static") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /readyz", s.ready) mux.HandleFunc("GET /setup", s.setupPage) mux.HandleFunc("POST /setup", s.setup) mux.HandleFunc("GET /login", s.loginPage) mux.HandleFunc("POST /login", s.login) mux.Handle("POST /logout", s.authenticated(s.logout)) 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("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("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)) mux.Handle("POST /pihole/{id}/password", s.adminOnly(s.piHolePassword)) mux.Handle("POST /pihole/{id}/refresh", s.adminOnly(s.piHoleRefresh)) mux.Handle("POST /pihole/{id}/remove", s.adminOnly(s.piHoleRemove)) mux.Handle("POST /pihole/{id}/delete", s.adminOnly(s.piHoleDelete)) mux.Handle("POST /pihole/{id}/records", s.adminOnly(s.piHoleRecordCreate)) mux.Handle("POST /pihole/{id}/records/update", s.adminOnly(s.piHoleRecordUpdate)) mux.Handle("POST /pihole/{id}/records/delete", s.adminOnly(s.piHoleRecordDelete)) 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 /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("GET /errors", s.adminOnly(s.errorsPage)) mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear)) mux.Handle("GET /account", s.authenticated(s.accountPage)) mux.Handle("POST /account/password", s.authenticated(s.passwordChange)) mux.Handle("GET /security", s.authenticated(s.securityRedirect)) mux.Handle("POST /security/password", s.authenticated(s.passwordChange)) mux.Handle("GET /access", s.adminOnly(s.usersPage)) mux.Handle("GET /users", s.adminOnly(s.usersRedirect)) mux.Handle("GET /users/new", s.adminOnly(s.userNewPage)) mux.Handle("POST /users", s.adminOnly(s.userCreate)) mux.Handle("GET /users/{id}", s.adminOnly(s.userDetail)) mux.Handle("POST /users/{id}/access", s.adminOnly(s.userAccessSave)) mux.Handle("POST /users/{id}/state", s.adminOnly(s.userStateSave)) mux.Handle("POST /users/{id}/password", s.adminOnly(s.userPasswordReset)) mux.Handle("GET /api/v1/status", s.adminOnly(s.statusAPI)) mux.Handle("GET /api/v1/scheduler", s.authenticated(s.schedulerAPI)) mux.Handle("GET /api/v1/software-update/status", s.adminOnly(s.softwareUpdateStatusAPI)) return s.securityHeaders(mux) } func (s *Server) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) } func (s *Server) ready(w http.ResponseWriter, r *http.Request) { if _, err := s.store.GetSettings(r.Context()); err != nil { http.Error(w, "database unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ready\n")) } func (s *Server) setupPage(w http.ResponseWriter, r *http.Request) { exists, err := s.store.HasAdmin(r.Context()) if err != nil { s.serverError(w, err) return } if exists { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.render(w, "setup", viewData{Title: "Ersteinrichtung", Page: "setup", Mode: s.config.Mode, CSRF: s.publicCSRF(w, r), Now: time.Now(), Form: map[string]string{"username": "admin", "display_name": "Administrator"}}) } func (s *Server) setup(w http.ResponseWriter, r *http.Request) { exists, err := s.store.HasAdmin(r.Context()) if err != nil { s.serverError(w, err) return } if exists { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if err := s.parseForm(w, r); err != nil { return } if !s.validPublicCSRF(r) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } form := map[string]string{"username": strings.TrimSpace(r.FormValue("username")), "display_name": strings.TrimSpace(r.FormValue("display_name"))} username, usernameErr := normalizeUsername(form["username"]) if usernameErr != nil { s.renderError(w, "setup", usernameErr.Error(), s.publicCSRF(w, r), form) return } if form["display_name"] == "" || len([]rune(form["display_name"])) > 80 { s.renderError(w, "setup", "Bitte einen Anzeigenamen mit höchstens 80 Zeichen angeben.", s.publicCSRF(w, r), form) return } password, confirm := r.FormValue("password"), r.FormValue("confirm_password") if password != confirm { s.renderError(w, "setup", "Die Passwörter stimmen nicht überein.", s.publicCSRF(w, r), form) return } hash, err := secure.HashPassword(password) if err != nil { s.renderError(w, "setup", err.Error(), s.publicCSRF(w, r), form) return } if err := s.store.CreateAdmin(r.Context(), username, form["display_name"], hash); err != nil { if errors.Is(err, store.ErrAdminAlreadyConfigured) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "constraint") { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.serverError(w, err) return } admin, _, err := s.store.GetUserByUsername(r.Context(), username) if err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "admin.setup", "Admin-Zugang eingerichtet") if err := s.startSession(w, r.Context(), admin); err != nil { s.serverError(w, err) return } s.redirectNotice(w, r, "/", "Ersteinrichtung abgeschlossen.") } func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { exists, err := s.store.HasAdmin(r.Context()) if err != nil { s.serverError(w, err) return } if !exists { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } if session, _ := s.session(r.Context(), r); session != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, "login", viewData{Title: "Anmelden", Page: "login", Mode: s.config.Mode, CSRF: s.publicCSRF(w, r), Flash: notice(r), Now: time.Now()}) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { if err := s.parseForm(w, r); err != nil { return } if !s.validPublicCSRF(r) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } clientIP := remoteIP(r) if !s.limiter.Allow(clientIP) { s.renderError(w, "login", "Zu viele Anmeldeversuche. Bitte warte kurz.", s.publicCSRF(w, r), map[string]string{}) return } username, usernameErr := normalizeUsername(r.FormValue("username")) user, hash, err := s.store.GetUserByUsername(r.Context(), username) lookupOK := usernameErr == nil && err == nil if !lookupOK { hash = s.dummyHash } passwordOK := s.verifyPassword(r.Context(), hash, r.FormValue("password")) if !lookupOK || !user.Active || !passwordOK { s.limiter.Fail(clientIP) _ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung") s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{}) return } s.limiter.Reset(clientIP) if err := s.startSession(w, r.Context(), user); err != nil { s.serverError(w, err) return } _ = s.store.MarkUserLoggedIn(r.Context(), user.ID) _ = s.store.AddAudit(r.Context(), "auth.login", "Angemeldet: "+user.Username) s.redirectNotice(w, r, "/", "Angemeldet.") } func (s *Server) verifyPassword(ctx context.Context, hash, password string) bool { if s.authSlots == nil { return secure.VerifyPassword(hash, password) } timer := time.NewTimer(5 * time.Second) defer timer.Stop() select { case s.authSlots <- struct{}{}: defer func() { <-s.authSlots }() return secure.VerifyPassword(hash, password) case <-ctx.Done(): return false case <-timer.C: return false } } func (s *Server) logout(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } _ = s.store.DeleteSession(r.Context(), session.TokenHash) s.clearSession(w) s.redirectNotice(w, r, "/login", "Abgemeldet.") } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, session *model.Session) { settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } user := userFromSession(session) zones, err := s.zonesForSession(r.Context(), session) if err != nil { s.serverError(w, err) return } active, _ := s.activeTargetsForSession(r.Context(), session) var runs []model.UpdateRun var events []model.AuditEvent errorCount := 0 if session.Role == "admin" { runs, _ = s.store.RecentRuns(r.Context(), 6) events, _ = s.store.RecentAudit(r.Context(), 6) errorCount, _ = s.store.CountErrors(r.Context()) } s.render(w, "dashboard", viewData{Title: "Übersicht", Page: "dashboard", CSRF: session.CSRFToken, Flash: notice(r), Now: time.Now(), User: user, IsAdmin: session.Role == "admin", Dashboard: model.Dashboard{Settings: settings, Zones: zones, ActiveTargets: active, RecentRuns: runs, RecentEvents: events, NextRunAt: s.scheduler.NextRunAt(), SchedulerRunning: s.scheduler.IsRunning(), ErrorCount: errorCount}}) } func (s *Server) domains(w http.ResponseWriter, r *http.Request, session *model.Session) { zones, err := s.zonesForSession(r.Context(), session) if err != nil { s.serverError(w, err) return } s.render(w, "domains", viewData{Title: "Domains", Page: "domains", CSRF: session.CSRFToken, Flash: notice(r), Zones: zones, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) } func (s *Server) domainNew(w http.ResponseWriter, r *http.Request, session *model.Session) { s.render(w, "domain_new", viewData{Title: "Domain verbinden", Page: "domains", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Now: time.Now(), Form: map[string]string{}}) } func (s *Server) domainCreate(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 } form := map[string]string{"label": strings.TrimSpace(r.FormValue("label")), "zone": strings.TrimSpace(r.FormValue("zone"))} token := strings.TrimSpace(r.FormValue("token")) zoneName, err := normalizeZone(form["zone"]) if err != nil || token == "" { if err == nil { err = errors.New("Hetzner API-Token ist erforderlich") } s.renderError(w, "domain_new", err.Error(), session.CSRFToken, form) return } zones, err := s.scheduler.ValidateToken(r.Context(), token) if err != nil { s.renderError(w, "domain_new", "Der API-Token konnte nicht validiert werden: "+safeMessage(err), session.CSRFToken, form) return } providerID := "" for _, candidate := range zones { if strings.EqualFold(strings.TrimSuffix(candidate.Name, "."), zoneName) { providerID = candidate.ID break } } if providerID == "" { s.renderError(w, "domain_new", "Die Zone ist mit diesem Token nicht erreichbar. Prüfe den Domainnamen oder das Hetzner-Projekt.", session.CSRFToken, form) return } if form["label"] == "" { form["label"] = "Hetzner · " + zoneName } fingerprint := s.cipher.Fingerprint(token) credential, err := s.store.FindCredentialByFingerprint(r.Context(), fingerprint) if err != nil { s.serverError(w, err) return } if credential == nil { ciphertext, nonce, encryptErr := s.cipher.Encrypt(token) if encryptErr != nil { s.serverError(w, encryptErr) return } created, createErr := s.store.CreateCredential(r.Context(), form["label"], ciphertext, nonce, fingerprint) if createErr != nil { s.serverError(w, createErr) return } credential = &created } zone, err := s.store.CreateZone(r.Context(), credential.ID, providerID, zoneName) if err != nil { s.renderError(w, "domain_new", "Domain konnte nicht hinzugefügt werden: "+safeMessage(err), session.CSRFToken, form) return } imported, importErr := s.scheduler.SyncZone(r.Context(), zone.ID) if importErr != nil { s.logger.Warn("initial zone import failed", "zone", zone.Name, "error", importErr) } _ = s.store.AddAudit(r.Context(), "zone.connected", "Domain verbunden: "+zone.Name) noticeText := fmt.Sprintf("%s verbunden; %d A-/AAAA-Ziele und die vorhandenen CNAMEs importiert.", zone.Name, imported) if importErr != nil { noticeText = zone.Name + " verbunden, der Import kann jetzt manuell erneut gestartet werden." } s.redirectNotice(w, r, "/domains/"+strconv.FormatInt(zone.ID, 10), noticeText) } func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request, session *model.Session) { zoneID, ok := pathID(w, r) if !ok { return } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } if !s.canAccessZone(r.Context(), session, zoneID) { http.NotFound(w, r) return } var targets []model.Target if session.Role == "admin" { targets, err = s.store.ListTargets(r.Context(), zoneID) } else { targets, err = s.store.ListAccessibleTargets(r.Context(), session.UserID, zoneID) } if err != nil { s.serverError(w, err) return } var archivedTargets []model.Target if session.Role == "admin" { archivedTargets, err = s.store.ListArchivedTargets(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } } var cnameRecords []model.CNAMERecord if session.Role == "admin" { cnameRecords, err = s.store.ListCNAMERecords(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } } settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } canCreateTargets := session.Role == "admin" manageTargetIDs := make(map[int64]bool, len(targets)) if session.Role == "admin" { for _, target := range targets { manageTargetIDs[target.ID] = true } } else { grants, grantErr := s.store.ListAccessGrants(r.Context(), session.UserID) if grantErr != nil { s.serverError(w, grantErr) return } domainManaged := false for _, grant := range grants { if grant.ZoneID != zoneID { continue } if grant.TargetID == nil { domainManaged = grant.CanManageTargets canCreateTargets = grant.CanCreateTargets } else if grant.CanManageTargets { manageTargetIDs[*grant.TargetID] = true } } if domainManaged { for _, target := range targets { manageTargetIDs[target.ID] = true } } } s.render(w, "domain_detail", viewData{Title: zone.Name, Page: "domains", CSRF: session.CSRFToken, Flash: notice(r), Zone: zone, Targets: targets, ArchivedTargets: archivedTargets, CNAMERecords: cnameRecords, Settings: settings, CanCreateTargets: canCreateTargets, ManageTargetIDs: manageTargetIDs, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) } func (s *Server) domainImport(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } zoneID, ok := pathID(w, r) if !ok { return } imported, err := s.scheduler.SyncZone(r.Context(), zoneID) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), "Import fehlgeschlagen: "+safeMessage(err), "error") return } s.redirectNotice(w, r, domainURL(zoneID), fmt.Sprintf("Import abgeschlossen: %d neue A-/AAAA-Ziele; CNAMEs wurden synchronisiert.", imported)) } func (s *Server) domainTTLSave(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 } zoneID, ok := pathID(w, r) if !ok { return } ttl, err := parseTTL(r.FormValue("ttl")) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } targets, err := s.store.ListTargets(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } cnames, err := s.store.ListCNAMERecords(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } changed, err := s.scheduler.SyncZoneTTL(r.Context(), zoneID, targets, cnames, ttl) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), "TTL konnte nicht für alle Records gesetzt werden: "+safeMessage(err), "error") return } if err := s.store.UpdateZoneTargetTTLs(r.Context(), zoneID, ttl); err != nil { _, _ = s.scheduler.SyncZone(r.Context(), zoneID) s.serverError(w, err) return } if err := s.store.UpdateZoneCNAMERecordTTLs(r.Context(), zoneID, ttl); err != nil { _, _ = s.scheduler.SyncZone(r.Context(), zoneID) s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "zone.ttl.updated", fmt.Sprintf("%s: TTL für %d verwaltete Records auf %d Sekunden gesetzt durch %s", zone.Name, len(targets)+len(cnames), ttl, session.Username)) s.redirectNotice(w, r, domainURL(zoneID), fmt.Sprintf("TTL für %d verwaltete Records auf %d Sekunden gesetzt (%d RRsets bei Hetzner geändert).", len(targets)+len(cnames), ttl, changed)) } func (s *Server) domainRemove(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } zoneID, ok := pathID(w, r) if !ok { return } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } if err := s.store.DeleteZone(r.Context(), zoneID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "zone.removed", "Verwaltung beendet: "+zone.Name) s.redirectNotice(w, r, "/domains", "Die lokale Verwaltung für "+zone.Name+" wurde entfernt; Hetzner-DNS-Records bleiben erhalten.") } func (s *Server) domainDelete(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 } zoneID, ok := pathID(w, r) if !ok { return } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } targets, err := s.store.ListTargets(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } archivedTargets, err := s.store.ListArchivedTargets(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } targets = append(targets, archivedTargets...) cnames, err := s.store.ListCNAMERecords(r.Context(), zoneID) if err != nil { s.serverError(w, err) return } deleted, err := s.scheduler.DeleteZoneRecords(r.Context(), zoneID, targets, cnames) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), "Domain konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") return } if err := s.store.DeleteZone(r.Context(), zoneID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "zone.deleted", fmt.Sprintf("Domain vollständig gelöscht: %s, %d Hetzner-RRsets entfernt durch %s", zone.Name, deleted, session.Username)) s.redirectNotice(w, r, "/domains", fmt.Sprintf("%s und alle Einträge der WebUI wurden gelöscht; %d zugehörige Hetzner-RRsets wurden entfernt.", zone.Name, deleted)) } func (s *Server) domainToken(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 } zoneID, 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, domainURL(zoneID), "Passwort nicht korrekt; Token bleibt verborgen.", "error") return } zone, credential, err := s.store.GetZoneWithCredential(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } token, 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(), "credential.revealed", "API-Token angezeigt: "+zone.Name) s.render(w, "token", viewData{ Title: "API-Token anzeigen", Page: "domains", CSRF: session.CSRFToken, SecretTitle: "Hetzner API-Token", SecretScope: "Zone: " + zone.Name, SecretValue: token, SecretBackURL: domainURL(zone.ID), User: userFromSession(session), IsAdmin: true, Now: time.Now(), }) } func (s *Server) targetCreate(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 } zoneID, err := strconv.ParseInt(r.FormValue("zone_id"), 10, 64) if err != nil || zoneID < 1 { http.Error(w, "Ungültige Domain", http.StatusBadRequest) return } if session.Role != "admin" { allowed, permissionErr := s.store.CanCreateTarget(r.Context(), session.UserID, zoneID) if permissionErr != nil { s.serverError(w, permissionErr) return } if !allowed { http.NotFound(w, r) return } } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } recordName, fqdn, err := normalizeRecord(r.FormValue("record_name"), zone.Name) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } if session.Role != "admin" && recordName == "@" { s.redirectNotice(w, r, domainURL(zoneID), "Mitglieder dürfen nur Subdomains, nicht den Root-Record anlegen.", "error") return } updateIPv4, updateIPv6 := checked(r, "update_ipv4"), checked(r, "update_ipv6") if updateIPv6 && !settings.IPv6Enabled { s.redirectNotice(w, r, domainURL(zoneID), "IPv6 ist global deaktiviert und kann für dieses Ziel nicht aktiviert werden.", "error") return } ttl, err := parseTTL(r.FormValue("ttl")) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } description := strings.TrimSpace(r.FormValue("description")) if len([]rune(description)) > 512 { s.redirectNotice(w, r, domainURL(zoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") return } activeRequested := checked(r, "active") active := targetDynDNSActive(activeRequested, updateIPv4, updateIPv6) activeAutoDisabled := activeRequested && !active staticIPv4, staticIPv6 := "", "" if !active { staticIPv4, staticIPv6, err = parseStaticTargetIPs(r, updateIPv4, updateIPv6) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } } target, err := s.store.CreateTarget(r.Context(), model.Target{ZoneID: zoneID, RecordName: recordName, FQDN: fqdn, UpdateIPv4: updateIPv4, UpdateIPv6: updateIPv6, Active: active, StaticIPv4: staticIPv4, StaticIPv6: staticIPv6, Description: description, TTL: ttl}) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), "Ziel konnte nicht angelegt werden: "+safeMessage(err), "error") return } 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.redirectNotice(w, r, domainURL(zoneID), "Ziel angelegt, aber die feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") return } _ = s.store.UpdateTargetResult(r.Context(), target.ID, status, "", updated) } _ = s.store.AddAudit(r.Context(), "target.created", "DynDNS-Ziel angelegt: "+target.FQDN+" durch "+session.Username) triggered := target.Active && s.triggerUpdateIfEnabled(r.Context()) noticeText := "Ziel angelegt." if activeAutoDisabled { noticeText += " DynDNS wurde deaktiviert, weil keine IP-Familie ausgewählt ist." } if !target.Active && hasStaticIP(target) { noticeText += " Feste IP gespeichert." } s.redirectNotice(w, r, domainURL(zoneID), noticeText+activeNotice(target.Active, triggered)) } func (s *Server) targetSave(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 } targetID, ok := pathID(w, r) if !ok { return } target, err := s.store.GetTarget(r.Context(), targetID) if err != nil { http.NotFound(w, r) return } if !s.canAccessTarget(r.Context(), session, target) { http.NotFound(w, r) return } if session.Role != "admin" { allowed, permissionErr := s.store.CanManageTarget(r.Context(), session.UserID, target.ZoneID, target.ID) if permissionErr != nil { s.serverError(w, permissionErr) return } if !allowed { http.NotFound(w, r) return } } if !target.ArchivedAt.IsZero() { http.NotFound(w, r) return } settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } wasActive := target.Active previousIPv4, previousIPv6, previousTTL := target.UpdateIPv4, target.UpdateIPv6, target.TTL if session.Role == "admin" { target.UpdateIPv4 = checked(r, "update_ipv4") if checked(r, "update_ipv6") && !settings.IPv6Enabled { s.redirectNotice(w, r, domainURL(target.ZoneID), "IPv6 ist global deaktiviert und kann für dieses Ziel nicht aktiviert werden.", "error") return } target.UpdateIPv6 = checked(r, "update_ipv6") } if !settings.IPv6Enabled { target.UpdateIPv6 = false target.StaticIPv6 = "" } activeRequested := checked(r, "active") target.Active = targetDynDNSActive(activeRequested, target.UpdateIPv4, target.UpdateIPv6) activeAutoDisabled := activeRequested && !target.Active target.Description = strings.TrimSpace(r.FormValue("description")) ttl, err := parseEditableTTL(r.FormValue("ttl"), target.TTL) if err != nil { s.redirectNotice(w, r, domainURL(target.ZoneID), err.Error(), "error") return } target.TTL = ttl if len([]rune(target.Description)) > 512 { s.redirectNotice(w, r, domainURL(target.ZoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") return } if target.Active { target.StaticIPv4, target.StaticIPv6 = "", "" } else { target.StaticIPv4, target.StaticIPv6, err = parseStaticTargetIPs(r, target.UpdateIPv4, target.UpdateIPv6) if err != nil { s.redirectNotice(w, r, domainURL(target.ZoneID), err.Error(), "error") return } } deleted, err := s.scheduler.DeleteTargetFamilies(r.Context(), target, previousIPv4 && !target.UpdateIPv4, previousIPv6 && !target.UpdateIPv6) if err != nil { s.redirectNotice(w, r, domainURL(target.ZoneID), "A-/AAAA-Record konnte nicht bei Hetzner gelöscht werden: "+safeMessage(err), "error") return } if previousTTL != target.TTL && (target.Active || !hasStaticIP(target)) { if err := s.scheduler.SyncTargetTTL(r.Context(), target); err != nil { s.redirectNotice(w, r, domainURL(target.ZoneID), "TTL konnte nicht bei Hetzner aktualisiert werden: "+safeMessage(err), "error") return } } if err := s.store.UpdateTarget(r.Context(), target); err != nil { s.serverError(w, err) return } 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.redirectNotice(w, r, domainURL(target.ZoneID), "Feste IP konnte nicht bei Hetzner gesetzt werden: "+safeMessage(syncErr), "error") return } _ = s.store.UpdateTargetResult(r.Context(), target.ID, status, "", updated) } else if target.Active { if err := s.scheduler.SyncTargetComment(r.Context(), target); err != nil { s.logger.Warn("sync target comment", "target", target.FQDN, "error", err) } } _ = s.store.AddAudit(r.Context(), "target.updated", "DynDNS-Ziel geändert: "+target.FQDN+" durch "+session.Username) needsUpdate := target.Active && (!wasActive || previousIPv4 != target.UpdateIPv4 || previousIPv6 != target.UpdateIPv6 || previousTTL != target.TTL) triggered := needsUpdate && s.triggerUpdateIfEnabled(r.Context()) noticeText := "Ziel gespeichert." if activeAutoDisabled { noticeText += " DynDNS wurde deaktiviert, weil keine IP-Familie ausgewählt ist." } if deleted > 0 { noticeText += " A-/AAAA-Record entfernt." } s.redirectNotice(w, r, domainURL(target.ZoneID), noticeText+activeNotice(needsUpdate, triggered)) } func (s *Server) targetDelete(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 } targetID, ok := pathID(w, r) if !ok { return } target, err := s.store.GetTarget(r.Context(), targetID) if err != nil { http.NotFound(w, r) return } deleted, err := s.scheduler.DeleteTargetFamilies(r.Context(), target, true, true) if err != nil { s.redirectNotice(w, r, domainURL(target.ZoneID), "Ziel konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") return } if err := s.store.DeleteTarget(r.Context(), target.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "target.deleted", "Ziel vollständig gelöscht: "+target.FQDN+" durch "+session.Username) s.redirectNotice(w, r, domainURL(target.ZoneID), fmt.Sprintf("Ziel gelöscht; %d A-/AAAA-Record(s) bei Hetzner entfernt.", deleted)) } func (s *Server) targetArchive(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 } targetID, ok := pathID(w, r) if !ok { return } target, err := s.store.GetTarget(r.Context(), targetID) if err != nil { http.NotFound(w, r) return } if !target.ArchivedAt.IsZero() { s.redirectNotice(w, r, domainURL(target.ZoneID), "Die Verknüpfung ist bereits getrennt.", "info") return } if err := s.store.ArchiveTarget(r.Context(), target.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "target.archived", "DynDNS-Verknüpfung getrennt: "+target.FQDN+" durch "+session.Username) s.redirectNotice(w, r, domainURL(target.ZoneID), "Verknüpfung getrennt. Der Hetzner-DNS-Record wurde nicht verändert.") } func (s *Server) targetRestore(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 } targetID, ok := pathID(w, r) if !ok { return } target, err := s.store.GetTarget(r.Context(), targetID) if err != nil { http.NotFound(w, r) return } if target.ArchivedAt.IsZero() { s.redirectNotice(w, r, domainURL(target.ZoneID), "Das Ziel ist bereits verknüpft.", "info") return } if err := s.store.RestoreTarget(r.Context(), target.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "target.restored", "DynDNS-Verknüpfung wiederhergestellt: "+target.FQDN+" durch "+session.Username) s.redirectNotice(w, r, domainURL(target.ZoneID), "Verknüpfung wiederhergestellt. Das Ziel bleibt zunächst deaktiviert.") } func (s *Server) cnameCreate(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 } zoneID, err := strconv.ParseInt(r.FormValue("zone_id"), 10, 64) if err != nil || zoneID < 1 { http.Error(w, "Ungültige Domain", http.StatusBadRequest) return } zone, err := s.store.GetZone(r.Context(), zoneID) if err != nil { http.NotFound(w, r) return } recordName, fqdn, err := normalizeRecord(r.FormValue("record_name"), zone.Name) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } if recordName == "@" { s.redirectNotice(w, r, domainURL(zoneID), "Ein CNAME kann nicht als Root-Record angelegt werden.", "error") return } destination, err := normalizeCNAMEDestination(r.FormValue("destination")) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } ttl, err := parseTTL(r.FormValue("ttl")) if err != nil { s.redirectNotice(w, r, domainURL(zoneID), err.Error(), "error") return } description := strings.TrimSpace(r.FormValue("description")) if len([]rune(description)) > 512 { s.redirectNotice(w, r, domainURL(zoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") return } record := model.CNAMERecord{ZoneID: zoneID, Active: checked(r, "active"), RecordName: recordName, FQDN: fqdn, Destination: destination, Description: description, TTL: ttl} if record.Active { if err := s.scheduler.CreateCNAME(r.Context(), record); err != nil { s.redirectNotice(w, r, domainURL(zoneID), "CNAME konnte nicht angelegt werden: "+safeMessage(err), "error") return } } created, err := s.store.CreateCNAMERecord(r.Context(), record) if err != nil { _, _ = s.scheduler.SyncZone(r.Context(), zoneID) s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "cname.created", "CNAME angelegt: "+created.FQDN+" → "+created.Destination+" durch "+session.Username) noticeText := "CNAME angelegt." if !record.Active { noticeText += " Der Hetzner-Record bleibt deaktiviert." } s.redirectNotice(w, r, domainURL(zoneID), noticeText) } func (s *Server) cnameSave(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 } recordID, ok := pathID(w, r) if !ok { return } previous, err := s.store.GetCNAMERecord(r.Context(), recordID) if err != nil { http.NotFound(w, r) return } record := previous record.Active = checked(r, "active") record.Destination, err = normalizeCNAMEDestination(r.FormValue("destination")) if err != nil { s.redirectNotice(w, r, domainURL(record.ZoneID), err.Error(), "error") return } record.TTL, err = parseEditableTTL(r.FormValue("ttl"), previous.TTL) if err != nil { s.redirectNotice(w, r, domainURL(record.ZoneID), err.Error(), "error") return } record.Description = strings.TrimSpace(r.FormValue("description")) if len([]rune(record.Description)) > 512 { s.redirectNotice(w, r, domainURL(record.ZoneID), "Die Beschreibung darf höchstens 512 Zeichen haben.", "error") return } var providerErr error switch { case previous.Active && !record.Active: providerErr = s.scheduler.DeleteCNAME(r.Context(), previous) case !previous.Active && record.Active: providerErr = s.scheduler.CreateCNAME(r.Context(), record) case previous.Active && record.Active: providerErr = s.scheduler.UpdateCNAME(r.Context(), previous, record) } if providerErr != nil { s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME konnte nicht gespeichert werden: "+safeMessage(providerErr), "error") return } if err := s.store.UpdateCNAMERecord(r.Context(), record); err != nil { _, _ = s.scheduler.SyncZone(r.Context(), record.ZoneID) s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "cname.updated", fmt.Sprintf("CNAME geändert: %s, aktiv=%t durch %s", record.FQDN, record.Active, session.Username)) state := "deaktiviert." if record.Active { state = "aktiviert." } s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME gespeichert und "+state) } func (s *Server) cnameDelete(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 } recordID, ok := pathID(w, r) if !ok { return } record, err := s.store.GetCNAMERecord(r.Context(), recordID) if err != nil { http.NotFound(w, r) return } deleted, err := s.scheduler.DeleteZoneRecords(r.Context(), record.ZoneID, nil, []model.CNAMERecord{record}) if err != nil { s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME konnte nicht vollständig gelöscht werden: "+safeMessage(err), "error") return } if err := s.store.DeleteCNAMERecord(r.Context(), record.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "cname.deleted", "CNAME vollständig gelöscht: "+record.FQDN+" durch "+session.Username) s.redirectNotice(w, r, domainURL(record.ZoneID), fmt.Sprintf("CNAME vollständig gelöscht; %d Hetzner-RRset entfernt.", deleted)) } func (s *Server) cnameRemove(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 } recordID, ok := pathID(w, r) if !ok { return } record, err := s.store.GetCNAMERecord(r.Context(), recordID) if err != nil { http.NotFound(w, r) return } if err := s.store.DeleteCNAMERecord(r.Context(), record.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "cname.removed", "CNAME nur aus WebUI entfernt: "+record.FQDN+" durch "+session.Username) s.redirectNotice(w, r, domainURL(record.ZoneID), "CNAME aus der WebUI entfernt; der Hetzner-Record blieb unverändert.") } func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } s.render(w, "settings", viewData{Title: "Einstellungen", Page: "settings", CSRF: session.CSRFToken, Flash: notice(r), Settings: settings, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) } func (s *Server) settingsSave(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 } interval, err := strconv.Atoi(r.FormValue("interval")) if err != nil || interval < 60 || interval > 86400 { s.redirectNotice(w, r, "/settings", "Das Intervall muss zwischen 60 und 86.400 Sekunden liegen.", "error") return } updaterEnabled, ipv6Enabled := checked(r, "updater_enabled"), checked(r, "ipv6_enabled") current, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } ipv4, ipv6 := lines(r.FormValue("ipv4_endpoints")), lines(r.FormValue("ipv6_endpoints")) if len(ipv4) == 0 { s.redirectNotice(w, r, "/settings", "Für IPv4 ist mindestens ein HTTPS-Endpoint erforderlich.", "error") return } if len(ipv6) == 0 { ipv6 = current.IPv6Endpoints } if ipv6Enabled && len(ipv6) == 0 { 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 ipv6Enabled { if err := s.scheduler.ValidateEndpoints(r.Context(), ipv6); err != nil { s.redirectNotice(w, r, "/settings", "IPv6-Endpoint ungültig: "+safeMessage(err), "error") return } } deletedIPv6 := 0 if !ipv6Enabled { deletedIPv6, err = s.scheduler.DisableIPv6(r.Context()) if err != nil { s.redirectNotice(w, r, "/settings", "IPv6 konnte nicht vollständig deaktiviert werden: "+safeMessage(err), "error") return } } if err := s.store.UpdateSettings(r.Context(), interval, updaterEnabled, ipv6Enabled, ipv4, ipv6); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "settings.updated", fmt.Sprintf("Updater geändert: Dienst=%t, IPv6=%t, Intervall=%ds, entfernte AAAA=%d", updaterEnabled, ipv6Enabled, interval, deletedIPv6)) s.scheduler.Trigger() noticeText := "Einstellungen gespeichert." if !ipv6Enabled { noticeText += fmt.Sprintf(" %d AAAA-Record(s) entfernt und IPv6 bei allen Zielen deaktiviert.", deletedIPv6) } s.redirectNotice(w, r, "/settings", noticeText) } func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } if !settings.UpdaterEnabled { s.redirectNotice(w, r, "/settings", "Der Updater ist deaktiviert. Aktiviere zuerst den Dienst.", "info") return } s.scheduler.Trigger() s.redirectNotice(w, r, "/", "Aktualisierung gestartet.") } func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, session *model.Session) { if s.updates == nil { http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) return } statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond) _, _ = s.updates.UpdateStatus(statusCtx) cancel() s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()}) } func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) { if s.updates == nil { http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) return } if err := s.parseForm(w, r); err != nil { return } if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } hash, err := s.store.PasswordHash(r.Context(), session.UserID) if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) { s.redirectNotice(w, r, "/system", "Admin-Passwort ist nicht korrekt.", "error") return } if err := s.updates.SaveCredential(r.Context(), r.FormValue("registry_username"), r.FormValue("registry_token")); err != nil { s.redirectNotice(w, r, "/system", "Update-Zugang konnte nicht gespeichert werden: "+safeMessage(err), "error") return } _ = s.store.AddAudit(r.Context(), "software_update.credential_changed", "Update-Zugang geändert durch "+session.Username) s.updates.TriggerCheck() s.redirectNotice(w, r, "/system", "Update-Zugang verschlüsselt gespeichert; Versionsprüfung gestartet.") } func (s *Server) softwareUpdateCheck(w http.ResponseWriter, r *http.Request, session *model.Session) { if s.updates == nil { http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) return } if err := s.parseForm(w, r); err != nil { return } if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } checkCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) err := s.updates.CheckNow(checkCtx) cancel() if err != nil { if errors.Is(err, appupdate.ErrCredentialMissing) { s.redirectNotice(w, r, "/system#update-access", "Richte zuerst den Update-Zugang ein.", "info") return } s.redirectNotice(w, r, "/system", "Versionsprüfung nicht möglich: "+safeMessage(err)+". Der laufende Dienst bleibt unverändert.", "error") return } s.redirectNotice(w, r, "/system", "Versionsprüfung abgeschlossen.") } func (s *Server) softwareUpdateStart(w http.ResponseWriter, r *http.Request, session *model.Session) { if s.updates == nil { http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) return } if err := s.parseForm(w, r); err != nil { return } if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } version := strings.TrimSpace(r.FormValue("version")) startCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) err := s.updates.RequestUpdate(startCtx, version) cancel() if err != nil { if strings.Contains(r.Header.Get("Accept"), "application/json") { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]any{"accepted": false, "message": safeMessage(err)}) return } s.redirectNotice(w, r, "/system", "Update konnte nicht gestartet werden: "+safeMessage(err), "error") return } _ = s.store.AddAudit(r.Context(), "software_update.started", "Software-Update auf "+version+" gestartet durch "+session.Username) if strings.Contains(r.Header.Get("Accept"), "application/json") { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "version": version}) return } s.redirectNotice(w, r, "/system", "Update auf "+version+" wurde gestartet.", "info") } func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { if s.updates == nil { http.Error(w, "Software-Updates sind nicht konfiguriert.", http.StatusServiceUnavailable) return } statusCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second) status, statusErr := s.updates.UpdateStatus(statusCtx) cancel() state := s.updates.State() w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "agent_available": statusErr == nil, "phase": status.Phase, "message": status.Message, "target_version": status.TargetVersion, "started_at": status.StartedAt, "updated_at": status.UpdatedAt, "current_version": state.CurrentVersion, "latest_version": state.LatestVersion, }) } func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) { targets, err := s.store.ListTargetsWithErrors(r.Context()) if err != nil { s.serverError(w, err) return } zones, err := s.store.ListZonesWithErrors(r.Context()) if err != nil { s.serverError(w, err) return } runs, err := s.store.FailedRuns(r.Context()) if err != nil { s.serverError(w, err) return } piHoles, err := s.store.ListPiHoleIntegrationsWithErrors(r.Context()) if err != nil { s.serverError(w, err) return } s.render(w, "errors", viewData{Title: "Fehler", Page: "errors", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ErrorTargets: targets, ErrorZones: zones, ErrorRuns: runs, ErrorPiHoles: piHoles, ErrorCount: len(targets) + len(zones) + len(runs) + len(piHoles), Now: time.Now()}) } func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *model.Session) { if !s.validSessionCSRF(r, session) { http.Error(w, "Ungültige Anfrage", http.StatusForbidden) return } if err := s.store.ClearErrors(r.Context()); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "errors.cleared", "Fehlerliste geleert durch "+session.Username) s.redirectNotice(w, r, "/errors", "Fehlerliste geleert.") } func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, session *model.Session) { s.render(w, "security", viewData{Title: "Mein Konto", Page: "account", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()}) } func (s *Server) securityRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) { http.Redirect(w, r, "/account", http.StatusPermanentRedirect) } func (s *Server) passwordChange(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 } hash, err := s.store.PasswordHash(r.Context(), session.UserID) if err != nil || !s.verifyPassword(r.Context(), hash, r.FormValue("current_password")) { s.redirectNotice(w, r, "/account", "Aktuelles Passwort ist nicht korrekt.", "error") return } if r.FormValue("new_password") != r.FormValue("confirm_password") { s.redirectNotice(w, r, "/account", "Die neuen Passwörter stimmen nicht überein.", "error") return } newHash, err := secure.HashPassword(r.FormValue("new_password")) if err != nil { s.redirectNotice(w, r, "/account", err.Error(), "error") return } if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username) s.redirectNotice(w, r, "/account", "Passwort geändert; andere eigene Sitzungen wurden abgemeldet.") } func (s *Server) statusAPI(w http.ResponseWriter, r *http.Request, _ *model.Session) { settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } active, _ := s.store.CountActiveTargets(r.Context()) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "last_ipv4": settings.LastIPv4, "last_ipv6": settings.LastIPv6, "last_run_at": settings.LastRunAt, "last_run_status": settings.LastRunStatus, "next_run_at": s.scheduler.NextRunAt(), "active_targets": active, "running": s.scheduler.IsRunning(), "enabled": settings.UpdaterEnabled, "ipv6_enabled": settings.IPv6Enabled, }) } func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, session *model.Session) { settings, err := s.store.GetSettings(r.Context()) if err != nil { s.serverError(w, err) return } response := schedulerResponse{ NextRunUnix: unixSeconds(s.scheduler.NextRunAt()), Running: s.scheduler.IsRunning(), Enabled: settings.UpdaterEnabled, IPv6Enabled: settings.IPv6Enabled, LastIPv4: settings.LastIPv4, LastIPv6: settings.LastIPv6, } if session.Role == "admin" && r.URL.Query().Get("runs") == "1" { runs, err := s.store.RecentRuns(r.Context(), 6) if err != nil { s.serverError(w, err) return } recentRuns := make([]schedulerRunResponse, 0, len(runs)) for _, run := range runs { recentRuns = append(recentRuns, schedulerRunResponse{ ID: run.ID, StartedAtUnix: unixSeconds(run.StartedAt), Status: run.Status, Summary: run.Summary, }) } response.RecentRuns = &recentRuns } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(response) } func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *model.Session) { users, err := s.store.ListUsers(r.Context()) if err != nil { s.serverError(w, err) return } s.render(w, "users", viewData{Title: "Zugriff & Sicherheit", Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()}) } func (s *Server) usersRedirect(w http.ResponseWriter, r *http.Request, _ *model.Session) { http.Redirect(w, r, "/access", http.StatusPermanentRedirect) } func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) { s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()}) } func (s *Server) userCreate(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 } form := map[string]string{"username": strings.TrimSpace(r.FormValue("username")), "display_name": strings.TrimSpace(r.FormValue("display_name")), "role": r.FormValue("role")} username, err := normalizeUsername(form["username"]) if err != nil { s.renderUserNewError(w, session, err.Error(), form) return } if form["display_name"] == "" || len([]rune(form["display_name"])) > 80 { s.renderUserNewError(w, session, "Bitte einen Anzeigenamen mit höchstens 80 Zeichen angeben.", form) return } if form["role"] != "admin" && form["role"] != "member" { s.renderUserNewError(w, session, "Ungültige Rolle.", form) return } if r.FormValue("password") != r.FormValue("confirm_password") { s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form) return } hash, err := secure.HashProvisionedPassword(r.FormValue("password")) if err != nil { s.renderUserNewError(w, session, err.Error(), form) return } user, err := s.store.CreateUser(r.Context(), username, form["display_name"], hash, form["role"]) if err != nil { s.renderUserNewError(w, session, "Benutzer konnte nicht angelegt werden: "+safeMessage(err), form) return } _ = s.store.AddAudit(r.Context(), "user.created", "Benutzer angelegt: "+user.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(user.ID, 10), "Benutzer angelegt. Weise jetzt Domains oder einzelne Hosts zu.") } func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *model.Session) { userID, ok := pathID(w, r) if !ok { return } managedUser, err := s.store.GetUser(r.Context(), userID) if err != nil { http.NotFound(w, r) return } zones, err := s.store.ListZones(r.Context()) if err != nil { s.serverError(w, err) return } grants, err := s.store.ListAccessGrants(r.Context(), userID) if err != nil { s.serverError(w, err) return } domainGrants := make(map[int64]bool) domainManaged := make(map[int64]bool) createTargetGrants := make(map[int64]bool) targetGrants := make(map[int64]bool) managedTargetGrants := make(map[int64]bool) for _, grant := range grants { if grant.TargetID == nil { domainGrants[grant.ZoneID] = true domainManaged[grant.ZoneID] = grant.CanManageTargets createTargetGrants[grant.ZoneID] = grant.CanCreateTargets } else { targetGrants[*grant.TargetID] = true managedTargetGrants[*grant.TargetID] = grant.CanManageTargets } } accessZones := make([]accessZone, 0, len(zones)) for _, zone := range zones { targets, targetErr := s.store.ListTargets(r.Context(), zone.ID) if targetErr != nil { s.serverError(w, targetErr) return } accessZones = append(accessZones, accessZone{Zone: zone, Targets: targets, DomainGranted: domainGrants[zone.ID], DomainManaged: domainManaged[zone.ID], CanCreateTargets: createTargetGrants[zone.ID], GrantedTargetIDs: targetGrants, ManagedTargetIDs: managedTargetGrants}) } s.render(w, "user_detail", viewData{Title: managedUser.DisplayName, Page: "access", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, ManagedUser: managedUser, AccessZones: accessZones, Now: time.Now()}) } func (s *Server) userAccessSave(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 } userID, ok := pathID(w, r) if !ok { return } managedUser, err := s.store.GetUser(r.Context(), userID) if err != nil { http.NotFound(w, r) return } if managedUser.IsAdmin() { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Admins haben immer vollständigen Zugriff; Einzelrechte sind nicht nötig.", "info") return } selection := store.AccessSelection{} for key, values := range r.Form { if len(values) == 0 || values[0] == "" { continue } if raw, found := strings.CutPrefix(key, "zone_"); found { if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { selection.ZoneIDs = append(selection.ZoneIDs, id) } } if raw, found := strings.CutPrefix(key, "target_"); found { if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { selection.TargetIDs = append(selection.TargetIDs, id) } } if raw, found := strings.CutPrefix(key, "manage_zone_"); found { if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { selection.ManageZoneIDs = append(selection.ManageZoneIDs, id) } } if raw, found := strings.CutPrefix(key, "manage_target_"); found { if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { selection.ManageTargetIDs = append(selection.ManageTargetIDs, id) } } if raw, found := strings.CutPrefix(key, "create_zone_"); found { if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 { selection.CreateTargetZoneIDs = append(selection.CreateTargetZoneIDs, id) } } } if err := s.store.ReplaceAccessGrants(r.Context(), userID, selection); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "user.access_updated", "Zugriffe geändert: "+managedUser.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Zugriffsrechte gespeichert.") } func (s *Server) userStateSave(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 } userID, ok := pathID(w, r) if !ok { return } managedUser, err := s.store.GetUser(r.Context(), userID) if err != nil { http.NotFound(w, r) return } displayName := strings.TrimSpace(r.FormValue("display_name")) role, active := r.FormValue("role"), checked(r, "active") if displayName == "" || len([]rune(displayName)) > 80 || (role != "admin" && role != "member") { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerdaten sind ungültig.", "error") return } if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil { if errors.Is(err, store.ErrLastActiveAdmin) { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error") return } s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "user.state_updated", "Benutzer geändert: "+managedUser.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Benutzerstatus gespeichert.") } func (s *Server) userPasswordReset(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 } userID, ok := pathID(w, r) if !ok { return } managedUser, err := s.store.GetUser(r.Context(), userID) if err != nil { http.NotFound(w, r) return } if r.FormValue("password") != r.FormValue("confirm_password") { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Die Passwörter stimmen nicht überein.", "error") return } hash, err := secure.HashPassword(r.FormValue("password")) if err != nil { s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), err.Error(), "error") return } if err := s.store.SetUserPassword(r.Context(), userID, hash); err != nil { s.serverError(w, err) return } _ = s.store.AddAudit(r.Context(), "user.password_reset", "Passwort zurückgesetzt: "+managedUser.Username+" durch "+session.Username) s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Passwort zurückgesetzt; der Benutzer muss sich neu anmelden.") } 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) if err != nil { s.serverError(w, err) return } if session == nil { s.redirectNotice(w, r, "/login", "Bitte melde dich an.", "info") return } next(w, r, session) }) } func (s *Server) adminOnly(next func(http.ResponseWriter, *http.Request, *model.Session)) http.Handler { return s.authenticated(func(w http.ResponseWriter, r *http.Request, session *model.Session) { if session.Role != "admin" { http.Error(w, "Diese Aktion erfordert Administratorrechte.", http.StatusForbidden) return } next(w, r, session) }) } func (s *Server) zonesForSession(ctx context.Context, session *model.Session) ([]model.Zone, error) { if session.Role == "admin" { return s.store.ListZones(ctx) } return s.store.ListAccessibleZones(ctx, session.UserID) } func (s *Server) activeTargetsForSession(ctx context.Context, session *model.Session) (int, error) { if session.Role == "admin" { return s.store.CountActiveTargets(ctx) } return s.store.CountActiveAccessibleTargets(ctx, session.UserID) } func (s *Server) canAccessZone(ctx context.Context, session *model.Session, zoneID int64) bool { if session.Role == "admin" { return true } allowed, err := s.store.CanAccessZone(ctx, session.UserID, zoneID) return err == nil && allowed } func (s *Server) canAccessTarget(ctx context.Context, session *model.Session, target model.Target) bool { if session.Role == "admin" { return true } allowed, err := s.store.CanAccessTarget(ctx, session.UserID, target.ZoneID, target.ID) return err == nil && allowed } func userFromSession(session *model.Session) model.User { return model.User{ID: session.UserID, Username: session.Username, DisplayName: session.DisplayName, Role: session.Role, Active: true} } func (s *Server) session(ctx context.Context, r *http.Request) (*model.Session, error) { cookie, err := r.Cookie("dyndns_session") if err != nil || cookie.Value == "" { return nil, nil } return s.store.GetSession(ctx, secure.HashToken(cookie.Value)) } func (s *Server) startSession(w http.ResponseWriter, ctx context.Context, user model.User) error { token, err := secure.RandomToken(32) if err != nil { return err } csrf, err := secure.RandomToken(32) if err != nil { return err } _, err = s.store.CreateSession(ctx, model.Session{UserID: user.ID, TokenHash: secure.HashToken(token), CSRFToken: csrf, ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL)}) if err != nil { return err } http.SetCookie(w, &http.Cookie{Name: "dyndns_session", Value: token, Path: "/", MaxAge: int(s.config.SessionTTL.Seconds()), HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) return nil } func (s *Server) clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: "dyndns_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) } func (s *Server) publicCSRF(w http.ResponseWriter, r *http.Request) string { if cookie, err := r.Cookie("dyndns_public_csrf"); err == nil && len(cookie.Value) >= 32 { return cookie.Value } token, err := secure.RandomToken(24) if err != nil { return "" } http.SetCookie(w, &http.Cookie{Name: "dyndns_public_csrf", Value: token, Path: "/", MaxAge: 3600, HttpOnly: true, Secure: s.config.CookieSecure, SameSite: http.SameSiteStrictMode}) return token } func (s *Server) validPublicCSRF(r *http.Request) bool { cookie, err := r.Cookie("dyndns_public_csrf") if err != nil || cookie.Value == "" { return false } return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(r.FormValue("csrf"))) == 1 } func (s *Server) validSessionCSRF(r *http.Request, session *model.Session) bool { return subtle.ConstantTimeCompare([]byte(session.CSRFToken), []byte(r.FormValue("csrf"))) == 1 } func (s *Server) parseForm(w http.ResponseWriter, r *http.Request) error { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) var err error if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") { err = r.ParseMultipartForm(64 << 10) } else { err = r.ParseForm() } if err != nil { http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest) return err } return nil } func (s *Server) triggerUpdateIfEnabled(ctx context.Context) bool { settings, err := s.store.GetSettings(ctx) if err != nil || !settings.UpdaterEnabled { return false } s.scheduler.Trigger() return true } func (s *Server) render(w http.ResponseWriter, name string, data viewData) { if data.Mode == "" { data.Mode = s.config.Mode } if data.Page != "setup" && data.Page != "login" { if settings, err := s.store.GetSettings(context.Background()); err == nil { data.UpdaterEnabled = settings.UpdaterEnabled } if data.IsAdmin && s.updates != nil { data.SoftwareUpdate = s.updates.State() } } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := s.templates[name].ExecuteTemplate(w, name, data); err != nil { s.logger.Error("render template", "template", name, "error", err) } } func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string, form map[string]string) { s.render(w, name, viewData{Title: "Eingabe prüfen", Page: name, Mode: s.config.Mode, CSRF: csrf, Flash: newFlash(message, "error"), Form: form, Now: time.Now()}) } func (s *Server) renderUserNewError(w http.ResponseWriter, session *model.Session, message string, form map[string]string) { s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "access", CSRF: session.CSRFToken, Flash: newFlash(message, "error"), Form: form, User: userFromSession(session), IsAdmin: true, Now: time.Now()}) } func (s *Server) serverError(w http.ResponseWriter, err error) { s.logger.Error("request failed", "error", err) http.Error(w, "Interner Fehler. Details stehen nicht in der Oberfläche.", http.StatusInternalServerError) } func (s *Server) redirectNotice(w http.ResponseWriter, r *http.Request, target, message string, kinds ...string) { kind := "success" if len(kinds) > 0 { kind = kinds[0] } location, err := url.Parse(target) if err != nil { s.serverError(w, err) return } query := location.Query() query.Set("notice", strings.TrimSpace(message)) query.Set("notice_kind", normalizeFlashKind(kind)) location.RawQuery = query.Encode() http.Redirect(w, r, location.String(), http.StatusSeeOther) } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") if s.config.CookieSecure { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } func loadTemplates() (map[string]*template.Template, error) { pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "system", "token", "users", "user_new", "user_detail"} functions := template.FuncMap{ "formatTime": func(value time.Time) string { if value.IsZero() { return "–" } return value.Local().Format("02.01.2006 · 15:04") }, "formatEndpoints": func(values []string) string { return strings.Join(values, "\n") }, "unixSeconds": unixSeconds, "initial": func(value string) string { for _, character := range strings.TrimSpace(value) { return strings.ToUpper(string(character)) } return "U" }, "statusClass": statusClass, "ttlOptions": func() []int { return []int{60, 300, 600, 1800, 3600, 86400} }, "supportedTTL": supportedTTL, "contains": strings.Contains, "families": func(target model.Target) string { var result []string if target.UpdateIPv4 { result = append(result, "A") } if target.UpdateIPv6 { result = append(result, "AAAA") } return strings.Join(result, " + ") }, } result := make(map[string]*template.Template, len(pages)) for _, page := range pages { templateSet, err := template.New("layout").Funcs(functions).ParseFS(embeddedAssets, "templates/layout.html", "templates/"+page+".html") if err != nil { return nil, fmt.Errorf("parse %s template: %w", page, err) } result[page] = templateSet } return result, nil } func unixSeconds(value time.Time) int64 { if value.IsZero() { return 0 } return value.Unix() } func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id < 1 { http.Error(w, "Ungültige Kennung", http.StatusBadRequest) return 0, false } return id, true } func normalizeZone(raw string) (string, error) { zone, err := idna.Lookup.ToASCII(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")) if err != nil || strings.Count(zone, ".") < 1 || len(zone) > 253 { return "", errors.New("Bitte eine gültige Domain angeben, z. B. example.de") } return zone, nil } func normalizeUsername(raw string) (string, error) { username := strings.ToLower(strings.TrimSpace(raw)) if len(username) < 3 || len(username) > 40 { return "", errors.New("Benutzername muss 3 bis 40 Zeichen lang sein") } for _, character := range username { if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '_' && character != '-' && character != '.' { return "", errors.New("Benutzername darf nur Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich enthalten") } } return username, nil } func normalizeRecord(raw, zone string) (string, string, error) { value := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") if value == "" || value == "@" { return "@", zone, nil } if strings.HasSuffix(value, "."+zone) { value = strings.TrimSuffix(value, "."+zone) } value, err := idna.Lookup.ToASCII(value) if err != nil || value == "" || len(value) > 253 || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { return "", "", errors.New("Bitte eine gültige Subdomain angeben") } for _, label := range strings.Split(value, ".") { if len(label) == 0 || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return "", "", errors.New("Bitte eine gültige Subdomain angeben") } } return value, value + "." + zone, nil } func normalizeCNAMEDestination(raw string) (string, error) { value := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") value, err := idna.Lookup.ToASCII(value) if err != nil || value == "" || value == "@" || len(value) > 253 || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") || net.ParseIP(value) != nil { return "", errors.New("Bitte ein gültiges CNAME-Ziel als Domain angeben") } for _, label := range strings.Split(value, ".") { if len(label) == 0 || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return "", errors.New("Bitte ein gültiges CNAME-Ziel als Domain angeben") } } return value + ".", nil } func parseStaticTargetIPs(r *http.Request, updateIPv4, updateIPv6 bool) (string, string, error) { staticIPv4, staticIPv6 := "", "" var err error if updateIPv4 { if strings.TrimSpace(r.FormValue("static_ipv4")) == "" { return "", "", errors.New("Für IPv4 ist bei deaktiviertem DynDNS eine feste IPv4-Adresse erforderlich") } staticIPv4, err = normalizeStaticIP(r.FormValue("static_ipv4"), 4) if err != nil { return "", "", err } } if updateIPv6 { if strings.TrimSpace(r.FormValue("static_ipv6")) == "" { return "", "", errors.New("Für IPv6 ist bei deaktiviertem DynDNS eine feste IPv6-Adresse erforderlich") } staticIPv6, err = normalizeStaticIP(r.FormValue("static_ipv6"), 6) if err != nil { return "", "", err } } return staticIPv4, staticIPv6, nil } func targetDynDNSActive(requested, updateIPv4, updateIPv6 bool) bool { return requested && (updateIPv4 || updateIPv6) } func normalizeStaticIP(raw string, family int) (string, error) { value := strings.TrimSpace(raw) if value == "" { return "", nil } address, err := netip.ParseAddr(value) if err != nil { return "", fmt.Errorf("Bitte eine gültige IPv%d-Adresse angeben", family) } address = address.Unmap() if (family == 4 && !address.Is4()) || (family == 6 && !address.Is6()) { return "", fmt.Errorf("Bitte eine gültige IPv%d-Adresse angeben", family) } return address.String(), nil } func hasStaticIP(target model.Target) bool { return target.StaticIPv4 != "" || target.StaticIPv6 != "" } func parseTTL(raw string) (int, error) { ttl, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || !supportedTTL(ttl) { return 0, errors.New("TTL muss 60, 300, 600, 1800, 3600 oder 86400 Sekunden betragen") } return ttl, nil } func parseEditableTTL(raw string, current int) (int, error) { ttl, err := parseTTL(raw) if err == nil { return ttl, nil } preserved, parseErr := strconv.Atoi(strings.TrimSpace(raw)) if parseErr == nil && preserved == current && preserved >= 60 { return preserved, nil } return 0, err } func supportedTTL(ttl int) bool { switch ttl { case 60, 300, 600, 1800, 3600, 86400: return true default: return false } } func lines(raw string) []string { seen := make(map[string]bool) var result []string for _, line := range strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' }) { line = strings.TrimSpace(line) if line != "" && !seen[line] { seen[line] = true result = append(result, line) } } return result } func checked(r *http.Request, key string) bool { return r.FormValue(key) == "on" || r.FormValue(key) == "true" || r.FormValue(key) == "1" } func domainURL(id int64) string { return "/domains/" + strconv.FormatInt(id, 10) } func activeNotice(updateNeeded, triggered bool) string { if triggered { return " Die Aktualisierung wurde direkt gestartet." } if updateNeeded { return " Der Updater ist deaktiviert; die Synchronisierung startet nach dem Aktivieren des Dienstes." } return "" } func notice(r *http.Request) *flashMessage { return newFlash(r.URL.Query().Get("notice"), r.URL.Query().Get("notice_kind")) } func newFlash(message, kind string) *flashMessage { message = strings.TrimSpace(message) if message == "" { return nil } return &flashMessage{Message: message, Kind: normalizeFlashKind(kind)} } func normalizeFlashKind(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { case "success": return "success" case "error": return "error" default: return "info" } } func safeMessage(err error) string { if err == nil { return "" } message := strings.TrimSpace(err.Error()) if len(message) > 240 { return message[:240] } return message } func statusClass(value string) string { value = strings.ToLower(value) switch { case strings.Contains(value, "ok"), strings.Contains(value, "unverändert"), strings.Contains(value, "aktualisiert"), strings.Contains(value, "angelegt"): return "status-good" case strings.Contains(value, "error"), strings.Contains(value, "fehler"), strings.Contains(value, "partial"): return "status-bad" default: return "status-warn" } } func remoteIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return "unknown" } if address, err := netip.ParseAddr(host); err == nil { return address.String() } return "unknown" } type loginLimiter struct { mu sync.Mutex attempts map[string][]time.Time lastCleanup time.Time } const ( loginAttemptLimit = 5 loginAttemptWindow = 15 * time.Minute loginLimiterCleanupEvery = time.Minute loginLimiterMaxTrackedIPs = 4096 ) func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time), lastCleanup: time.Now()} } func (l *loginLimiter) Allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= loginLimiterCleanupEvery { l.cleanupLocked(now) } cutoff := now.Add(-loginAttemptWindow) values := l.attempts[ip] kept := values[:0] for _, value := range values { if value.After(cutoff) { kept = append(kept, value) } } if len(kept) == 0 { delete(l.attempts, ip) } else { l.attempts[ip] = kept } if len(kept) == 0 && len(l.attempts) >= loginLimiterMaxTrackedIPs { return false } return len(kept) < loginAttemptLimit } func (l *loginLimiter) Fail(ip string) { l.mu.Lock() defer l.mu.Unlock() if _, exists := l.attempts[ip]; !exists && len(l.attempts) >= loginLimiterMaxTrackedIPs { return } l.attempts[ip] = append(l.attempts[ip], time.Now()) } func (l *loginLimiter) Reset(ip string) { l.mu.Lock() defer l.mu.Unlock() delete(l.attempts, ip) } func (l *loginLimiter) cleanupLocked(now time.Time) { cutoff := now.Add(-loginAttemptWindow) for ip, values := range l.attempts { kept := values[:0] for _, value := range values { if value.After(cutoff) { kept = append(kept, value) } } if len(kept) == 0 { delete(l.attempts, ip) } else { l.attempts[ip] = kept } } l.lastCleanup = now }