Files

2421 lines
85 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
"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{}
instanceID string
restartContainer func()
restartMu sync.Mutex
restartPending bool
}
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
InstanceID string
}
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, restartContainer func()) (*Server, error) {
templates, err := loadTemplates()
if err != nil {
return nil, err
}
instanceID, err := secure.RandomToken(18)
if err != nil {
return nil, fmt.Errorf("create process instance ID: %w", 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), instanceID: instanceID, restartContainer: restartContainer}, 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.dnsChange(s.domainCreate)))
mux.Handle("GET /domains/{id}", s.authenticated(s.domainDetail))
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))
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.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))
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.Header().Set("Cache-Control", "no-store")
if s.instanceID != "" {
w.Header().Set("X-Domain-Control-Instance", s.instanceID)
}
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.RecentSignificantRuns(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), updated)
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), updated)
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 !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 && !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
}
}
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(), InstanceID: s.instanceID})
}
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", appupdate.ErrCredentialMissing.Error(), "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) containerRestart(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
}
if s.restartContainer == nil {
http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable)
return
}
if s.updates != nil {
statusCtx, cancel := context.WithTimeout(r.Context(), time.Second)
updateStatus, statusErr := s.updates.UpdateStatus(statusCtx)
cancel()
if statusErr == nil && updateStatus.Busy() {
http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict)
return
}
}
s.restartMu.Lock()
if s.restartPending {
s.restartMu.Unlock()
s.writeRestartAccepted(w, r, true)
return
}
if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil {
s.restartMu.Unlock()
s.serverError(w, err)
return
}
s.restartPending = true
restart := s.restartContainer
s.restartMu.Unlock()
s.writeRestartAccepted(w, r, false)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
restart()
}
func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) {
w.Header().Set("Cache-Control", "no-store")
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, "already_pending": alreadyPending, "instance_id": s.instanceID})
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n"))
}
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.RecentSignificantRuns(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.")
}
// 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)
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
}