Feature: Offline device history with delete and remote uninstall

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
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-18 17:54:30 +02:00
1 parent 1922f87dd2
commit b1ddec1b8a
12 files changed
+1212 -27

No files matched your search

+42
View File
@@ -18,6 +18,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
@@ -931,6 +932,34 @@ func main() {
// the HTTP server reads from it.
deviceHub := hub.New()
// Persistent device history: every device that has ever logged in, so the
// web UI can keep showing it after it goes offline. Same working-directory
// convention as users.json. Retention is capped by count (newest last_seen
// wins) so the file and the device_list payload stay bounded.
devicesFile := os.Getenv("YAMA_DEVICES_FILE")
if devicesFile == "" {
devicesFile = "devices.json"
rememberDefault("YAMA_DEVICES_FILE", devicesFile)
}
devicesMax := 1000
if v := os.Getenv("YAMA_DEVICES_MAX"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
devicesMax = n
} else {
log.Warn("Invalid YAMA_DEVICES_MAX=%q (want a positive integer); using %d", v, devicesMax)
}
}
deviceHistory, histErr := devicehistory.Open(devicesFile, devicesMax,
log.WithPrefix("History"))
if histErr != nil {
// Keep the store: live recording still works, and the next flush
// replaces the unreadable file. Losing old history is preferable to
// refusing to start the server.
log.Warn("Failed to load %s: %v (starting with empty history)", devicesFile, histErr)
}
deviceHub.Subscribe(deviceHistory)
log.Info("Device history: %s (max %d)", devicesFile, devicesMax)
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
@@ -1154,6 +1183,7 @@ func main() {
// Start HTTP server for web UI. Hub gives it read-only access to the
// device registry; the authenticator owns user accounts and session tokens.
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
WithDeviceHistory(deviceHistory).
WithConfig(webCfg)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
@@ -1227,5 +1257,17 @@ func main() {
for _, srv := range servers {
srv.Stop()
}
// Devices still connected at shutdown never fire OnDeviceOffline (their
// TCP handlers are stopped above), so stamp them now. Otherwise a device
// that had been online for days would show a stale "last seen" after the
// restart until it reconnects.
for _, d := range deviceHub.ListDevices() {
deviceHistory.OnDeviceOffline(d.ID)
}
// Last-chance flush: the debounce timer may hold the final offline
// timestamps from devices that dropped while we were shutting down.
if err := deviceHistory.Close(); err != nil {
log.Warn("device history flush: %v", err)
}
log.Info("Server stopped")
}
+373
View File
@@ -0,0 +1,373 @@
// 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
}
@@ -0,0 +1,263 @@
package devicehistory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func testDevice(id, name string) hub.DeviceInfo {
return hub.DeviceInfo{
ID: id,
Name: name,
Group: "office",
Version: "1.2.3",
OS: "Windows 11",
IP: "203.0.113.7",
PeerIP: "198.51.100.9",
Screen: "1:1920*1080",
Online: true,
}
}
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
h := hub.New()
h.Subscribe(s)
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
// The device is online, so it must not appear in the offline list.
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
t.Fatalf("offline while online = %d, want 0", len(got))
}
h.Unregister("dev-1")
off := s.Offline(nil)
if len(off) != 1 {
t.Fatalf("offline entries = %d, want 1", len(off))
}
d := off[0]
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
t.Fatalf("offline device = %+v", d)
}
if d.Online {
t.Fatal("offline entry marked online")
}
if d.LastSeen == 0 {
t.Fatal("last_seen not set")
}
}
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
first := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("dev-1", "old-name"), first)
// A later login refreshes display fields and last_seen but keeps first_seen.
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
reopened.mu.Lock()
rec := reopened.devices["dev-1"]
reopened.mu.Unlock()
if rec == nil {
t.Fatal("record not persisted")
}
if rec.Name != "new-name" {
t.Fatalf("name = %q, want new-name", rec.Name)
}
if rec.FirstSeen != first.Unix() {
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
}
if rec.LastSeen != first.Add(time.Hour).Unix() {
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
}
}
func TestStorePrunesOldest(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("a", "A"), base)
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
s.mu.Lock()
_, hasA := s.devices["a"]
_, hasC := s.devices["c"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || hasA || !hasC {
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
}
}
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("online-old", "Old"), base)
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
s.offlineAt("offline-new", base.Add(2*time.Minute))
// This upsert exceeds the cap; the offline record must be evicted before
// the still-connected device, even though its last_seen is newer.
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
s.mu.Lock()
_, hasOld := s.devices["online-old"]
_, hasGone := s.devices["offline-new"]
_, hasNew := s.devices["online-2"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || !hasOld || hasGone || !hasNew {
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
}
}
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("old", "Old"), base)
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
off := s.Offline(nil)
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
t.Fatalf("offline order = %+v, want new then old", off)
}
}
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path, 1000, nil)
if err == nil {
t.Fatal("Open corrupt file: want error, got nil")
}
if len(s.devices) != 0 {
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush after corrupt load: %v", err)
}
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if len(reopened.devices) != 1 {
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
}
}
func TestStoreRemovePersistsDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("gone", "Gone"), base)
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
if !s.Remove("gone") {
t.Fatal("Remove existing record = false, want true")
}
if s.Remove("gone") {
t.Fatal("Remove missing record = true, want false")
}
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
t.Fatalf("offline after remove = %+v, want only kept", got)
}
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if _, ok := reopened.devices["gone"]; ok {
t.Fatal("removed record came back after reload")
}
if _, ok := reopened.devices["kept"]; !ok {
t.Fatal("kept record missing after reload")
}
}
func TestFlushLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("leftover temp file: %s", e.Name())
}
}
}
+14
View File
@@ -165,6 +165,9 @@ type DeviceInfo struct {
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
Online bool `json:"online"`
// LastSeen is unix seconds of the last login/disconnect. Only populated
// for offline entries in device_list; online devices leave it zero.
LastSeen int64 `json:"last_seen,omitempty"`
}
// EventHandler receives notifications about device lifecycle, per-tick live
@@ -240,6 +243,17 @@ func (h *Hub) SetSender(fn SendFunc) {
h.sender = fn
}
// IsOnline reports whether id is currently registered with a live main
// connection. Callers that need to choose between an online-only command and
// an offline bookkeeping action should check this first; SendToDevice can
// still turn into ErrDeviceOffline if the device drops in between.
func (h *Hub) IsOnline(id string) bool {
h.mu.RLock()
d, ok := h.devices[id]
h.mu.RUnlock()
return ok && d.conn != nil
}
// SendToDevice forwards an already-formed command payload to the device's
// main connection. data should be the raw command bytes (the sender takes
// care of framing / compression at the protocol layer).
+1
View File
@@ -110,6 +110,7 @@ const (
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
CommandUninstall byte = 63 // TOKEN_UNINSTALL - uninstall the host agent (single byte)
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
CommandScreenKeyframeReq byte = 169 // COMMAND_SCREEN_KEYFRAME_REQ - force the client to emit an IDR immediately
+131
View File
@@ -0,0 +1,131 @@
package web
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
t.Helper()
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
auth := wsauth.New()
wh := &wsHub{
auth: auth,
devices: h,
history: hist,
log: logger.New(logger.Config{}),
clients: make(map[*wsClient]struct{}),
}
return wh, h, hist
}
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
tok, err := wh.auth.IssueToken("tester", role)
if err != nil {
panic(err)
}
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
wh.register(c)
return c, tok
}
func deleteReply(t *testing.T, c *wsClient) map[string]any {
t.Helper()
for {
select {
case m := <-c.send:
var out map[string]any
if err := json.Unmarshal(m.data, &out); err != nil {
t.Fatalf("decode reply: %v", err)
}
if out["cmd"] == "delete_device_result" {
return out
}
default:
t.Fatal("no delete_device_result reply")
return nil
}
}
}
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "removed" {
t.Fatalf("reply = %+v, want ok/removed", rep)
}
if hist.Remove("gone") {
t.Fatal("history record still present after delete")
}
}
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
wh, h, hist := deleteTestHub(t)
var sent []byte
h.SetSender(func(_ *connection.Context, data []byte) error {
sent = append([]byte(nil), data...)
return nil
})
h.Register(&hub.Device{ID: "live", Name: "Live"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "uninstall" {
t.Fatalf("reply = %+v, want ok/uninstall", rep)
}
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
}
// The history record is dropped along with the uninstall, so the device
// does not reappear as a dead offline entry after it disconnects.
if hist.Remove("live") {
t.Fatal("online device's history record survived the uninstall")
}
}
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != false {
t.Fatalf("reply = %+v, want ok:false", rep)
}
if !hist.Remove("gone") {
t.Fatal("viewer delete removed the history record")
}
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func TestListDevicesWithOffline(t *testing.T) {
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
h.Unregister("gone")
wh := &wsHub{devices: h, history: hist}
got := wh.listDevicesWithOffline()
if len(got) != 2 {
t.Fatalf("devices = %d, want 2 (1 online + 1 offline)", len(got))
}
if got[0].ID != "online" || !got[0].Online {
t.Fatalf("first = %+v, want online device", got[0])
}
if got[1].ID != "gone" || got[1].Online {
t.Fatalf("second = %+v, want offline device", got[1])
}
if got[1].LastSeen == 0 {
t.Fatal("offline device missing last_seen")
}
}
func TestListDevicesWithoutHistoryIsOnlineOnly(t *testing.T) {
h := hub.New()
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
wh := &wsHub{devices: h}
got := wh.listDevicesWithOffline()
if len(got) != 1 || got[0].ID != "online" {
t.Fatalf("devices = %+v, want just the online device", got)
}
}
+11 -1
View File
@@ -16,6 +16,7 @@ import (
"unicode"
"unicode/utf8"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -30,6 +31,7 @@ type Server struct {
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
history *devicehistory.Store // nil → no offline devices
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
loginIPLimit *wsauth.RateLimiter
@@ -86,6 +88,14 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
return &Server{port: port, log: log, hub: h, auth: auth}
}
// WithDeviceHistory attaches the persistent device-history store. When set,
// device_list also returns devices that have logged in before but are offline
// now. Passing nil keeps the online-only behaviour. Safe to call before Start.
func (s *Server) WithDeviceHistory(h *devicehistory.Store) *Server {
s.history = h
return s
}
// WithConfig applies hardening configuration. Returns the receiver for
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
@@ -107,7 +117,7 @@ func (s *Server) Start() error {
return nil
}
s.ws = newWSHub(s.auth, s.hub, s.log).
s.ws = newWSHub(s.auth, s.hub, s.history, s.log).
withOriginAllowlist(s.allowedOrigins).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -96,7 +97,7 @@ func (c *wsClient) close() {
// challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser
// overwrites its capability state on each challenge. Old frontends ignore it.
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true}`
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true}`
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
@@ -116,6 +117,7 @@ func (c *wsClient) isWatching(deviceID string) bool {
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
@@ -138,10 +140,11 @@ type wsHub struct {
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
}
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
h := &wsHub{
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
screenCloseTimers: make(map[string]*time.Timer),
+87 -2
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"errors"
"sort"
"time"
@@ -82,6 +83,8 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleTermClose(c, raw)
// Admin operations (Phase 7).
case "delete_device":
h.handleDeleteDevice(c, raw)
case "create_user":
h.handleCreateUser(c, raw)
case "delete_user":
@@ -576,14 +579,29 @@ func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "device_list") {
return
}
devices := h.devices.ListDevices()
c.queue(mustJSON(map[string]any{
"cmd": "device_list",
"ok": true,
"devices": devices,
"devices": h.listDevicesWithOffline(),
}))
}
// listDevicesWithOffline returns the online snapshot followed by historical
// devices that are not currently online. The online-ID set is derived from the
// same snapshot, so a device reconnecting between the two lookups cannot appear
// twice. With no history store wired up this is just the online list.
func (h *wsHub) listDevicesWithOffline() []hub.DeviceInfo {
devices := h.devices.ListDevices()
if h.history == nil {
return devices
}
online := make(map[string]struct{}, len(devices))
for _, d := range devices {
online[d.ID] = struct{}{}
}
return append(devices, h.history.Offline(online)...)
}
// requireAuth validates the token embedded in raw against the authenticator's
// session store (not against c.token). Tokens live independently of WS
// connections — the browser may reconnect after a visibility/network blip and
@@ -841,6 +859,73 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleDeleteDevice implements the device list's "Delete" action.
//
// The meaning depends on liveness, matching the MFC client's
// OnOnlineUninstall: an online agent is told to uninstall itself with the
// single-byte TOKEN_UNINSTALL command; an offline device has no agent to
// talk to. Either way the persisted history record is removed as well, so
// the deleted device does not linger in the offline list.
//
// Admin-only: this is destructive and irreversible.
func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
const replyCmd = "delete_device_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Invalid device id",
}))
return
}
if h.devices.IsOnline(in.ID) {
err := h.devices.SendToDevice(in.ID, []byte{protocol.CommandUninstall})
if err == nil {
// Drop the record too: after the agent uninstalls itself it will
// never log in again, so keeping it would leave a dead entry in
// the offline list. The disconnect event that follows finds no
// record and is a no-op.
if h.history != nil {
h.history.Remove(in.ID)
}
h.log.Info("device uninstall requested by %s: id=%s (record removed)", c.role, in.ID)
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall",
}))
return
}
// Lost the race with a disconnect between IsOnline and the send:
// fall through to removing the (now offline) history record.
if !errors.Is(err, hub.ErrDeviceOffline) {
h.log.Warn("device uninstall failed for %s: %v", in.ID, err)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": err.Error(),
}))
return
}
}
if h.history == nil || !h.history.Remove(in.ID) {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Device not found",
}))
return
}
h.log.Info("device record removed by %s: id=%s", c.role, in.ID)
// Other browsers (and this one) refresh their lists; the reply below
// still gives the initiator a precise result/toast.
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "removed",
}))
}
// handleDeleteUser removes an account and revokes any of its live sessions.
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {