2036 lines
71 KiB
Go
2036 lines
71 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/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
|
||
logger *slog.Logger
|
||
templates map[string]*template.Template
|
||
limiter *loginLimiter
|
||
}
|
||
|
||
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
|
||
Token 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
|
||
ErrorCount int
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
|
||
templates, err := loadTemplates()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, logger: logger, templates: templates, limiter: newLoginLimiter()}, 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 /settings", s.adminOnly(s.settingsPage))
|
||
mux.Handle("POST /settings", s.adminOnly(s.settingsSave))
|
||
mux.Handle("POST /run", s.adminOnly(s.runNow))
|
||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||
mux.Handle("GET /security", s.authenticated(s.securityPage))
|
||
mux.Handle("POST /security/password", s.authenticated(s.passwordChange))
|
||
mux.Handle("GET /users", s.adminOnly(s.usersPage))
|
||
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))
|
||
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 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)
|
||
if usernameErr != nil || err != nil || !user.Active || !secure.VerifyPassword(hash, r.FormValue("password")) {
|
||
s.limiter.Fail(clientIP)
|
||
_ = s.store.AddAudit(r.Context(), "auth.failed", "Fehlgeschlagene Anmeldung")
|
||
s.renderError(w, "login", "Passwort nicht korrekt.", 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) 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 || !secure.VerifyPassword(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, Zone: zone, Token: token, 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) 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
|
||
}
|
||
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, ErrorCount: len(targets) + len(zones) + len(runs), 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) securityPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||
s.render(w, "security", viewData{Title: "Sicherheit", Page: "security", CSRF: session.CSRFToken, Flash: notice(r), Mode: s.config.Mode, User: userFromSession(session), IsAdmin: session.Role == "admin", Now: time.Now()})
|
||
}
|
||
|
||
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 || !secure.VerifyPassword(hash, r.FormValue("current_password")) {
|
||
s.redirectNotice(w, r, "/security", "Aktuelles Passwort ist nicht korrekt.", "error")
|
||
return
|
||
}
|
||
if r.FormValue("new_password") != r.FormValue("confirm_password") {
|
||
s.redirectNotice(w, r, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
|
||
return
|
||
}
|
||
newHash, err := secure.HashPassword(r.FormValue("new_password"))
|
||
if err != nil {
|
||
s.redirectNotice(w, r, "/security", err.Error(), "error")
|
||
return
|
||
}
|
||
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
|
||
s.serverError(w, err)
|
||
return
|
||
}
|
||
_ = s.store.DeleteOtherSessions(r.Context(), session.UserID, session.ID)
|
||
_ = s.store.AddAudit(r.Context(), "security.password_changed", "Passwort geändert: "+session.Username)
|
||
s.redirectNotice(w, r, "/security", "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, _ *model.Session) {
|
||
settings, err := s.store.GetSettings(r.Context())
|
||
if err != nil {
|
||
s.serverError(w, err)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
|
||
"running": s.scheduler.IsRunning(),
|
||
"enabled": settings.UpdaterEnabled,
|
||
})
|
||
}
|
||
|
||
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: "Benutzer", Page: "users", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Users: users, Now: time.Now()})
|
||
}
|
||
|
||
func (s *Server) userNewPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||
s.render(w, "user_new", viewData{Title: "Benutzer anlegen", Page: "users", 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.HashPassword(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: "users", 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 managedUser.IsAdmin() && (role != "admin" || !active) {
|
||
count, countErr := s.store.CountActiveAdmins(r.Context())
|
||
if countErr != nil {
|
||
s.serverError(w, countErr)
|
||
return
|
||
}
|
||
if count <= 1 {
|
||
s.redirectNotice(w, r, "/users/"+strconv.FormatInt(userID, 10), "Der letzte aktive Admin kann nicht deaktiviert oder herabgestuft werden.", "error")
|
||
return
|
||
}
|
||
}
|
||
if err := s.store.UpdateUserState(r.Context(), userID, displayName, role, active); err != nil {
|
||
s.serverError(w, err)
|
||
return
|
||
}
|
||
if role != managedUser.Role || active != managedUser.Active {
|
||
_ = s.store.DeleteUserSessions(r.Context(), userID)
|
||
}
|
||
_ = 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.DeleteUserSessions(r.Context(), userID)
|
||
_ = 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)
|
||
if err := r.ParseForm(); 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
|
||
}
|
||
}
|
||
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: "users", 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("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=()")
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func loadTemplates() (map[string]*template.Template, error) {
|
||
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "settings", "errors", "security", "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,
|
||
"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
|
||
}
|
||
|
||
func newLoginLimiter() *loginLimiter { return &loginLimiter{attempts: make(map[string][]time.Time)} }
|
||
func (l *loginLimiter) Allow(ip string) bool {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
cutoff := time.Now().Add(-15 * time.Minute)
|
||
values := l.attempts[ip]
|
||
kept := values[:0]
|
||
for _, value := range values {
|
||
if value.After(cutoff) {
|
||
kept = append(kept, value)
|
||
}
|
||
}
|
||
l.attempts[ip] = kept
|
||
return len(kept) < 5
|
||
}
|
||
func (l *loginLimiter) Fail(ip string) {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
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)
|
||
}
|