Feature(Go): Web auth, WebSocket signaling and live device list (Phase 3)
This commit is contained in:
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -9,21 +10,28 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// Server serves the web remote desktop UI: the embedded index.html, xterm.js
|
||||
// static assets, and the PWA manifest. WebSocket signaling, device list and
|
||||
// screen streaming will be wired up in later phases.
|
||||
// static assets, the PWA manifest, and JSON APIs backed by the device hub.
|
||||
// WebSocket signaling and screen streaming will be wired up in later phases.
|
||||
type Server struct {
|
||||
port int
|
||||
log *logger.Logger
|
||||
srv *http.Server
|
||||
hub *hub.Hub
|
||||
auth *wsauth.Authenticator
|
||||
ws *wsHub
|
||||
}
|
||||
|
||||
// New creates an HTTP server bound to the given port. port=0 disables the server.
|
||||
func New(port int, log *logger.Logger) *Server {
|
||||
return &Server{port: port, log: log}
|
||||
// The hub provides read access to the online-device registry; the authenticator
|
||||
// owns user accounts and session tokens.
|
||||
func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *Server {
|
||||
return &Server{port: port, log: log, hub: h, auth: auth}
|
||||
}
|
||||
|
||||
// Start launches the server in a goroutine and returns immediately.
|
||||
@@ -34,10 +42,14 @@ func (s *Server) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.ws = newWSHub(s.auth, s.hub, s.log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/manifest.json", s.handleManifest)
|
||||
mux.HandleFunc("/api/devices", s.handleDevices)
|
||||
mux.HandleFunc("/ws", s.ws.serve)
|
||||
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm-fit.js", staticHandler(xtermFitJS, "application/javascript; charset=utf-8"))
|
||||
@@ -66,6 +78,9 @@ func (s *Server) Start() error {
|
||||
|
||||
// Stop gracefully shuts the server down.
|
||||
func (s *Server) Stop() {
|
||||
if s.ws != nil {
|
||||
s.ws.stop()
|
||||
}
|
||||
if s.srv == nil {
|
||||
return
|
||||
}
|
||||
@@ -89,6 +104,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleDevices returns a JSON snapshot of currently-online devices. Empty
|
||||
// array (not null) when no clients are connected — matches what the front-end
|
||||
// will eventually expect.
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
devices := s.hub.ListDevices()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(devices); err != nil {
|
||||
s.log.Error("encode /api/devices: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
|
||||
// Static JSON, no template needed.
|
||||
const manifestJSON = `{
|
||||
|
||||
222
server/go/web/ws.go
Normal file
222
server/go/web/ws.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// ----- WS framing knobs ---------------------------------------------------
|
||||
|
||||
const (
|
||||
wsWriteWait = 10 * time.Second // single-frame write deadline
|
||||
wsReadLimit = 1 << 20 // refuse incoming frames over 1 MB
|
||||
wsSendBuffer = 64 // outbound queue depth per client
|
||||
)
|
||||
|
||||
// upgrader allows any origin — this service is meant to be tunneled through
|
||||
// frp, so requests can legitimately arrive from arbitrary front-end hosts.
|
||||
// Adjust CheckOrigin once we have a deployment story.
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ----- per-connection client state ----------------------------------------
|
||||
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
|
||||
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
||||
nonce string // outstanding challenge — cleared after a successful login
|
||||
token string // set once authenticated
|
||||
role string // mirrors session role after login
|
||||
addr string // client address for logs
|
||||
}
|
||||
|
||||
// queue writes a payload onto the send buffer. Drops silently if the buffer
|
||||
// is full so a stuck reader can't back-pressure the broadcast path.
|
||||
func (c *wsClient) queue(payload []byte) {
|
||||
select {
|
||||
case c.send <- payload:
|
||||
case <-c.closed:
|
||||
default:
|
||||
// queue full — caller is responsible for noticing if it matters.
|
||||
}
|
||||
}
|
||||
|
||||
// close signals both loops to exit. Safe to call multiple times.
|
||||
func (c *wsClient) close() {
|
||||
c.once.Do(func() {
|
||||
close(c.closed)
|
||||
_ = c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// ----- ws hub: registry of all connected browsers -------------------------
|
||||
|
||||
type wsHub struct {
|
||||
auth *wsauth.Authenticator
|
||||
devices *hub.Hub
|
||||
log *logger.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[*wsClient]struct{}
|
||||
|
||||
unsub func()
|
||||
}
|
||||
|
||||
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
|
||||
h := &wsHub{
|
||||
auth: auth,
|
||||
devices: devices,
|
||||
log: log,
|
||||
clients: make(map[*wsClient]struct{}),
|
||||
}
|
||||
h.unsub = devices.Subscribe(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// stop unsubscribes from the device hub. Existing connections keep running
|
||||
// until they close on their own; we only block new event delivery.
|
||||
func (h *wsHub) stop() {
|
||||
if h.unsub != nil {
|
||||
h.unsub()
|
||||
h.unsub = nil
|
||||
}
|
||||
}
|
||||
|
||||
// hub.EventHandler — invoked from hub.Register / hub.Unregister.
|
||||
func (h *wsHub) OnDeviceOnline(_ hub.DeviceInfo) {
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
}
|
||||
|
||||
func (h *wsHub) OnDeviceOffline(_ string) {
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
}
|
||||
|
||||
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
||||
// rows can refresh RTT and active-window labels without re-fetching.
|
||||
func (h *wsHub) OnDeviceUpdate(id string, rtt int, activeWindow string) {
|
||||
payload := mustJSON(map[string]any{
|
||||
"cmd": "device_update",
|
||||
"id": id,
|
||||
"rtt": rtt,
|
||||
"activeWindow": activeWindow,
|
||||
})
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.token != "" {
|
||||
c.queue(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) broadcastAuthenticated(msg string) {
|
||||
payload := []byte(msg)
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
if c.token != "" {
|
||||
c.queue(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) register(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *wsHub) unregister(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
||||
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
||||
// brief network blip, reload) and must be able to resume with the same
|
||||
// cached token. The token expires on its own TTL.
|
||||
c.close()
|
||||
}
|
||||
|
||||
// ----- HTTP handler -------------------------------------------------------
|
||||
|
||||
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
h.log.Error("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(wsReadLimit)
|
||||
|
||||
nonce, err := wsauth.NewNonce()
|
||||
if err != nil {
|
||||
h.log.Error("nonce gen: %v", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
client := &wsClient{
|
||||
conn: conn,
|
||||
send: make(chan []byte, wsSendBuffer),
|
||||
closed: make(chan struct{}),
|
||||
nonce: nonce,
|
||||
addr: r.RemoteAddr,
|
||||
}
|
||||
h.register(client)
|
||||
defer h.unregister(client)
|
||||
|
||||
go h.writeLoop(client)
|
||||
|
||||
// Greet with a challenge nonce so the browser can compute the login response.
|
||||
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"}`))
|
||||
|
||||
h.readLoop(client)
|
||||
}
|
||||
|
||||
// writeLoop drains the send queue. Exits when the channel is closed or a
|
||||
// write fails. Closing the underlying connection is the read loop's job.
|
||||
func (h *wsHub) writeLoop(c *wsClient) {
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
case <-c.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop dispatches incoming messages. Exits on read error (peer closed,
|
||||
// timeout, malformed frame, etc.), which then triggers unregister cleanup.
|
||||
func (h *wsHub) readLoop(c *wsClient) {
|
||||
for {
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var env struct {
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
continue // ignore garbage frames
|
||||
}
|
||||
h.dispatch(c, env.Cmd, raw)
|
||||
}
|
||||
}
|
||||
166
server/go/web/ws_handlers.go
Normal file
166
server/go/web/ws_handlers.go
Normal file
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user