package web import ( "encoding/json" ) // dispatch routes one inbound message to its handler. The `raw` payload is // 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. func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { switch cmd { case "get_salt": h.handleGetSalt(c, raw) case "login": h.handleLogin(c, raw) case "get_devices": h.handleGetDevices(c, raw) case "ping": // no-op heartbeat; the read itself was the keep-alive signal case "disconnect": c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`)) // Reserved for later phases. Reply with a benign failure so the UI can // surface a clear error instead of spinning indefinitely. case "connect": h.replyNotImplemented(c, "connect_result", "Screen sharing not yet implemented on Go server") case "rdp_reset": // silently ignored — UI uses this as a fire-and-forget case "mouse", "key": // silently ignored — no remote screen yet case "term_open": h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server") case "term_input", "term_resize", "term_close": // silently ignored — no terminal session // Admin operations (Phase 7). case "create_user": h.replyNotImplemented(c, "create_user_result", "User management not yet implemented") case "delete_user": h.replyNotImplemented(c, "delete_user_result", "User management not yet implemented") case "list_users": h.replyNotImplemented(c, "list_users_result", "User management not yet implemented") case "get_groups": c.queue([]byte(`{"cmd":"groups","ok":true,"groups":[]}`)) } } func (h *wsHub) replyNotImplemented(c *wsClient, replyCmd, msg string) { c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": msg, })) } // ----- handlers ------------------------------------------------------------ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) { var in struct { Username string `json:"username"` } _ = json.Unmarshal(raw, &in) salt, ok := h.auth.GetSalt(in.Username) // Do not leak which usernames exist: always return ok=true with a salt. // For unknown users hand back the empty salt (matches admin convention) // so the timing/shape of the response is uniform. if !ok { salt = "" } c.queue(mustJSON(map[string]any{ "cmd": "salt", "ok": true, "salt": salt, })) } func (h *wsHub) handleLogin(c *wsClient, raw []byte) { var in struct { Username string `json:"username"` Response string `json:"response"` Nonce string `json:"nonce"` } if err := json.Unmarshal(raw, &in); err != nil { c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"})) return } // Bind the response to the challenge we issued at connect time so that // replays from a different connection can't reuse a captured response. if in.Nonce == "" || in.Nonce != c.nonce { c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"})) return } token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce) if err != nil { // Burn the challenge on failure too — forces a new round on retry. c.nonce = "" c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"})) return } c.nonce = "" c.token = token c.role = role h.log.Info("ws login: user=%s role=%s addr=%s", in.Username, role, c.addr) c.queue(mustJSON(map[string]any{ "cmd": "login_result", "ok": true, "token": token, "role": role, })) } func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) { if !h.requireAuth(c, raw, "device_list") { return } devices := h.devices.ListDevices() c.queue(mustJSON(map[string]any{ "cmd": "device_list", "ok": true, "devices": devices, })) } // requireAuth validates the token embedded in raw against the authenticator's // session store (not against c.token). Tokens live independently of WS // connections — the browser may reconnect after a visibility/network blip and // resume with the same token, so we must not tie validity to one WS lifetime. // On the first authenticated message we cache the token/role on the wsClient // so broadcasts know to deliver to this connection. func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool { var in struct { Token string `json:"token"` } _ = json.Unmarshal(raw, &in) if in.Token == "" { c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) return false } sess, err := h.auth.ValidateToken(in.Token) if err != nil { c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) return false } if c.token == "" { c.token = in.Token c.role = sess.Role } return true } func mustJSON(v any) []byte { b, err := json.Marshal(v) if err != nil { // All callers pass simple map[string]any with primitive values; // marshal can't realistically fail. If it does, return a safe fallback. return []byte(`{"cmd":"error","msg":"internal encode error"}`) } return b }