Feature(Go): Web auth, WebSocket signaling and live device list (Phase 3)

This commit is contained in:
yuanyuanxiang
2026-05-17 22:18:29 +02:00
committed by yuanyuanxiang
parent 534d3650c4
commit 4ea6ed252c
13 changed files with 1211 additions and 21 deletions

217
server/go/hub/hub.go Normal file
View File

@@ -0,0 +1,217 @@
// Package hub maintains the registry of currently online devices and acts as
// the bridge between the TCP server (which sees raw client connections) and
// the web server (which serves browser clients).
//
// The TCP side calls RegisterDevice / UnregisterDevice as clients come and go.
// The web side calls ListDevices / GetDevice / (Phase 4) SendToDevice.
// Neither side imports the other — both depend only on this package.
//
// Phase 3 scope: device list only. Frame/cursor pub-sub and SendToDevice are
// added in later phases as features need them.
package hub
import (
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
)
// Device is the internal record for one logical end-device (keyed by MasterID).
// A single device may use multiple TCP sub-connections (screen, terminal …);
// only the main login connection is stored here.
//
// PCName from LOGIN_INFOR is interpreted as "ComputerName/Group" and
// ModuleVersion as "Version-Capability"; the split halves live in separate
// fields so the front-end can render them independently.
type Device struct {
ID string // MasterID — stable identifier the client reports at login
Name string // PCName before '/' (real computer name)
Group string // PCName after '/' (group label; may be empty)
Version string // ModuleVersion before '-' (semantic version)
Capability string // ModuleVersion after '-' (capability tags; may be empty)
OS string // OS version string
CPU string // from LOGIN_INFOR reserved field 2
FilePath string // from LOGIN_INFOR reserved field 4
InstallTime string // from LOGIN_INFOR reserved field 6 (or StartTime)
Location string // client-reported geo string (reserved field 10)
PeerIP string // network-level remote address as seen by the server
PublicIP string // client-reported public IP (reserved field 11)
ConnectedAt time.Time
// Live fields refreshed on every heartbeat. Protected by hub.mu.
RTT int // network latency in ms (Heartbeat.Ping)
ActiveWindow string // foreground window title (Heartbeat.ActiveWnd, decoded)
// conn is the main connection's context. Web side will use it in Phase 4
// to push COMMAND_SCREEN_SPY and similar commands via the hub.
conn *connection.Context
}
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
// endpoint and the WS device_list message. Field names match what the
// existing browser front-end expects.
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Version string `json:"version"`
Capability string `json:"capability,omitempty"`
OS string `json:"os"`
CPU string `json:"cpu,omitempty"`
FilePath string `json:"file_path,omitempty"`
InstallTime string `json:"install_time,omitempty"`
Location string `json:"location,omitempty"`
IP string `json:"ip"` // client-reported public IP (matches C++ key)
PeerIP string `json:"peer_ip,omitempty"`
RTT int `json:"rtt"`
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
Online bool `json:"online"`
}
// EventHandler receives notifications about device lifecycle and per-tick
// live updates. Methods are invoked synchronously from Register / Unregister /
// UpdateLive — implementations must be non-blocking (typically just write to
// a channel or queue).
type EventHandler interface {
OnDeviceOnline(d DeviceInfo)
OnDeviceOffline(id string)
OnDeviceUpdate(id string, rtt int, activeWindow string)
}
// Hub is a thread-safe registry of online devices.
type Hub struct {
mu sync.RWMutex
devices map[string]*Device
subMu sync.RWMutex
subscribers []EventHandler
}
// New returns an empty Hub.
func New() *Hub {
return &Hub{devices: make(map[string]*Device)}
}
// Subscribe registers an EventHandler. The returned func removes it.
// Multiple handlers are supported; each receives every event.
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
h.subMu.Lock()
h.subscribers = append(h.subscribers, eh)
h.subMu.Unlock()
return func() {
h.subMu.Lock()
defer h.subMu.Unlock()
for i, x := range h.subscribers {
if x == eh {
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
return
}
}
}
}
func (h *Hub) snapshotSubscribers() []EventHandler {
h.subMu.RLock()
defer h.subMu.RUnlock()
out := make([]EventHandler, len(h.subscribers))
copy(out, h.subscribers)
return out
}
// Register records a device as online. Re-registering an existing ID overwrites
// the previous entry (e.g. a client reconnect with the same MasterID).
// A nil device or empty ID is silently ignored.
// Subscribers are notified after the device is added.
func (h *Hub) Register(d *Device) {
if d == nil || d.ID == "" {
return
}
h.mu.Lock()
h.devices[d.ID] = d
info := deviceToInfo(d)
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnDeviceOnline(info)
}
}
// Unregister removes a device by ID. No-op if not present.
// Subscribers are notified after the device is removed (only if it existed).
func (h *Hub) Unregister(id string) {
if id == "" {
return
}
h.mu.Lock()
_, existed := h.devices[id]
delete(h.devices, id)
h.mu.Unlock()
if !existed {
return
}
for _, s := range h.snapshotSubscribers() {
s.OnDeviceOffline(id)
}
}
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
// it shares no state with the hub.
func (h *Hub) ListDevices() []DeviceInfo {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]DeviceInfo, 0, len(h.devices))
for _, d := range h.devices {
out = append(out, deviceToInfo(d))
}
return out
}
func deviceToInfo(d *Device) DeviceInfo {
return DeviceInfo{
ID: d.ID,
Name: d.Name,
Group: d.Group,
Version: d.Version,
Capability: d.Capability,
OS: d.OS,
CPU: d.CPU,
FilePath: d.FilePath,
InstallTime: d.InstallTime,
Location: d.Location,
IP: d.PublicIP,
PeerIP: d.PeerIP,
RTT: d.RTT,
ActiveWindow: d.ActiveWindow,
ConnectedAt: d.ConnectedAt.Unix(),
Online: true, // a device that's in the map is by definition online
}
}
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
// device's live fields, then notifies subscribers. No-op if the device is
// not registered (e.g. heartbeat arriving for a connection that never sent
// TOKEN_LOGIN or has already disconnected).
func (h *Hub) UpdateLive(id string, rtt int, activeWindow string) {
if id == "" {
return
}
h.mu.Lock()
d, ok := h.devices[id]
if !ok {
h.mu.Unlock()
return
}
d.RTT = rtt
d.ActiveWindow = activeWindow
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnDeviceUpdate(id, rtt, activeWindow)
}
}
// Count returns the current number of online devices.
func (h *Hub) Count() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.devices)
}

