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
@@ -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