package web import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sort" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" ) // R-35 (D4, operator ruling 2026-10-08, `09` §3 decision 188): the dashboard's sign-ins survive the controller's own // restarts — a settings push, an update, a crash. Before this, s.sessions lived only in memory and every restart // signed the household out mid-flow. // // The disk holds a FINGERPRINT, never the cookie: the map is keyed by sha256(cookie), and only that key, the expiry // and the CSRF token are written. The data dir rides in the whole-guest archive (plaintext by design, `07` §5), so a // copy of the archive must not hold anything a browser could present. sha256 of 32 random bytes cannot be turned back // into the cookie, and presenting the fingerprint itself is hashed again and misses (TestR35_FileHoldsNoToken). // // Expiry is unchanged (sessionMaxAge from creation; an expired row is dropped at load and at every save). Logout and // invalidateAllSessions (password change, claim reset) write the file at once, so an ended session stays ended // across a restart (TestR35_LogoutEndsSessionAcrossRestart, TestR35_InvalidateAllEndsSessionAcrossRestart). // // A missing file is normal; an unreadable or corrupt one is logged and the server starts with no sessions — never // fatal, and the failure direction is "sign in again", never "signed in" (TestR35_CorruptFileStartsEmpty). const sessionsFileName = "dashboard-sessions.json" type sessionsFile struct { Version int `json:"version"` Sessions []sessionDisk `json:"sessions"` } type sessionDisk struct { Fingerprint string `json:"fingerprint"` ExpiresAt time.Time `json:"expires_at"` CSRFToken string `json:"csrf_token"` } // sessionFingerprint is the map key and the on-disk id of a session: hex(sha256(cookie value)). func sessionFingerprint(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (s *Server) sessionsPath() string { if s.cfg == nil || s.cfg.Paths.DataDir == "" { return "" } return filepath.Join(s.cfg.Paths.DataDir, sessionsFileName) } // loadSessions reads the persisted sessions into s.sessions. Called once from NewServer. func (s *Server) loadSessions() { path := s.sessionsPath() if path == "" { return } b, err := os.ReadFile(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { logx.Warnf(s.logger, "[web] sessions: cannot read %s, starting with none: %v", path, err) } else { logx.Debugf(s.logger, "[web] sessions: no %s yet, starting with none", sessionsFileName) } return } var f sessionsFile if err := json.Unmarshal(b, &f); err != nil { logx.Warnf(s.logger, "[web] sessions: %s is not valid JSON, starting with none: %v", path, err) return } now := time.Now() loaded, expired, bad := 0, 0, 0 s.sessionsMu.Lock() for _, d := range f.Sessions { if len(d.Fingerprint) != sha256.Size*2 || d.CSRFToken == "" { bad++ continue } if !now.Before(d.ExpiresAt) { expired++ continue } s.sessions[d.Fingerprint] = &session{expiresAt: d.ExpiresAt, csrfToken: d.CSRFToken} loaded++ } s.sessionsMu.Unlock() logx.Infof(s.logger, "[web] sessions: restored %d dashboard session(s) from disk (dropped %d expired, %d malformed)", loaded, expired, bad) } // saveSessionsLocked writes the live sessions (expired ones dropped) atomically, 0600, fsynced. The caller holds // sessionsMu for writing. A failure is logged and returned; the in-memory state stays authoritative for this process. func (s *Server) saveSessionsLocked() error { path := s.sessionsPath() if path == "" { return nil } now := time.Now() f := sessionsFile{Version: 1, Sessions: []sessionDisk{}} for fp, sess := range s.sessions { if !now.Before(sess.expiresAt) { continue } f.Sessions = append(f.Sessions, sessionDisk{Fingerprint: fp, ExpiresAt: sess.expiresAt, CSRFToken: sess.csrfToken}) } sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].Fingerprint < f.Sessions[j].Fingerprint }) b, err := json.MarshalIndent(f, "", " ") if err != nil { return err } if err := writeSessionsAtomic(path, b); err != nil { logx.Warnf(s.logger, "[web] sessions: cannot save %s (sessions stay valid until this process ends): %v", path, err) return err } logx.Debugf(s.logger, "[web] sessions: saved %d session(s) to %s", len(f.Sessions), sessionsFileName) return nil } // writeSessionsAtomic is tmp + fsync + rename at 0600 — the family.json shape (internal/family saveLocked). func writeSessionsAtomic(path string, b []byte) error { tmp := path + ".tmp" fh, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } if _, err := fh.Write(b); err != nil { fh.Close() os.Remove(tmp) return err } if err := fh.Sync(); err != nil { fh.Close() os.Remove(tmp) return err } if err := fh.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }