// Package store persists small runtime state (terminal names, login sessions) // to a JSON file with atomic writes. package store import ( "encoding/json" "errors" "log" "os" "sync" "time" ) type Terminal struct { ID string `json:"id"` Name string `json:"name"` } // Session is one signed-in browser. The map key is the cookie's token; ID is what the // UI sees and revokes by, so a session list never shows a usable token. type Session struct { ID string `json:"id"` User string `json:"user"` Expires time.Time `json:"expires"` Created time.Time `json:"created,omitempty"` LastSeen time.Time `json:"last_seen,omitempty"` IP string `json:"ip,omitempty"` Agent string `json:"agent,omitempty"` } // UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no // user yet; the server gives it to the migrated first account. func (s *Session) UnmarshalJSON(b []byte) error { if len(b) > 0 && b[0] == '"' { return json.Unmarshal(b, &s.Expires) } type plain Session return json.Unmarshal(b, (*plain)(s)) } // UserState is what follows an account rather than the machine: how the console looks // to that person and how their dashboard is laid out. type UserState struct { UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server } // RememberedMount is one bind mount of a container that was running when Ham Lab last // looked. A compose stack records the path its file had *inside* whatever deployed it, so // when that container is gone the path is the only way back to the file on disk. type RememberedMount struct { Container string `json:"container"` Dest string `json:"dest"` // the path inside the container Src string `json:"src"` // the path on the machine Seen time.Time `json:"seen"` } type State struct { Terminals []Terminal `json:"terminals"` Sessions map[string]Session `json:"sessions"` // token -> session Users map[string]*UserState `json:"users,omitempty"` // UI is the pre-users machine-wide look; on first boot with users it moves to the first // account. Kept so an old state file still reads. UI json.RawMessage `json:"ui,omitempty"` Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go) // Mounts remembers where a container's bind mounts pointed, so a stack deployed by an // orchestrator that has since been removed can still be found on disk. See // internal/api/stacks.go. Mounts []RememberedMount `json:"mounts,omitempty"` SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one } // UserState returns the prefs of an account, making an empty one if needed. func (st *State) UserState(name string) *UserState { if st.Users == nil { st.Users = map[string]*UserState{} } u := st.Users[name] if u == nil { u = &UserState{} st.Users[name] = u } return u } type Store struct { mu sync.Mutex path string state State } func Open(path string) (*Store, error) { s := &Store{path: path} b, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } if err == nil { // State is conveniences (sessions, terminal names, layout). A file mangled by a power // cut must not keep the console down: set it aside and start clean. if err := json.Unmarshal(b, &s.state); err != nil { s.state = State{} _ = os.Rename(path, path+".corrupt") log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path) } } if s.state.Sessions == nil { s.state.Sessions = map[string]Session{} } return s, nil } // Update applies fn under the lock and writes the result to disk. func (s *Store) Update(fn func(*State)) error { s.mu.Lock() defer s.mu.Unlock() fn(&s.state) return s.save() } // View reads state under the lock. func (s *Store) View(fn func(*State)) { s.mu.Lock() defer s.mu.Unlock() fn(&s.state) } func (s *Store) save() error { b, err := json.MarshalIndent(&s.state, "", " ") if err != nil { return err } return WriteFileAtomic(s.path, b, 0o600) } // WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the // old version or the new one, never empty or half-written. func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { tmp := path + ".tmp" f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err } _, err = f.Write(data) if err == nil { err = f.Sync() } if cerr := f.Close(); err == nil { err = cerr } if err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) }