Files
SimpleRemoter/server/go/web/ws.go
T
yuanyuanxianganddeepseek-flash a522424491 Feature: Security audit logging with admin web query page
Add a dedicated security audit trail to the Go server. Login attempts,
authorization denials, WebSocket lifecycle, remote screen and terminal
sessions, clipboard operations, device authentication and admin mutations
now emit stable JSON-line events (audit=true, event=<name>) to a separate
logs/audit.log, mirrored to server.log. The path is configurable through
YAMA_AUDIT_LOG and the separate file can be switched off. Records never
contain passcodes, password hashes, tokens or clipboard contents, and
untrusted string fields are stripped of control characters and truncated
so they cannot forge log lines.

Expose the trail to administrators through a query_audit WebSocket command
and an Audit Log page in the web UI. The page filters by time range, event
(exact or prefix), actor, IP, device id, keyword and warning level, pages
newest-first, and stays hidden unless the server advertises the auditLog
capability, so older C++ servers degrade cleanly. Each query scans at most
the newest 32 MB of the current file to bound latency, records itself as
admin.audit.query, and tolerates torn or non-audit lines.

Add unit tests for event emission and redaction, query filtering, paging,
malformed lines, missing or disabled audit files, and handler
authorization.

Co-Authored-By: deepseek-flash
2026-10-18 18:44:17 +02:00

711 lines
22 KiB
Go

