feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+243 -41
View File
@@ -21,6 +21,7 @@ import (
"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"
@@ -36,9 +37,12 @@ type Server struct {
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 {
@@ -74,6 +78,7 @@ type viewData struct {
ErrorPiHoles []model.PiHoleIntegration
ErrorCount int
PiHoleView *piHoleView
SoftwareUpdate appupdate.State
}
type flashMessage struct {
@@ -91,12 +96,12 @@ type accessZone struct {
ManagedTargetIDs map[int64]bool
}
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, logger *slog.Logger) (*Server, error) {
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, logger: logger, templates: templates, limiter: newLoginLimiter()}, nil
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 {
@@ -144,11 +149,18 @@ func (s *Server) Handler() http.Handler {
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 /security", s.authenticated(s.securityPage))
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 /users", s.adminOnly(s.usersPage))
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))
@@ -157,6 +169,7 @@ func (s *Server) Handler() http.Handler {
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)
}
@@ -226,6 +239,10 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
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
@@ -278,10 +295,15 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
}
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")) {
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", "Passwort nicht korrekt.", s.publicCSRF(w, r), map[string]string{})
s.renderError(w, "login", "Anmeldung nicht möglich. Prüfe deine Zugangsdaten.", s.publicCSRF(w, r), map[string]string{})
return
}
s.limiter.Reset(clientIP)
@@ -294,6 +316,23 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
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)
@@ -641,7 +680,7 @@ func (s *Server) domainToken(w http.ResponseWriter, r *http.Request, session *mo
return
}
hash, err := s.store.PasswordHash(r.Context(), session.UserID)
if err != nil || !secure.VerifyPassword(hash, r.FormValue("password")) {
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
}
@@ -1240,6 +1279,126 @@ func (s *Server) runNow(w http.ResponseWriter, r *http.Request, session *model.S
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,
"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 {
@@ -1277,8 +1436,12 @@ func (s *Server) errorsClear(w http.ResponseWriter, r *http.Request, session *mo
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) 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) {
@@ -1290,26 +1453,25 @@ func (s *Server) passwordChange(w http.ResponseWriter, r *http.Request, session
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")
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, "/security", "Die neuen Passwörter stimmen nicht überein.", "error")
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, "/security", err.Error(), "error")
s.redirectNotice(w, r, "/account", err.Error(), "error")
return
}
if err := s.store.UpdatePassword(r.Context(), session.UserID, newHash); err != nil {
if err := s.store.UpdatePasswordKeepingSession(r.Context(), session.UserID, newHash, session.ID); 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.")
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) {
@@ -1338,6 +1500,7 @@ func (s *Server) schedulerAPI(w http.ResponseWriter, r *http.Request, _ *model.S
"next_run_unix": unixSeconds(s.scheduler.NextRunAt()),
"running": s.scheduler.IsRunning(),
"enabled": settings.UpdaterEnabled,
"last_ipv4": settings.LastIPv4,
})
}
@@ -1347,11 +1510,15 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request, session *mode
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()})
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: "users", CSRF: session.CSRFToken, User: userFromSession(session), IsAdmin: true, Form: map[string]string{"role": "member"}, Now: time.Now()})
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) {
@@ -1380,7 +1547,7 @@ func (s *Server) userCreate(w http.ResponseWriter, r *http.Request, session *mod
s.renderUserNewError(w, session, "Die Passwörter stimmen nicht überein.", form)
return
}
hash, err := secure.HashPassword(r.FormValue("password"))
hash, err := secure.HashProvisionedPassword(r.FormValue("password"))
if err != nil {
s.renderUserNewError(w, session, err.Error(), form)
return
@@ -1438,7 +1605,7 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request, session *mod
}
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()})
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) {
@@ -1524,24 +1691,14 @@ func (s *Server) userStateSave(w http.ResponseWriter, r *http.Request, session *
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 {
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
}
}
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.")
}
@@ -1576,7 +1733,6 @@ func (s *Server) userPasswordReset(w http.ResponseWriter, r *http.Request, sessi
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.")
}
@@ -1719,6 +1875,9 @@ func (s *Server) render(w http.ResponseWriter, name string, data viewData) {
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")
@@ -1732,7 +1891,7 @@ func (s *Server) renderError(w http.ResponseWriter, name, message, csrf string,
}
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()})
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) {
@@ -1774,7 +1933,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
}
func loadTemplates() (map[string]*template.Template, error) {
pages := []string{"setup", "login", "dashboard", "domains", "domain_new", "domain_detail", "pihole", "settings", "errors", "security", "token", "users", "user_new", "user_detail"}
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() {
@@ -2043,15 +2202,30 @@ func remoteIP(r *http.Request) string {
}
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
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 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)
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 {
@@ -2059,12 +2233,22 @@ func (l *loginLimiter) Allow(ip string) bool {
kept = append(kept, value)
}
}
l.attempts[ip] = kept
return len(kept) < 5
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) {
@@ -2072,3 +2256,21 @@ func (l *loginLimiter) Reset(ip string) {
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
}