Persist every device that has ever logged in to the Go server in a small JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json convention: atomic temp-file write with fsync and rename, a 500 ms debounce for bursts of reconnects, first_seen preserved across logins, and pruning that evicts offline records before devices that are still connected. Devices connected at shutdown are stamped offline before the final flush so last_seen stays accurate across restarts. The server advertises an offlineDevices capability and device_list now appends historical devices that are not online, marked with online:false and last_seen. The web UI shows an Offline checkbox after Total/Showing (state kept in localStorage), renders offline cards dimmed and dashed, and always lists them in the screen-wall sidebar. Older servers without the capability keep the online-only list. Grid and List cards gain a delete button after the terminal button, admin only, behind a confirmation dialog. Deleting an offline device removes its history record; deleting an online device first sends the single-byte TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then removes the record too, so the device does not come back as a dead offline entry. The agent's own disconnect then runs the existing cleanup path: screen and terminal sub-connections are closed, the hub entry is removed, and browsers receive device_offline to release their watch sets, wall sessions, and stream references. Wall mode exposes no delete action. Tests cover history persistence/pruning/recovery, offline ordering, the device_list merge, and all three delete outcomes (offline removal, online uninstall with a single 0x3F byte, and viewer rejection). Co-Authored-By: deepseek-flash
374 lines
10 KiB
Go
374 lines
10 KiB
Go
// Package devicehistory persists the set of devices that have ever logged in
|
|
// to the Go server, so the web UI can keep listing them after they go offline.
|
|
//
|
|
// It implements hub.EventHandler and is subscribed to the hub, which means the
|
|
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
|
|
// the record (refreshing the display fields the client reports at login) and
|
|
// offline events merely refresh last_seen.
|
|
//
|
|
// Storage is a small JSON file, mirroring the wsauth users.json convention:
|
|
// atomic temp-file + fsync + rename, loaded once at startup and written back
|
|
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
|
|
package devicehistory
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
|
)
|
|
|
|
const (
|
|
// fileVersion lets a future release migrate the on-disk shape. Unknown
|
|
// versions are still parsed best-effort; only structurally invalid JSON
|
|
// makes Open fail.
|
|
fileVersion = 1
|
|
|
|
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
|
|
// a server restart makes every agent reconnect at once; without the
|
|
// debounce that would be one file write per device.
|
|
flushDelay = 500 * time.Millisecond
|
|
)
|
|
|
|
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
|
|
// offline list can be rendered with the same front-end card code; only the
|
|
// fields that are stable across sessions are kept (RTT / active window are
|
|
// live-only and would be stale noise on an offline card).
|
|
type Record struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Group string `json:"group,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Capability string `json:"capability,omitempty"`
|
|
OS string `json:"os,omitempty"`
|
|
CPU string `json:"cpu,omitempty"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
InstallTime string `json:"install_time,omitempty"`
|
|
Location string `json:"location,omitempty"`
|
|
PeerIP string `json:"peer_ip,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
Screen string `json:"screen,omitempty"`
|
|
|
|
FirstSeen int64 `json:"first_seen"`
|
|
LastSeen int64 `json:"last_seen"`
|
|
}
|
|
|
|
type fileShape struct {
|
|
Version int `json:"version"`
|
|
Devices map[string]*Record `json:"devices"`
|
|
}
|
|
|
|
// Store is the in-memory device history plus its backing file. Safe for
|
|
// concurrent use.
|
|
type Store struct {
|
|
path string
|
|
max int // 0 = unlimited
|
|
log *logger.Logger
|
|
|
|
mu sync.Mutex
|
|
devices map[string]*Record
|
|
// online is derived from hub events and used only by pruning: a device
|
|
// that is still connected must keep its record even if its last login is
|
|
// older than the 1000th offline device's. Not persisted — after a restart
|
|
// every device is offline until it reconnects.
|
|
online map[string]bool
|
|
dirty bool
|
|
timer *time.Timer
|
|
}
|
|
|
|
// Open loads the history at path. A missing file is an empty history, not an
|
|
// error. If the file exists but cannot be parsed, the returned store is still
|
|
// usable (empty) along with the error, so callers can log and continue — the
|
|
// next flush will replace the corrupt file.
|
|
func Open(path string, max int, log *logger.Logger) (*Store, error) {
|
|
s := newStore(path, max, log)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
return s, err
|
|
}
|
|
var f fileShape
|
|
if err := json.Unmarshal(data, &f); err != nil {
|
|
return s, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
for id, r := range f.Devices {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
// The map key is authoritative; tolerate records written by a
|
|
// future/older shape that omitted or disagreed with the id field.
|
|
r.ID = id
|
|
s.devices[id] = r
|
|
}
|
|
s.pruneLocked()
|
|
return s, nil
|
|
}
|
|
|
|
// New returns an empty store for path without touching disk. Useful when Open
|
|
// failed and the caller still wants live recording to work.
|
|
func New(path string, max int, log *logger.Logger) *Store {
|
|
return newStore(path, max, log)
|
|
}
|
|
|
|
func newStore(path string, max int, log *logger.Logger) *Store {
|
|
return &Store{
|
|
path: path,
|
|
max: max,
|
|
log: log,
|
|
devices: make(map[string]*Record),
|
|
online: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// OnDeviceOnline records a device that just logged in. It refreshes every
|
|
// display field because the agent reports its current name/group/version at
|
|
// each login, and preserves first_seen across reconnects.
|
|
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
|
|
s.upsertAt(d, time.Now())
|
|
}
|
|
|
|
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
|
|
if d.ID == "" {
|
|
return
|
|
}
|
|
now := at.Unix()
|
|
|
|
s.mu.Lock()
|
|
rec, ok := s.devices[d.ID]
|
|
if !ok {
|
|
rec = &Record{ID: d.ID, FirstSeen: now}
|
|
s.devices[d.ID] = rec
|
|
}
|
|
if rec.FirstSeen == 0 {
|
|
rec.FirstSeen = now
|
|
}
|
|
rec.Name = d.Name
|
|
rec.Group = d.Group
|
|
rec.Version = d.Version
|
|
rec.Capability = d.Capability
|
|
rec.OS = d.OS
|
|
rec.CPU = d.CPU
|
|
rec.FilePath = d.FilePath
|
|
rec.InstallTime = d.InstallTime
|
|
rec.Location = d.Location
|
|
rec.PeerIP = d.PeerIP
|
|
rec.IP = d.IP
|
|
rec.Screen = d.Screen
|
|
rec.LastSeen = now
|
|
s.online[d.ID] = true
|
|
|
|
s.pruneLocked()
|
|
s.markDirtyLocked()
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// OnDeviceOffline refreshes last_seen. The record was created when the device
|
|
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
|
|
// reaches the hub).
|
|
func (s *Store) OnDeviceOffline(id string) {
|
|
if id == "" {
|
|
return
|
|
}
|
|
s.offlineAt(id, time.Now())
|
|
}
|
|
|
|
func (s *Store) offlineAt(id string, at time.Time) {
|
|
s.mu.Lock()
|
|
delete(s.online, id)
|
|
if rec, ok := s.devices[id]; ok {
|
|
rec.LastSeen = at.Unix()
|
|
s.markDirtyLocked()
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// The remaining EventHandler methods are not relevant to device history.
|
|
func (s *Store) OnDeviceUpdate(string, int, string) {}
|
|
func (s *Store) OnScreenFrame(string, []byte, bool) {}
|
|
func (s *Store) OnResolutionChange(string, int, int) {}
|
|
func (s *Store) OnCursorChange(string, byte) {}
|
|
func (s *Store) OnClipboardText(string, string) {}
|
|
func (s *Store) OnTerminalReady(string, bool) {}
|
|
func (s *Store) OnTerminalData(string, []byte) {}
|
|
func (s *Store) OnTerminalClosed(string, string) {}
|
|
|
|
// Offline returns the records whose IDs are not in online, newest last_seen
|
|
// first, converted to the hub's device payload shape with Online=false.
|
|
//
|
|
// Callers pass the hub's current online snapshot; the store deliberately does
|
|
// not know which device is online, so a reconnect that re-registers the same
|
|
// ID can never produce a duplicate row.
|
|
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
|
|
s.mu.Lock()
|
|
out := make([]hub.DeviceInfo, 0, len(s.devices))
|
|
for id, rec := range s.devices {
|
|
if _, ok := online[id]; ok {
|
|
continue
|
|
}
|
|
out = append(out, hub.DeviceInfo{
|
|
ID: rec.ID,
|
|
Name: rec.Name,
|
|
Group: rec.Group,
|
|
Version: rec.Version,
|
|
Capability: rec.Capability,
|
|
OS: rec.OS,
|
|
CPU: rec.CPU,
|
|
FilePath: rec.FilePath,
|
|
InstallTime: rec.InstallTime,
|
|
Location: rec.Location,
|
|
IP: rec.IP,
|
|
PeerIP: rec.PeerIP,
|
|
Screen: rec.Screen,
|
|
LastSeen: rec.LastSeen,
|
|
Online: false,
|
|
})
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].LastSeen != out[j].LastSeen {
|
|
return out[i].LastSeen > out[j].LastSeen
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
// Remove deletes the persisted record for id and reports whether one existed.
|
|
// This is the web UI's explicit "delete" for an offline device: an offline
|
|
// record has no live agent to uninstall, so forgetting it is all "delete" can
|
|
// mean. The caller decides when removal is appropriate; the store does not
|
|
// refuse to drop a record that happens to be marked online.
|
|
func (s *Store) Remove(id string) bool {
|
|
if id == "" {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
_, existed := s.devices[id]
|
|
if existed {
|
|
delete(s.devices, id)
|
|
delete(s.online, id)
|
|
s.markDirtyLocked()
|
|
}
|
|
s.mu.Unlock()
|
|
return existed
|
|
}
|
|
|
|
// Flush writes the current state synchronously. Normally the debounced timer
|
|
// does this; Flush exists for tests and graceful shutdown.
|
|
func (s *Store) Flush() error {
|
|
s.mu.Lock()
|
|
if s.timer != nil {
|
|
s.timer.Stop()
|
|
s.timer = nil
|
|
}
|
|
if !s.dirty {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.dirty = false
|
|
data, err := s.marshalLocked()
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeAtomic(s.path, data); err != nil {
|
|
s.mu.Lock()
|
|
s.dirty = true
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close stops the debounce timer and flushes pending changes.
|
|
func (s *Store) Close() error {
|
|
return s.Flush()
|
|
}
|
|
|
|
func (s *Store) markDirtyLocked() {
|
|
s.dirty = true
|
|
if s.timer == nil {
|
|
s.timer = time.AfterFunc(flushDelay, s.flush)
|
|
}
|
|
}
|
|
|
|
func (s *Store) flush() {
|
|
if err := s.Flush(); err != nil && s.log != nil {
|
|
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
|
|
}
|
|
}
|
|
|
|
func (s *Store) marshalLocked() ([]byte, error) {
|
|
f := fileShape{Version: fileVersion, Devices: s.devices}
|
|
return json.MarshalIndent(f, "", " ")
|
|
}
|
|
|
|
// pruneLocked keeps the max most-recently-seen records. It runs after every
|
|
// online upsert, which is the only path that can grow the map; the O(n log n)
|
|
// sort only happens when the cap is actually exceeded.
|
|
func (s *Store) pruneLocked() {
|
|
if s.max <= 0 || len(s.devices) <= s.max {
|
|
return
|
|
}
|
|
ids := make([]string, 0, len(s.devices))
|
|
for id := range s.devices {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool {
|
|
oi, oj := s.online[ids[i]], s.online[ids[j]]
|
|
if oi != oj {
|
|
return !oi // evict offline records before online ones
|
|
}
|
|
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
|
|
})
|
|
for _, id := range ids[:len(ids)-s.max] {
|
|
delete(s.devices, id)
|
|
}
|
|
}
|
|
|
|
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
|
|
// fsync, then rename over the destination.
|
|
func writeAtomic(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if dir != "" {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|