package web
import (
"encoding/json"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"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
)
// baseUpgrader carries the buffer-size config shared by all WS upgrades.
// CheckOrigin is set per-hub in wsHub.upgradeWS so the allowlist is
// closed-over per instance instead of being a global mutable.
var baseUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}
// ----- per-connection client state ----------------------------------------
// wsMsg is one queued WebSocket frame. binary toggles between
// websocket.TextMessage (JSON signaling) and websocket.BinaryMessage
// (screen frames).
type wsMsg struct {
binary bool
data []byte
}
type wsClient struct {
conn *websocket.Conn
send chan wsMsg
closed chan struct{}
once sync.Once
// Immutable connection metadata (set once in serve).
id string // short random id for audit correlation
addr string // resolved client IP for logs / rate limits
peerAddr string // actual TCP peer (differs from addr behind a proxy)
origin string
userAgent string
connectedAt time.Time
// 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
user string // mirrors session username after login/resume
termWatching string // device ID for an open web terminal session, "" otherwise
// watchIDs is the set of devices this browser is streaming (the screen
// wall watches several at once). Empty while on the device list.
// Guarded by wsHub.mu.
watchIDs map[string]struct{}
// watchStartedAt records when each watch began so stop events can carry a
// duration. Guarded by wsHub.mu.
watchStartedAt map[string]time.Time
// keyframeAt throttles keyframe requests to one per device per second.
// Unix seconds, guarded by wsHub.mu.
keyframeAt map[string]int64
// Terminal session bookkeeping. Guarded by wsHub.mu.
termStartedAt time.Time
termCloseReason string
// Audit counters, owned by the read-loop goroutine and read in unregister
// after that goroutine returns.
statCommands int
statInvalidToken int
statLoginFailures int
statAdminDenied int
statUnknownSalt int
statUnknownCmd int
auditInvalidTokenLogged bool
auditUnknownSaltLogged bool
auditUnknownCmdLogged bool
}
// queue writes a JSON text frame 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) {
c.enqueue(wsMsg{binary: false, data: payload})
}
// queueBinary writes a binary WS frame. Used for screen-stream packets.
func (c *wsClient) queueBinary(payload []byte) {
c.enqueue(wsMsg{binary: true, data: payload})
}
func (c *wsClient) enqueue(m wsMsg) {
select {
case c.send <- m:
case <-c.closed:
default:
// queue full — drop (acceptable for video; signaling clients are
// typically not behind enough for the small text buffer to fill).
}
}
// 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()
})
}
// challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser
// overwrites its capability state on each challenge. Old frontends ignore it.
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true,"auditLog":true}`
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
// a teardown. This keeps the per-frame broadcast loop cheap for a misbehaving
// or buggy client.
const maxWatchPerClient = 32
// isWatching reports whether this connection subscribed to deviceID.
// Callers must hold wsHub.mu (read or write).
func (c *wsClient) isWatching(deviceID string) bool {
_, ok := c.watchIDs[deviceID]
return ok
}
// ----- ws hub: registry of all connected browsers -------------------------
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
clients map[*wsClient]struct{}
// screenCloseTimers delays CloseScreen by screenCloseGrace after the last
// viewer disconnects. A reconnect within that window cancels the timer and
// reuses the live screen sub-conn (hot path), avoiding a full cold-start.
// Guarded by mu.
screenCloseTimers map[string]*time.Timer
unsub func()
// Hardening knobs wired from server.Config. Nil/empty values mean
// "no extra restriction" — useful for local dev where the hub is
// exercised without server.Server wiring up the env-driven defaults.
allowedOrigins []string // empty → only same-origin upgrades accepted
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
}
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
h := &wsHub{
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
screenCloseTimers: make(map[string]*time.Timer),
}
h.unsub = devices.Subscribe(h)
return h
}
// withOriginAllowlist returns h after installing the explicit Origin
// allowlist. Chainable. Pass empty/nil to keep "same-origin only".
func (h *wsHub) withOriginAllowlist(origins []string) *wsHub {
h.allowedOrigins = origins
return h
}
// withLoginRateLimiters wires per-IP and per-username throttles into
// the login flow. Either may be nil to disable that dimension.
func (h *wsHub) withLoginRateLimiters(byIP, byUser *wsauth.RateLimiter) *wsHub {
h.loginIPLimit = byIP
h.loginUserLimit = byUser
return h
}
// withTrustForwardedFor opts in to using the last entry of the
// X-Forwarded-For header as the client IP. Safe only when the server is
// behind a reverse proxy that you control.
func (h *wsHub) withTrustForwardedFor(trust bool) *wsHub {
h.trustForwardedFor = trust
return h
}
// checkOrigin decides whether to accept a WebSocket upgrade based on
// the request's Origin header. Same-origin (Origin host == Host) is
// always accepted; explicit allowlist entries cover the
// PWA-from-different-domain or local-dev cases.
//
// An empty Origin header is rejected: a legitimate browser always sends
// it on cross-origin requests, and same-origin requests have it too in
// modern Chrome/Safari/Firefox. Non-browser clients (curl, scripts) that
// omit Origin shouldn't be talking to the WS endpoint anyway.
func (h *wsHub) checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
// Same-origin (Origin host matches the Host the request came in on).
// Strip any port mismatch: if the server is behind a proxy, Host may
// not include a port while Origin does (or vice versa), so compare
// the hostname components.
originHost := u.Hostname()
reqHost := stripPort(r.Host)
if originHost == reqHost && originHost != "" {
return true
}
// Explicit allowlist entries — match Origin in full (scheme + host
// + port) so a customer can pin exactly one trusted PWA origin.
for _, allowed := range h.allowedOrigins {
if allowed == "" {
continue
}
if strings.EqualFold(origin, strings.TrimSpace(allowed)) {
return true
}
}
return false
}
func stripPort(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return h
}
return hostport
}
// clientIP returns the source IP of an HTTP request. By default uses
// r.RemoteAddr (the actual TCP peer); this is the only safe choice when
// the server is directly exposed to the internet, because a malicious
// client can put anything in X-Forwarded-For and would otherwise rotate
// it to evade per-IP rate limits.
//
// When `trustForwardedFor` is true the LAST entry of X-Forwarded-For is
// returned instead — appropriate only when running behind a reverse
// proxy that you control and that overwrites/appends the header (caddy,
// nginx with proper config, etc). Toggled via the YAMA_WEB_TRUST_PROXY
// env var at startup.
func clientIP(r *http.Request, trustForwardedFor bool) string {
if trustForwardedFor {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the LAST entry — the closest hop, i.e. our own
// trusted proxy's view of the peer. Trusting the first
// entry would let a malicious client at the head of the
// chain set an arbitrary value.
parts := strings.Split(xff, ",")
ip := strings.TrimSpace(parts[len(parts)-1])
if ip != "" {
return ip
}
}
}
return stripPort(r.RemoteAddr)
}
// 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(id string) {
// Tell everyone authenticated to refresh their device list (covers
// users sitting on the devices-page).
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
// Also tell any browser actively viewing this device's screen — the
// devices_changed handler only refreshes the list page; viewers on the
// screen page would otherwise see a frozen frame with "Connected" status
// indefinitely. The browser handles this by showing "Device offline" and
// bouncing back to the device list after 2 s.
if id == "" {
return
}
msg := mustJSON(map[string]any{
"cmd": "device_offline",
"id": id,
})
// Drop the id from every watch set as well: the device is gone, so this
// connection no longer holds a subscription for it. The browser does the
// same with its local refcount. Without this, a stale entry would keep
// countWatchers non-zero and later block the screen teardown.
type viewer struct {
c *wsClient
start time.Time
}
var stopped []viewer
h.mu.Lock()
for c := range h.clients {
if !c.isWatching(id) {
continue
}
delete(c.watchIDs, id)
delete(c.keyframeAt, id)
if c.token != "" {
c.queue(msg)
}
if !c.watchStartedAt[id].IsZero() {
stopped = append(stopped, viewer{c, c.watchStartedAt[id]})
}
delete(c.watchStartedAt, id)
}
h.mu.Unlock()
// Audit outside h.mu: h.audit takes h.mu.RLock.
for _, v := range stopped {
h.audit(v.c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(v.start),
"reason": "device_offline",
})
}
}
// OnCursorChange relays the remote cursor index to every viewer of this
// device. The browser maps the index to a CSS cursor (desktop) or overlay
// SVG variant (touch). Hub already de-duplicates so we always have a real
// transition here.
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
msg := mustJSON(map[string]any{
"cmd": "cursor",
"id": deviceID,
"index": index,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
}
// OnResolutionChange notifies viewers so the browser-side WebCodecs decoder
// can be (re)initialized with the right frame size. Without this, incoming
// binary frames after connect_result are decoded by an uninitialized
// VideoDecoder and the page stays on "Waiting for video...".
func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
msg := mustJSON(map[string]any{
"cmd": "resolution_changed",
"id": deviceID,
"width": width,
"height": height,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
}
// OnClipboardText relays a device's clipboard text back to every browser
// watching it (in response to a "clipboard_get" request).
func (h *wsHub) OnClipboardText(deviceID string, text string) {
msg := mustJSON(map[string]any{
"cmd": "clipboard_text",
"id": deviceID,
"text": text,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
}
// OnScreenFrame ships a screen packet to every browser currently watching
// this device. We hold the read lock for the whole iteration, but each
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
// cannot stall the fast ones.
func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalReady notifies the requesting browser that its term_open
// handshake completed. mode is "pty" or "legacy" — xterm.js disables the
// resize callback in legacy mode (no PTY behind the cmd pipe).
func (h *wsHub) OnTerminalReady(deviceID string, isPTY bool) {
mode := "legacy"
if isPTY {
mode = "pty"
}
msg := mustJSON(map[string]any{
"cmd": "term_ready",
"id": deviceID,
"mode": mode,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnTerminalData ships one chunk of raw shell output (already wrapped in
// the "TRM1" magic header) over the binary WS frame. Single-viewer is
// enforced upstream so at most one client matches per device.
func (h *wsHub) OnTerminalData(deviceID string, packet []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalClosed fires when the device's shell exits or the sub-conn
// drops. The browser closes its xterm panel. We also clear termWatching
// so a subsequent term_open from the same browser isn't rejected as
// "already open" by stale state.
func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
msg := mustJSON(map[string]any{
"cmd": "term_closed",
"ok": true,
"reason": reason,
})
type closed struct {
c *wsClient
start time.Time
why string
}
var done []closed
h.mu.Lock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
why := c.termCloseReason
if why == "" {
why = reason
}
done = append(done, closed{c, c.termStartedAt, why})
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
c.queue(msg)
}
}
h.mu.Unlock()
// Audit outside h.mu: h.audit takes h.mu.RLock.
for _, d := range done {
h.audit(d.c, false, auditTermStop, map[string]any{
"device_id": deviceID,
"duration_ms": durationMs(d.start),
"reason": d.why,
})
}
}
// 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)
// Snapshot the watch set and clear it under the same lock. The teardown
// below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
// deferredCloseScreen takes h.mu.Lock.
watched := make([]string, 0, len(c.watchIDs))
started := make(map[string]time.Time, len(c.watchIDs))
for id := range c.watchIDs {
watched = append(watched, id)
started[id] = c.watchStartedAt[id]
}
c.watchIDs = nil
c.watchStartedAt = nil
c.keyframeAt = nil
termDevice := c.termWatching
termStart := c.termStartedAt
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
h.mu.Unlock()
// If this client was the last viewer of a device, tear down the screen
// session so the device stops encoding.
for _, id := range watched {
if !started[id].IsZero() {
h.audit(c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(started[id]),
"reason": "ws_close",
})
}
if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id)
}
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session. The client is already
// out of h.clients, so OnTerminalClosed won't reach it — audit here.
if termDevice != "" {
h.devices.CloseTerminalSession(termDevice)
h.audit(c, false, auditTermStop, map[string]any{
"device_id": termDevice,
"duration_ms": durationMs(termStart),
"reason": "ws_close",
})
}
h.auditClose(c, len(watched))
// 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()
}
// screenCloseGrace is how long we keep a device's screen sub-conn alive after
// the last browser viewer disconnects. A reconnect within this window (e.g.
// the user briefly switched to a TOTP app on mobile) cancels the timer and
// resumes on the hot path — no cold-start, no waiting for a new IDR.
const screenCloseGrace = 30 * time.Second
// deferredCloseScreen schedules CloseScreen after screenCloseGrace unless a
// viewer reconnects first. Must be called without h.mu held.
func (h *wsHub) deferredCloseScreen(deviceID string) {
h.mu.Lock()
if t, ok := h.screenCloseTimers[deviceID]; ok {
t.Stop()
}
h.screenCloseTimers[deviceID] = time.AfterFunc(screenCloseGrace, func() {
h.mu.Lock()
delete(h.screenCloseTimers, deviceID)
h.mu.Unlock()
if h.countWatchers(deviceID) == 0 {
h.devices.CloseScreen(deviceID)
}
})
h.mu.Unlock()
}
// ----- HTTP handler -------------------------------------------------------
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
// Reject disallowed origins explicitly so the rejection is auditable.
// Upgrader.CheckOrigin below is kept as defense in depth.
if !h.checkOrigin(r) {
fields := requestFields(r, h.trustForwardedFor)
fields["reason"] = "origin_not_allowed"
h.log.AuditWarn(auditWSReject, fields)
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
// Build a per-call upgrader so CheckOrigin closes over this hub's
// allowlist instead of a package-level mutable.
upgrader := baseUpgrader
upgrader.CheckOrigin = h.checkOrigin
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fields := requestFields(r, h.trustForwardedFor)
fields["reason"] = "upgrade_failed"
fields["error"] = err.Error()
h.log.AuditWarn(auditWSReject, fields)
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 wsMsg, wsSendBuffer),
closed: make(chan struct{}),
id: newConnID(),
addr: clientIP(r, h.trustForwardedFor),
peerAddr: stripPort(r.RemoteAddr),
origin: r.Header.Get("Origin"),
userAgent: r.UserAgent(),
connectedAt: time.Now(),
nonce: nonce,
watchIDs: make(map[string]struct{}),
watchStartedAt: make(map[string]time.Time),
keyframeAt: make(map[string]int64),
}
h.register(client)
h.audit(client, false, auditWSConnect, client.wsRequestFields())
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 + `"` + challengeCaps + `}`))
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:
msgType := websocket.TextMessage
if msg.binary {
msgType = websocket.BinaryMessage
}
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
if err := c.conn.WriteMessage(msgType, msg.data); 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)
}
}