The login is a user name and a password. The old single password becomes the account named after the OS user on first boot, along with its sessions and the machine-wide look. Settings gains Users (add, remove, reset a password — your own password confirms) and Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents and the dashboard layout follow the account; the refresh rate, graph history and alerts stay the machine's. Pairing a machine takes a user on it. Log lines name the actor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
145 lines
4.2 KiB
Go
145 lines
4.2 KiB
Go
// 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 {
|
|
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
|
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
|
}
|
|
|
|
type State struct {
|
|
Terminals []Terminal `json:"terminals"`
|
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
|
Users map[string]*UserState `json:"users,omitempty"`
|
|
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they
|
|
// move to the first account. Kept so an old state file still reads.
|
|
Dashboard json.RawMessage `json:"dashboard,omitempty"`
|
|
UI json.RawMessage `json:"ui,omitempty"`
|
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
|
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)
|
|
}
|