Feature(Go): Mouse/keyboard input + user management with users.json (Phase 5 + 7)

This commit is contained in:
yuanyuanxiang
2026-05-18 13:53:44 +02:00
committed by yuanyuanxiang
parent fba4143dd1
commit 6485e800d6
7 changed files with 764 additions and 21 deletions

1
.gitignore vendored
View File

@@ -91,3 +91,4 @@ YAMA.code-workspace
Bin/* Bin/*
nul nul
server/go/web/assets/index.html server/go/web/assets/index.html
server/go/users.json

View File

@@ -120,6 +120,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` | | `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` | | `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
| `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` | | `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` |
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
```bash ```bash
# Linux/macOS # Linux/macOS

View File

@@ -286,6 +286,13 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
if len(reserved) > protocol.ResFieldClientLoc { if len(reserved) > protocol.ResFieldClientLoc {
location = info.GetReservedField(protocol.ResFieldClientLoc) location = info.GetReservedField(protocol.ResFieldClientLoc)
} }
// RES_RESOLUTION is already formatted by the client as "N:W*H"
// (see client/LoginServer.cpp:414). Pass through unchanged so the web
// UI's device card renders it next to the version label.
resolution := ""
if len(reserved) > protocol.ResFieldResolution {
resolution = info.GetReservedField(protocol.ResFieldResolution)
}
// Register with hub so the web side can list this device. Sub-connections // Register with hub so the web side can list this device. Sub-connections
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry // (screen / terminal etc.) reuse the MasterID and will overwrite this entry
@@ -301,6 +308,7 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
FilePath: clientInfo.FilePath, FilePath: clientInfo.FilePath,
InstallTime: info.StartTime, InstallTime: info.StartTime,
Location: location, Location: location,
Resolution: resolution,
PeerIP: ctx.GetPeerIP(), PeerIP: ctx.GetPeerIP(),
PublicIP: clientInfo.IP, PublicIP: clientInfo.IP,
ConnectedAt: time.Now(), ConnectedAt: time.Now(),
@@ -399,9 +407,18 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
if info := ctx.GetInfo(); info.ClientID != "" { if info := ctx.GetInfo(); info.ClientID != "" {
var rtt int32 var rtt int32
var activeWindow string var activeWindow string
// ActiveWnd at data[9..521] is a 512-byte GBK-encoded string. // ActiveWnd at data[9..521] is a 512-byte NUL-padded string. Encoding
// depends on the client: new clients advertise CLIENT_CAP_UTF8 (bit
// 0x0002 in the moduleVersion hex tail) and ship UTF-8 directly;
// legacy Windows clients still use CP936 (GBK). Decoding UTF-8 bytes
// as GBK turns Chinese characters into mojibake — see the matching
// C++ guard at server/2015Remote/WebService.cpp:1530.
if len(data) >= 9+512 { if len(data) >= 9+512 {
activeWindow = protocol.GbkToUTF8(data[9 : 9+512]) activeWindow = protocol.DecodeClientString(
data[9:9+512],
h.hub.Capability(info.ClientID),
info.ClientType,
)
} }
// Ping at data[521..525] is a little-endian int32. // Ping at data[521..525] is a little-endian int32.
if len(data) >= 525 { if len(data) >= 525 {
@@ -551,6 +568,17 @@ func main() {
log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable") log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable")
} }
// Persistent users live in users.json next to the binary's working dir
// — same default the C++ WebService uses (m_ConfigDir + "users.json").
// Loading is best-effort: a missing file means "no extra users yet".
usersFile := os.Getenv("YAMA_USERS_FILE")
if usersFile == "" {
usersFile = "users.json"
}
if err := webAuth.SetUsersFile(usersFile); err != nil {
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
}
// Create servers for each port // Create servers for each port
var servers []*server.Server var servers []*server.Server
for _, port := range ports { for _, port := range ports {

View File

@@ -48,6 +48,7 @@ type Device struct {
Location string // client-reported geo string (reserved field 10) Location string // client-reported geo string (reserved field 10)
PeerIP string // network-level remote address as seen by the server PeerIP string // network-level remote address as seen by the server
PublicIP string // client-reported public IP (reserved field 11) PublicIP string // client-reported public IP (reserved field 11)
Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15)
ConnectedAt time.Time ConnectedAt time.Time
// Live fields refreshed on every heartbeat. Protected by hub.mu. // Live fields refreshed on every heartbeat. Protected by hub.mu.
@@ -115,6 +116,18 @@ func (h *Hub) MainConn(id string) *connection.Context {
return nil return nil
} }
// Capability returns the device's reported capability hex string
// (LOGIN_INFOR.moduleVersion tail). Empty for unknown devices — callers
// should treat that as "no caps" (legacy Windows GBK default).
func (h *Hub) Capability(id string) string {
h.mu.RLock()
defer h.mu.RUnlock()
if d, ok := h.devices[id]; ok {
return d.Capability
}
return ""
}
// DeviceInfo is the JSON-safe projection of Device for the /api/devices // DeviceInfo is the JSON-safe projection of Device for the /api/devices
// endpoint and the WS device_list message. Field names match what the // endpoint and the WS device_list message. Field names match what the
// existing browser front-end expects. // existing browser front-end expects.
@@ -131,6 +144,7 @@ type DeviceInfo struct {
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
IP string `json:"ip"` // client-reported public IP (matches C++ key) IP string `json:"ip"` // client-reported public IP (matches C++ key)
PeerIP string `json:"peer_ip,omitempty"` PeerIP string `json:"peer_ip,omitempty"`
Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key
RTT int `json:"rtt"` RTT int `json:"rtt"`
ActiveWindow string `json:"activeWindow,omitempty"` ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"` ConnectedAt int64 `json:"connected_at"`
@@ -205,6 +219,30 @@ func (h *Hub) SendToDevice(id string, data []byte) error {
return h.sender(d.conn, data) return h.sender(d.conn, data)
} }
// SendToScreen routes a payload to the device's currently-bound screen
// sub-connection. Input events (COMMAND_SCREEN_CONTROL) MUST go through the
// screen sub-conn rather than the main conn — the C++ client only dispatches
// these commands from CScreenManager::OnReceive, which reads exclusively from
// the sub-conn (see client/ScreenManager.cpp:1065). Returns ErrDeviceOffline
// when the device is unknown OR has no active screen session, so callers can
// quietly drop input from browsers that haven't called connect yet.
func (h *Hub) SendToScreen(id string, data []byte) error {
h.mu.RLock()
d, ok := h.devices[id]
var sc *connection.Context
if ok {
sc = d.screenConn
}
h.mu.RUnlock()
if !ok || sc == nil {
return ErrDeviceOffline
}
if h.sender == nil {
return ErrNoSender
}
return h.sender(sc, data)
}
// BindScreenConn associates a freshly-arrived sub-connection (the one that // BindScreenConn associates a freshly-arrived sub-connection (the one that
// just sent TOKEN_BITMAPINFO) with the device identified by clientID. // just sent TOKEN_BITMAPINFO) with the device identified by clientID.
// Returns false if the device is not registered — callers should drop the // Returns false if the device is not registered — callers should drop the
@@ -350,6 +388,7 @@ func deviceToInfo(d *Device) DeviceInfo {
Location: d.Location, Location: d.Location,
IP: d.PublicIP, IP: d.PublicIP,
PeerIP: d.PeerIP, PeerIP: d.PeerIP,
Screen: d.Resolution,
RTT: d.RTT, RTT: d.RTT,
ActiveWindow: d.ActiveWindow, ActiveWindow: d.ActiveWindow,
ConnectedAt: d.ConnectedAt.Unix(), ConnectedAt: d.ConnectedAt.Unix(),

View File

@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"strconv"
"strings" "strings"
"golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/encoding/simplifiedchinese"
@@ -36,6 +37,21 @@ func GbkToUTF8(data []byte) string {
return cleanString(buf.String()) return cleanString(buf.String())
} }
// Utf8CleanString trims at the first NUL and strips non-printables — the
// UTF-8 counterpart of GbkToUTF8 for clients that have the CLIENT_CAP_UTF8
// capability bit. Decoding as GBK in that case would mangle multi-byte
// sequences (the C++ comment at WebService.cpp:1530 calls out this exact
// "double-encoding" footgun).
func Utf8CleanString(data []byte) string {
if idx := bytes.IndexByte(data, 0); idx >= 0 {
data = data[:idx]
}
if len(data) == 0 {
return ""
}
return cleanString(string(data))
}
// cleanString removes non-printable characters except common whitespace // cleanString removes non-printable characters except common whitespace
func cleanString(s string) string { func cleanString(s string) string {
var result strings.Builder var result strings.Builder
@@ -47,14 +63,52 @@ func cleanString(s string) string {
return strings.TrimSpace(result.String()) return strings.TrimSpace(result.String())
} }
// Client capability bitmask values, matching common/commands.h CLIENT_CAP_*.
// Reported in the hex tail of LOGIN_INFOR.moduleVersion (after the '-').
const (
ClientCapV2 uint32 = 0x0001 // CLIENT_CAP_V2 — V2 file transfer
ClientCapUTF8 uint32 = 0x0002 // CLIENT_CAP_UTF8 — UTF-8 protocol strings (activeWindow, key-log titles, ...)
ClientCapScreenPreview uint32 = 0x0004 // CLIENT_CAP_SCREEN_PREVIEW
)
// SupportsCap returns true when the client's reported capability hex string
// has the given bit set. An empty / unparseable string means "no caps" and
// matches the legacy GBK-Windows convention.
func SupportsCap(capability string, bit uint32) bool {
if capability == "" {
return false
}
caps, err := strconv.ParseUint(strings.TrimSpace(capability), 16, 32)
if err != nil {
return false
}
return uint32(caps)&bit != 0
}
// DecodeClientString decodes a fixed-length, NUL-padded buffer the client
// sent as part of a binary protocol field (typically ActiveWnd). If the
// client signals UTF-8 capability or is known to ship UTF-8 by default
// (Linux / macOS), the bytes are treated as UTF-8; otherwise they're
// decoded from GBK (CP936 — the legacy Windows default).
//
// clientType comes from LOGIN_INFOR reserved field 0 (RES_CLIENT_TYPE) and
// capability from the hex tail of moduleVersion. Both can be empty.
func DecodeClientString(data []byte, capability, clientType string) string {
if SupportsCap(capability, ClientCapUTF8) || clientType == "LNX" || clientType == "MAC" {
return Utf8CleanString(data)
}
return GbkToUTF8(data)
}
// Command tokens - matching the C++ definitions (common/commands.h). // Command tokens - matching the C++ definitions (common/commands.h).
const ( const (
// Server -> Client commands // Server -> Client commands
CommandActived byte = 0 // COMMAND_ACTIVED CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream" CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
CommandBye byte = 204 // COMMAND_BYE - disconnect CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
// Client -> Server tokens // Client -> Server tokens
TokenAuth byte = 100 // TOKEN_AUTH - authorization required TokenAuth byte = 100 // TOKEN_AUTH - authorization required
@@ -87,11 +141,11 @@ const (
// aborts the process. Struct layout matches MasterSettings in // aborts the process. Struct layout matches MasterSettings in
// common/commands.h (pragma pack 4, total 1000 bytes). // common/commands.h (pragma pack 4, total 1000 bytes).
const ( const (
CmdMasterSetting byte = 215 CmdMasterSetting byte = 215
MasterSettingsSize = 1000 MasterSettingsSize = 1000
MasterSettingsOffReportInterval = 0 // int32, seconds MasterSettingsOffReportInterval = 0 // int32, seconds
MasterSettingsOffSignature = 508 // Signature[64] MasterSettingsOffSignature = 508 // Signature[64]
MasterSettingsSignatureLen = 64 MasterSettingsSignatureLen = 64
// DefaultReportIntervalSec matches the C++ default. Sending 0 makes the // DefaultReportIntervalSec matches the C++ default. Sending 0 makes the
// client disable its active-window heartbeat field, breaking RTT / // client disable its active-window heartbeat field, breaking RTT /
// ActiveWindow live updates on the web UI. // ActiveWindow live updates on the web UI.
@@ -116,6 +170,101 @@ const (
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses) AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
) )
// Windows message constants used inside MSG64.message. The client dispatches
// on these values verbatim (CScreenManager::ProcessCommand at
// client/ScreenManager.cpp:1617), so these MUST stay bit-identical to the
// WinUser.h definitions even though this Go server is cross-platform.
const (
WMKeyDown uint64 = 0x0100
WMKeyUp uint64 = 0x0101
WMSysKeyDown uint64 = 0x0104
WMSysKeyUp uint64 = 0x0105
WMMouseMove uint64 = 0x0200
WMLButtonDown uint64 = 0x0201
WMLButtonUp uint64 = 0x0202
WMLButtonDblClk uint64 = 0x0203
WMRButtonDown uint64 = 0x0204
WMRButtonUp uint64 = 0x0205
WMRButtonDblClk uint64 = 0x0206
WMMButtonDown uint64 = 0x0207
WMMButtonUp uint64 = 0x0208
WMMouseWheel uint64 = 0x020A
)
// Virtual-key codes referenced from the input mapping. Same numeric values
// as the Win32 VK_* constants.
const (
VKLWin = 0x5B // VK_LWIN — filtered: never forwarded
VKRWin = 0x5C // VK_RWIN — filtered: never forwarded
VKPrior = 0x21 // VK_PRIOR (Page Up) — extended-key range start
VKDown = 0x28 // VK_DOWN — extended-key range end
VKInsert = 0x2D
VKDelete = 0x2E
VKNumLock = 0x90
VKRControl = 0xA3
VKRMenu = 0xA5
VKApps = 0x5D
)
// MK_* wParam bitflags for mouse-button messages.
const (
MKLButton uint64 = 0x0001
MKRButton uint64 = 0x0002
MKMButton uint64 = 0x0010
)
// MSG64 is the 48-byte fixed layout the client expects inside a
// COMMAND_SCREEN_CONTROL packet (common/commands.h class MSG64).
//
// [hwnd:8][message:8][wParam:8][lParam:8][time:8][pt.x:4][pt.y:4]
//
// All uint64 fields are little-endian; pt is two int32 LE. The client's
// ProcessCommand validates `ulLength % 48 == 0` and treats each 48-byte
// block as one MSG64.
const Msg64Size = 48
// BuildScreenControlPacket encodes one COMMAND_SCREEN_CONTROL packet
// carrying a single MSG64 record. The cmd byte is prepended.
//
// Wire layout:
//
// [CMD:1][hwnd:8 LE][message:8 LE][wParam:8 LE][lParam:8 LE][time:8 LE][pt.x:4 LE][pt.y:4 LE]
//
// time is filled with a monotonic-ish ms value (ms since Unix epoch trimmed
// to 32 bits) so the client's GetTickCount() comparisons stay reasonable.
func BuildScreenControlPacket(message, wParam, lParam uint64, ptX, ptY int32, timeMs uint32) []byte {
buf := make([]byte, 1+Msg64Size)
buf[0] = CommandScreenControl
// hwnd left zero — the client recomputes hWnd via WindowFromPoint.
binary.LittleEndian.PutUint64(buf[1+8:1+16], message)
binary.LittleEndian.PutUint64(buf[1+16:1+24], wParam)
binary.LittleEndian.PutUint64(buf[1+24:1+32], lParam)
binary.LittleEndian.PutUint64(buf[1+32:1+40], uint64(timeMs))
binary.LittleEndian.PutUint32(buf[1+40:1+44], uint32(ptX))
binary.LittleEndian.PutUint32(buf[1+44:1+48], uint32(ptY))
return buf
}
// MakeLParam packs x into the low word and y into the high word — the
// Windows MAKELPARAM macro the client expects in mouse-message lParams.
func MakeLParam(x, y int32) uint64 {
return uint64(uint32(x)&0xFFFF) | (uint64(uint32(y)&0xFFFF) << 16)
}
// IsExtendedKey returns true when the given Win32 VK code should set the
// extended-key bit (bit 24) in a keyboard lParam. Matches the C++
// HandleKey logic (server/2015Remote/WebService.cpp:944).
func IsExtendedKey(vk int) bool {
if vk >= VKPrior && vk <= VKDown {
return true
}
switch vk {
case VKInsert, VKDelete, VKNumLock, VKRControl, VKRMenu, VKApps:
return true
}
return false
}
// Reserved-field indices we care about (see common/commands.h RES_* enum). // Reserved-field indices we care about (see common/commands.h RES_* enum).
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots // LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
// even when leaving others blank ("?"). // even when leaving others blank ("?").
@@ -125,6 +274,7 @@ const (
ResFieldInstallTime = 6 // RES_INSTALL_TIME ResFieldInstallTime = 6 // RES_INSTALL_TIME
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
ResFieldResolution = 15 // RES_RESOLUTION — client-formatted screen geometry: "N:W*H"
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
) )
@@ -173,8 +323,8 @@ const (
LoginInfoSize = 980 // Total size of LOGIN_INFOR struct (with alignment padding) LoginInfoSize = 980 // Total size of LOGIN_INFOR struct (with alignment padding)
// Field offsets (with alignment padding) // Field offsets (with alignment padding)
OffsetToken = 0 // 1 byte (unsigned char) OffsetToken = 0 // 1 byte (unsigned char)
OffsetOsVerInfoEx = 1 // 156 bytes (char[156]) OffsetOsVerInfoEx = 1 // 156 bytes (char[156])
// 3 bytes padding here to align dwCPUMHz to 4-byte boundary // 3 bytes padding here to align dwCPUMHz to 4-byte boundary
OffsetCPUMHz = 160 // 4 bytes (unsigned int) - aligned to 4 OffsetCPUMHz = 160 // 4 bytes (unsigned int) - aligned to 4
OffsetModuleVersion = 164 // 24 bytes (char[24]) OffsetModuleVersion = 164 // 24 bytes (char[24])

View File

@@ -2,6 +2,8 @@ package web
import ( import (
"encoding/json" "encoding/json"
"sort"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol" "github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
@@ -11,8 +13,10 @@ import (
// passed through so handlers can re-parse to their own shape. // passed through so handlers can re-parse to their own shape.
// //
// Phase 3 implements: get_salt, login, get_devices, ping, disconnect. // Phase 3 implements: get_salt, login, get_devices, ping, disconnect.
// Phase 4/5/6 commands (connect, mouse, key, term_*, etc.) get a friendly // Phase 4 adds: connect, screen frame relay.
// "not yet implemented" reply so the browser UI doesn't hang silently. // Phase 5 adds: mouse, key (input forwarding to the device screen sub-conn).
// Phase 6/7 commands (term_*, user mgmt) get a friendly "not yet implemented"
// reply so the browser UI doesn't hang silently.
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
switch cmd { switch cmd {
case "get_salt": case "get_salt":
@@ -30,8 +34,10 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleConnect(c, raw) h.handleConnect(c, raw)
case "rdp_reset": case "rdp_reset":
// silently ignored — UI uses this as a fire-and-forget // silently ignored — UI uses this as a fire-and-forget
case "mouse", "key": case "mouse":
// silently ignored — no remote screen yet h.handleMouse(c, raw)
case "key":
h.handleKey(c, raw)
case "term_open": case "term_open":
h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server") h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server")
case "term_input", "term_resize", "term_close": case "term_input", "term_resize", "term_close":
@@ -39,13 +45,13 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
// Admin operations (Phase 7). // Admin operations (Phase 7).
case "create_user": case "create_user":
h.replyNotImplemented(c, "create_user_result", "User management not yet implemented") h.handleCreateUser(c, raw)
case "delete_user": case "delete_user":
h.replyNotImplemented(c, "delete_user_result", "User management not yet implemented") h.handleDeleteUser(c, raw)
case "list_users": case "list_users":
h.replyNotImplemented(c, "list_users_result", "User management not yet implemented") h.handleListUsers(c, raw)
case "get_groups": case "get_groups":
c.queue([]byte(`{"cmd":"groups","ok":true,"groups":[]}`)) h.handleGetGroups(c, raw)
} }
} }
@@ -57,6 +63,23 @@ func (h *wsHub) replyNotImplemented(c *wsClient, replyCmd, msg string) {
})) }))
} }
// requireAdmin combines token validation with a role=="admin" check. The
// reply on failure has the standard `{cmd, ok:false, msg}` shape so the
// front-end's generic toast handler can surface the reason.
func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool) {
if !h.requireAuth(c, raw, replyCmd) {
return false
}
// c.role is cached on first successful requireAuth; safe to read here.
if c.role != "admin" {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
}))
return false
}
return true
}
// ----- handlers ------------------------------------------------------------ // ----- handlers ------------------------------------------------------------
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) { func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
@@ -275,6 +298,271 @@ func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
return true return true
} }
// handleMouse forwards one mouse event from the browser to the device's
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
// (server/2015Remote/WebService.cpp:773) so the client's
// CScreenManager::ProcessCommand sees identical bytes regardless of which
// server is serving the device. Unauthenticated and unknown event types
// drop silently — input is high-frequency, error replies would just spam
// the WS.
func (h *wsHub) handleMouse(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "mouse_result") {
return
}
var in struct {
Type string `json:"type"`
X int32 `json:"x"`
Y int32 `json:"y"`
Button int `json:"button"`
Delta int `json:"delta"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
deviceID := c.watching
if deviceID == "" {
return // browser hasn't picked a device yet
}
var message, wParam uint64
switch in.Type {
case "down":
switch in.Button {
case 0:
message, wParam = protocol.WMLButtonDown, protocol.MKLButton
case 1:
message, wParam = protocol.WMMButtonDown, protocol.MKMButton
case 2:
message, wParam = protocol.WMRButtonDown, protocol.MKRButton
default:
return
}
case "up":
switch in.Button {
case 0:
message = protocol.WMLButtonUp
case 1:
message = protocol.WMMButtonUp
case 2:
message = protocol.WMRButtonUp
default:
return
}
case "move":
message = protocol.WMMouseMove
case "wheel":
message = protocol.WMMouseWheel
// Windows expects ±120 per notch in HIWORD(wParam). Browsers report
// `deltaY` in pixels with sign flipped relative to Win32 conventions
// (positive deltaY = scroll down). Normalize to one notch per call,
// signed so up scrolls "up" on the remote.
var wheel int16
switch {
case in.Delta > 0:
wheel = -120
case in.Delta < 0:
wheel = 120
}
wParam = uint64(uint16(wheel)) << 16
case "dblclick":
// Windows synthesizes double-click from rapid down/up sequences, so
// the C++ server only forwards this for macOS clients. We don't
// currently track client OS on the Go side; drop the event — the
// down/up pair the browser already sent is sufficient on Windows.
return
default:
return
}
lParam := protocol.MakeLParam(in.X, in.Y)
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, in.X, in.Y, tickMillis())
_ = h.devices.SendToScreen(deviceID, pkt)
}
// handleKey forwards one keyboard event to the device. lParam is built to
// match the Windows convention so applications relying on TranslateMessage
// (e.g. text input fields) behave correctly. Mirrors
// CWebService::HandleKey at server/2015Remote/WebService.cpp:878.
//
// Scan code: the C++ server resolves the VK -> scan code via the local
// MapVirtualKey. Go is cross-platform so we leave the scan-code bits zero;
// the client side ultimately delivers events via SendInput/keybd_event
// which accept VK-only input, and the extended-key bit (bit 24) is what
// actually matters for distinguishing numpad/arrow keys.
func (h *wsHub) handleKey(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "key_result") {
return
}
var in struct {
KeyCode int `json:"keyCode"`
Down bool `json:"down"`
Alt bool `json:"alt"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.KeyCode == protocol.VKLWin || in.KeyCode == protocol.VKRWin {
return // Windows keys stay local; matches both C++ server and client
}
deviceID := c.watching
if deviceID == "" {
return
}
var message uint64
switch {
case in.Alt && in.Down:
message = protocol.WMSysKeyDown
case in.Alt && !in.Down:
message = protocol.WMSysKeyUp
case in.Down:
message = protocol.WMKeyDown
default:
message = protocol.WMKeyUp
}
// lParam layout (Win32 keyboard message):
// bits 0..15: repeat count (= 1)
// bits 16..23: scan code (we leave zero — see handleKey doc)
// bit 24: extended-key flag (arrows, numpad, RCtrl, RAlt, ...)
// bit 29: context code (Alt held)
// bits 30..31: previous-key/transition flags (both set on key-up)
lParam := uint64(1)
if protocol.IsExtendedKey(in.KeyCode) {
lParam |= 1 << 24
}
if in.Alt {
lParam |= 1 << 29
}
if !in.Down {
lParam |= 3 << 30
}
wParam := uint64(uint32(in.KeyCode))
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, 0, 0, tickMillis())
_ = h.devices.SendToScreen(deviceID, pkt)
}
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
// password is hashed with a fresh salt, the user table is persisted, and
// any duplicate username is rejected.
func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
const replyCmd = "create_user_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
AllowedGroups []string `json:"allowed_groups"`
}
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return
}
if in.Role == "" {
in.Role = "viewer"
}
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.log.Info("user created by %s: name=%s role=%s groups=%d",
c.role, in.Username, in.Role, len(in.AllowedGroups))
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// 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) {
const replyCmd = "delete_user_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
Username string `json:"username"`
}
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return
}
if err := h.auth.DeleteUser(in.Username); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.log.Info("user deleted by %s: name=%s", c.role, in.Username)
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleListUsers returns the account table to admin callers. Field shape
// matches the C++ list_users_result the browser already parses: an array
// of {username, role, allowed_groups}.
func (h *wsHub) handleListUsers(c *wsClient, raw []byte) {
const replyCmd = "list_users_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
users := h.auth.ListUsers()
out := make([]map[string]any, 0, len(users))
for _, u := range users {
groups := u.AllowedGroups
if groups == nil {
groups = []string{}
}
out = append(out, map[string]any{
"username": u.Username,
"role": u.Role,
"allowed_groups": groups,
})
}
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": true,
"users": out,
}))
}
// handleGetGroups returns the deduplicated set of group labels currently
// observed on online devices, plus a synthetic "default" entry that the
// admin UI uses for ungrouped devices. Admin-only — non-admins don't get
// to enumerate the device fleet's groups. Matches the contract of
// CWebService::HandleGetGroups (WebService.cpp:1130).
func (h *wsHub) handleGetGroups(c *wsClient, raw []byte) {
const replyCmd = "groups"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
seen := map[string]struct{}{"default": {}}
for _, d := range h.devices.ListDevices() {
g := d.Group
if g == "" {
g = "default"
}
seen[g] = struct{}{}
}
groups := make([]string, 0, len(seen))
for g := range seen {
groups = append(groups, g)
}
sort.Strings(groups)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": true,
"groups": groups,
}))
}
// tickMillis returns a 32-bit-truncated ms timestamp suitable for the
// MSG64.time field. The client compares these with GetTickCount(), which
// is also a 32-bit ms counter — exact origin doesn't matter, only that
// successive events have non-decreasing values within the wrap window.
func tickMillis() uint32 {
return uint32(time.Now().UnixMilli() & 0xFFFFFFFF)
}
func mustJSON(v any) []byte { func mustJSON(v any) []byte {
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {

View File

@@ -10,7 +10,11 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"os"
"path/filepath"
"sort"
"sync" "sync"
"time" "time"
) )
@@ -32,6 +36,12 @@ type User struct {
PasswordHash string // SHA256(password+salt) in lowercase hex PasswordHash string // SHA256(password+salt) in lowercase hex
Salt string // empty for admin (matches C++ convention) Salt string // empty for admin (matches C++ convention)
Role string // "admin" or "viewer" Role string // "admin" or "viewer"
// AllowedGroups restricts which device groups this user can view. Empty
// slice = no device access (admin role is treated as "all" elsewhere
// without consulting this list). Matches the C++ WebUser.allowed_groups
// field one-for-one so users.json is interchangeable between the two
// servers.
AllowedGroups []string
} }
// Session is the authenticated state attached to a valid token. // Session is the authenticated state attached to a valid token.
@@ -48,6 +58,10 @@ type Authenticator struct {
users map[string]*User // username -> user users map[string]*User // username -> user
tokens map[string]*Session // token -> session tokens map[string]*Session // token -> session
tokenExpire time.Duration tokenExpire time.Duration
// usersFile is the persistence target for non-admin accounts (admin
// lives in env/master-password only and is never written out, matching
// the C++ SaveUsers behavior). Empty disables persistence.
usersFile string
} }
// New returns an empty Authenticator. Call AddUser to populate. // New returns an empty Authenticator. Call AddUser to populate.
@@ -92,6 +106,100 @@ func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string
}) })
} }
// CreateUser registers a new account, persists the user table, and returns
// nil on success. Mirrors the C++ CWebService::CreateUser semantics:
// - role must be "admin" or "viewer"
// - "admin" is reserved for the bootstrap account and cannot be created
// via this API
// - duplicate usernames are rejected
//
// Password is hashed with a fresh per-user salt before being stored.
func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error {
if username == "" || plainPassword == "" {
return errors.New("username and password required")
}
if username == "admin" {
return errors.New("'admin' is reserved")
}
if role != "admin" && role != "viewer" {
return errors.New("role must be 'admin' or 'viewer'")
}
salt, err := NewSalt()
if err != nil {
return err
}
u := User{
Username: username,
PasswordHash: HashPassword(plainPassword, salt),
Salt: salt,
Role: role,
AllowedGroups: append([]string(nil), allowedGroups...),
}
a.mu.Lock()
if _, exists := a.users[username]; exists {
a.mu.Unlock()
return errors.New("user already exists")
}
a.users[username] = &u
path := a.usersFile
snapshot := a.snapshotPersistableLocked()
a.mu.Unlock()
if path != "" {
return writeUsersFile(path, snapshot)
}
return nil
}
// DeleteUser removes a user account and persists the change. The bootstrap
// admin (always Role=="admin" with empty Salt) is protected: deleting it
// would lock everyone out of the UI with no way back in.
func (a *Authenticator) DeleteUser(username string) error {
if username == "" || username == "admin" {
return errors.New("cannot delete admin")
}
a.mu.Lock()
if _, ok := a.users[username]; !ok {
a.mu.Unlock()
return errors.New("user not found")
}
delete(a.users, username)
// Also drop any live sessions belonging to that user so they don't
// outlive their account.
for tok, s := range a.tokens {
if s.Username == username {
delete(a.tokens, tok)
}
}
path := a.usersFile
snapshot := a.snapshotPersistableLocked()
a.mu.Unlock()
if path != "" {
return writeUsersFile(path, snapshot)
}
return nil
}
// ListUsers returns a stable, copied snapshot of all users in name order.
// PasswordHash and Salt are zeroed in the returned records so callers can
// JSON-marshal the result without leaking credentials.
func (a *Authenticator) ListUsers() []User {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]User, 0, len(a.users))
for _, u := range a.users {
c := *u
c.PasswordHash = ""
c.Salt = ""
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false). // GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
// Note: the C++ admin uses an empty salt — that is still considered "found" // Note: the C++ admin uses an empty salt — that is still considered "found"
// and the empty string is returned with ok=true. // and the empty string is returned with ok=true.
@@ -135,6 +243,134 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
return token, u.Role, nil return token, u.Role, nil
} }
// usersFileEntry is the on-disk shape of one record in users.json. Field
// names match the C++ WebService SaveUsers output exactly so the two
// servers can share the file.
type usersFileEntry struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Salt string `json:"salt"`
Role string `json:"role"`
AllowedGroups []string `json:"allowed_groups"`
}
type usersFile struct {
Users []usersFileEntry `json:"users"`
}
// SetUsersFile points the authenticator at a JSON file used to persist
// non-admin accounts and loads any existing entries. Calling it again with
// a different path is allowed but the previous file is not re-read on a
// nil-arg call — initialize once at startup. Missing files are not an
// error; they're treated as an empty user table.
func (a *Authenticator) SetUsersFile(path string) error {
a.mu.Lock()
a.usersFile = path
a.mu.Unlock()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(data) == 0 {
return nil
}
var f usersFile
if err := json.Unmarshal(data, &f); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
for _, e := range f.Users {
// Skip admin: it's always sourced from env/master password and
// already injected via AddAdminFromPlainPassword.
if e.Username == "" || e.Username == "admin" {
continue
}
if e.PasswordHash == "" {
continue
}
role := e.Role
if role == "" {
role = "viewer"
}
a.users[e.Username] = &User{
Username: e.Username,
PasswordHash: e.PasswordHash,
Salt: e.Salt,
Role: role,
AllowedGroups: append([]string(nil), e.AllowedGroups...),
}
}
return nil
}
// snapshotPersistableLocked builds the on-disk slice while a.mu is held by
// the caller. Admin records are excluded — they live in env, not in the file.
func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry {
out := make([]usersFileEntry, 0, len(a.users))
for _, u := range a.users {
if u.Username == "admin" {
continue
}
groups := append([]string{}, u.AllowedGroups...)
out = append(out, usersFileEntry{
Username: u.Username,
PasswordHash: u.PasswordHash,
Salt: u.Salt,
Role: u.Role,
AllowedGroups: groups,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// writeUsersFile writes the user list atomically: encode to a temp file in
// the same directory, fsync, rename — so a crash mid-write can't leave the
// service starting up with a half-written users.json next time.
func writeUsersFile(path string, entries []usersFileEntry) error {
data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "users-*.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
}
// ValidateToken returns the session for a token or ErrInvalidToken. Expired // ValidateToken returns the session for a token or ErrInvalidToken. Expired
// tokens are removed lazily as they are looked up. // tokens are removed lazily as they are looked up.
func (a *Authenticator) ValidateToken(token string) (*Session, error) { func (a *Authenticator) ValidateToken(token string) (*Session, error) {