2322 lines
81 KiB
Go
2322 lines
81 KiB
Go
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
|
||
}
|