152
server/go/hub/hub_test.go Normal file
View File

@@ -0,0 +1,152 @@
package hub
import (
"fmt"
"sync"
"testing"
"time"
)
func TestHubRegisterListUnregister(t *testing.T) {
h := New()
if got := h.Count(); got != 0 {
t.Fatalf("empty hub: want Count=0, got %d", got)
}
h.Register(&Device{ID: "a", Name: "Alice", ConnectedAt: time.Now()})
h.Register(&Device{ID: "b", Name: "Bob", ConnectedAt: time.Now()})
if got := h.Count(); got != 2 {
t.Fatalf("after 2 registers: want Count=2, got %d", got)
}
list := h.ListDevices()
if len(list) != 2 {
t.Fatalf("want 2 devices in list, got %d", len(list))
}
h.Unregister("a")
if got := h.Count(); got != 1 {
t.Fatalf("after unregister: want Count=1, got %d", got)
}
// Unregister non-existent ID is a no-op
h.Unregister("ghost")
if got := h.Count(); got != 1 {
t.Fatalf("after no-op unregister: want Count=1, got %d", got)
}
}
func TestHubNilAndEmptyIgnored(t *testing.T) {
h := New()
h.Register(nil)
h.Register(&Device{ID: ""})
h.Unregister("")
if got := h.Count(); got != 0 {
t.Fatalf("nil/empty register should be no-op, got Count=%d", got)
}
}
type captureHandler struct {
mu sync.Mutex
online []string
offline []string
updates []string // formatted "id:rtt"
}
func (c *captureHandler) OnDeviceOnline(d DeviceInfo) {
c.mu.Lock()
c.online = append(c.online, d.ID)
c.mu.Unlock()
}
func (c *captureHandler) OnDeviceOffline(id string) {
c.mu.Lock()
c.offline = append(c.offline, id)
c.mu.Unlock()
}
func (c *captureHandler) OnDeviceUpdate(id string, rtt int, _ string) {
c.mu.Lock()
c.updates = append(c.updates, fmt.Sprintf("%s:%d", id, rtt))
c.mu.Unlock()
}
func TestHubSubscribeEvents(t *testing.T) {
h := New()
c := &captureHandler{}
unsub := h.Subscribe(c)
h.Register(&Device{ID: "x", Name: "x"})
h.Register(&Device{ID: "y", Name: "y"})
h.Unregister("x")
h.Unregister("nonexistent") // no event
if len(c.online) != 2 || c.online[0] != "x" || c.online[1] != "y" {
t.Fatalf("online events: %+v", c.online)
}
if len(c.offline) != 1 || c.offline[0] != "x" {
t.Fatalf("offline events: %+v", c.offline)
}
unsub()
h.Register(&Device{ID: "z"})
if len(c.online) != 2 {
t.Fatalf("after unsubscribe should not receive events: %+v", c.online)
}
}
func TestHubUpdateLive(t *testing.T) {
h := New()
c := &captureHandler{}
h.Subscribe(c)
h.Register(&Device{ID: "x", Name: "x"})
h.UpdateLive("x", 42, "Notepad")
h.UpdateLive("ghost", 999, "should be ignored") // unknown id, no event
if len(c.updates) != 1 || c.updates[0] != "x:42" {
t.Fatalf("updates: %+v", c.updates)
}
list := h.ListDevices()
if list[0].RTT != 42 || list[0].ActiveWindow != "Notepad" {
t.Fatalf("live fields not applied: %+v", list[0])
}
}
func TestHubRegisterOverwrites(t *testing.T) {
h := New()
h.Register(&Device{ID: "x", Name: "first"})
h.Register(&Device{ID: "x", Name: "second"})
list := h.ListDevices()
if len(list) != 1 || list[0].Name != "second" {
t.Fatalf("re-register should overwrite, got %+v", list)
}
}
// Race detector should not fire under `go test -race ./hub/...`.
func TestHubConcurrent(t *testing.T) {
h := New()
const goroutines = 50
const opsPer = 100
var wg sync.WaitGroup
for g := range goroutines {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := range opsPer {
id := fmt.Sprintf("g%d-%d", g, i)
h.Register(&Device{ID: id, Name: id, ConnectedAt: time.Now()})
_ = h.ListDevices()
_ = h.Count()
h.Unregister(id)
}
}(g)
}
wg.Wait()
if got := h.Count(); got != 0 {
t.Fatalf("after all unregisters: want 0, got %d", got)
}
}