Feature(Go): Mouse/keyboard input + user management with users.json (Phase 5 + 7)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user