Improve Go Server to support remote desktop and command control #1
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,3 +91,4 @@ YAMA.code-workspace
|
||||
Bin/*
|
||||
nul
|
||||
server/go/web/assets/index.html
|
||||
server/go/users.json
|
||||
|
||||
@@ -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. | `<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
|
||||
# Linux/macOS
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,11 +63,49 @@ 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
|
||||
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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user