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:
1 parent
1922f87dd2
commit
b1ddec1b8a
12 files changed
+1212
-27
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user