diff --git a/.gitignore b/.gitignore index 7ee8739..0cd6d8c 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,4 @@ YAMA.code-workspace Bin/* nul server/go/web/assets/index.html +server/go/users.json diff --git a/server/go/README.md b/server/go/README.md index 96cdde5..27c6be1 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -120,6 +120,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 | `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_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. | `` | +| `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 # Linux/macOS diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 364f9f3..cbda53d 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -286,6 +286,13 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) { if len(reserved) > 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 // (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, InstallTime: info.StartTime, Location: location, + Resolution: resolution, PeerIP: ctx.GetPeerIP(), PublicIP: clientInfo.IP, ConnectedAt: time.Now(), @@ -399,9 +407,18 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) { if info := ctx.GetInfo(); info.ClientID != "" { var rtt int32 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 { - 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. 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") } + // 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 var servers []*server.Server for _, port := range ports { diff --git a/server/go/hub/hub.go b/server/go/hub/hub.go index 51c6670..f600fd6 100644 --- a/server/go/hub/hub.go +++ b/server/go/hub/hub.go @@ -48,6 +48,7 @@ type Device struct { 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) + Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15) ConnectedAt time.Time // Live fields refreshed on every heartbeat. Protected by hub.mu. @@ -115,6 +116,18 @@ func (h *Hub) MainConn(id string) *connection.Context { 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 // endpoint and the WS device_list message. Field names match what the // existing browser front-end expects. @@ -131,6 +144,7 @@ type DeviceInfo struct { Location string `json:"location,omitempty"` IP string `json:"ip"` // client-reported public IP (matches C++ key) PeerIP string `json:"peer_ip,omitempty"` + Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key RTT int `json:"rtt"` ActiveWindow string `json:"activeWindow,omitempty"` ConnectedAt int64 `json:"connected_at"` @@ -205,6 +219,30 @@ func (h *Hub) SendToDevice(id string, data []byte) error { 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 // just sent TOKEN_BITMAPINFO) with the device identified by clientID. // Returns false if the device is not registered — callers should drop the @@ -350,6 +388,7 @@ func deviceToInfo(d *Device) DeviceInfo { Location: d.Location, IP: d.PublicIP, PeerIP: d.PeerIP, + Screen: d.Resolution, RTT: d.RTT, ActiveWindow: d.ActiveWindow, ConnectedAt: d.ConnectedAt.Unix(), diff --git a/server/go/protocol/commands.go b/server/go/protocol/commands.go index ca3f46a..09fe249 100644 --- a/server/go/protocol/commands.go +++ b/server/go/protocol/commands.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "strconv" "strings" "golang.org/x/text/encoding/simplifiedchinese" @@ -36,6 +37,21 @@ func GbkToUTF8(data []byte) 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 func cleanString(s string) string { var result strings.Builder @@ -47,14 +63,52 @@ func cleanString(s string) 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). const ( // Server -> Client commands - CommandActived byte = 0 // COMMAND_ACTIVED - CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture - CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream" - CommandBye byte = 204 // COMMAND_BYE - disconnect - CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK + CommandActived byte = 0 // COMMAND_ACTIVED + CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture + CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches) + CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream" + CommandBye byte = 204 // COMMAND_BYE - disconnect + CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK // Client -> Server tokens TokenAuth byte = 100 // TOKEN_AUTH - authorization required @@ -87,11 +141,11 @@ const ( // aborts the process. Struct layout matches MasterSettings in // common/commands.h (pragma pack 4, total 1000 bytes). const ( - CmdMasterSetting byte = 215 - MasterSettingsSize = 1000 + CmdMasterSetting byte = 215 + MasterSettingsSize = 1000 MasterSettingsOffReportInterval = 0 // int32, seconds - MasterSettingsOffSignature = 508 // Signature[64] - MasterSettingsSignatureLen = 64 + MasterSettingsOffSignature = 508 // Signature[64] + MasterSettingsSignatureLen = 64 // DefaultReportIntervalSec matches the C++ default. Sending 0 makes the // client disable its active-window heartbeat field, breaking RTT / // ActiveWindow live updates on the web UI. @@ -116,6 +170,101 @@ const ( 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). // LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots // even when leaving others blank ("?"). @@ -125,6 +274,7 @@ const ( ResFieldInstallTime = 6 // RES_INSTALL_TIME ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string 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 ) @@ -173,8 +323,8 @@ const ( LoginInfoSize = 980 // Total size of LOGIN_INFOR struct (with alignment padding) // Field offsets (with alignment padding) - OffsetToken = 0 // 1 byte (unsigned char) - OffsetOsVerInfoEx = 1 // 156 bytes (char[156]) + OffsetToken = 0 // 1 byte (unsigned char) + OffsetOsVerInfoEx = 1 // 156 bytes (char[156]) // 3 bytes padding here to align dwCPUMHz to 4-byte boundary OffsetCPUMHz = 160 // 4 bytes (unsigned int) - aligned to 4 OffsetModuleVersion = 164 // 24 bytes (char[24]) diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index 45e0cdf..cb5906f 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -2,6 +2,8 @@ package web import ( "encoding/json" + "sort" + "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol" @@ -11,8 +13,10 @@ import ( // passed through so handlers can re-parse to their own shape. // // Phase 3 implements: get_salt, login, get_devices, ping, disconnect. -// Phase 4/5/6 commands (connect, mouse, key, term_*, etc.) get a friendly -// "not yet implemented" reply so the browser UI doesn't hang silently. +// Phase 4 adds: connect, screen frame relay. +// 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) { switch cmd { case "get_salt": @@ -30,8 +34,10 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { h.handleConnect(c, raw) case "rdp_reset": // silently ignored — UI uses this as a fire-and-forget - case "mouse", "key": - // silently ignored — no remote screen yet + case "mouse": + h.handleMouse(c, raw) + case "key": + h.handleKey(c, raw) case "term_open": h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server") 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). case "create_user": - h.replyNotImplemented(c, "create_user_result", "User management not yet implemented") + h.handleCreateUser(c, raw) case "delete_user": - h.replyNotImplemented(c, "delete_user_result", "User management not yet implemented") + h.handleDeleteUser(c, raw) case "list_users": - h.replyNotImplemented(c, "list_users_result", "User management not yet implemented") + h.handleListUsers(c, raw) 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 ------------------------------------------------------------ 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 } +// 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 { b, err := json.Marshal(v) if err != nil { diff --git a/server/go/wsauth/wsauth.go b/server/go/wsauth/wsauth.go index 74beb8f..15b6b39 100644 --- a/server/go/wsauth/wsauth.go +++ b/server/go/wsauth/wsauth.go @@ -10,7 +10,11 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" + "os" + "path/filepath" + "sort" "sync" "time" ) @@ -32,6 +36,12 @@ type User struct { PasswordHash string // SHA256(password+salt) in lowercase hex Salt string // empty for admin (matches C++ convention) 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. @@ -48,6 +58,10 @@ type Authenticator struct { users map[string]*User // username -> user tokens map[string]*Session // token -> session 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. @@ -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). // Note: the C++ admin uses an empty salt — that is still considered "found" // 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 } +// 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 // tokens are removed lazily as they are looked up. func (a *Authenticator) ValidateToken(token string) (*Session, error) {