v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s

R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112
family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its
family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers.
R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 09:36:34 +02:00
parent 7d41c2973c
commit 94f44931dd
28 changed files with 945 additions and 190 deletions
+18 -8
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -218,20 +219,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
// check that none of them has counted yet. A window that has passed starts clean.
// check that none of them has counted yet. A window that has passed starts clean. The escrow wizard's re-auth uses it
// too (the same counter: a wrong password is a wrong password, wherever it was typed).
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
s.loginAttemptMu.Lock()
defer s.loginAttemptMu.Unlock()
if s.loginAttempts == nil {
s.loginAttempts = map[string]*loginAttempt{}
return reserveAttempt(&s.loginAttemptMu, &s.loginAttempts, ip)
}
// reserveAttempt is the one body of the 5-per-minute password limiters: check and count under mu, in one step. Each
// limiter keeps its OWN map (the share password's is not the dashboard's, so a guest and the owner never share a
// counter); the map is allocated lazily, so struct-literal test servers that skip NewServer still work.
func reserveAttempt(mu *sync.Mutex, attempts *map[string]*loginAttempt, key string) (int, bool) {
mu.Lock()
defer mu.Unlock()
if *attempts == nil {
*attempts = map[string]*loginAttempt{}
}
a := s.loginAttempts[ip]
a := (*attempts)[key]
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
a = nil
}
if a == nil {
a = &loginAttempt{}
s.loginAttempts[ip] = a
(*attempts)[key] = a
}
if a.count >= loginMaxAttempts {
return a.count, false
@@ -241,7 +250,8 @@ func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
return a.count, true
}
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
// comparePassword is bcrypt's compare for the dashboard sign-in, the escrow re-auth and the share password;
// passwordCompare replaces it in tests (a slow compare makes a race measurable).
func (s *Server) comparePassword(hash, pw []byte) error {
if s.passwordCompare != nil {
return s.passwordCompare(hash, pw)