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
This commit is contained in:
1 parent
b1ddec1b8a
commit
a522424491
12 files changed
+2272
-88
No files matched your search
+304
-21
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
@@ -38,6 +39,9 @@ func (h *wsHub) rotateChallenge(c *wsClient) {
|
||||
// Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
|
||||
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
|
||||
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
||||
// Connection-scoped counters feed the web.ws.close audit summary, which
|
||||
// is what turns a silent port scan into a visible event.
|
||||
c.statCommands++
|
||||
switch cmd {
|
||||
case "get_salt":
|
||||
h.handleGetSalt(c, raw)
|
||||
@@ -93,6 +97,19 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
||||
h.handleListUsers(c, raw)
|
||||
case "get_groups":
|
||||
h.handleGetGroups(c, raw)
|
||||
case "query_audit":
|
||||
h.handleQueryAudit(c, raw)
|
||||
default:
|
||||
// Unknown commands are attacker-controlled and can be sent in a tight
|
||||
// loop. Count them for the close summary and record only the first
|
||||
// one per connection so the audit trail cannot be flooded.
|
||||
c.statUnknownCmd++
|
||||
if !c.auditUnknownCmdLogged {
|
||||
c.auditUnknownCmdLogged = true
|
||||
h.audit(c, true, auditUnknownCmd, map[string]any{
|
||||
"command": cmd,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +122,11 @@ func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool)
|
||||
}
|
||||
// c.role is cached on first successful requireAuth; safe to read here.
|
||||
if c.role != "admin" {
|
||||
c.statAdminDenied++
|
||||
h.audit(c, true, auditDenied, map[string]any{
|
||||
"operation": strings.TrimSuffix(replyCmd, "_result"),
|
||||
"reason": "not_admin",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
|
||||
}))
|
||||
@@ -133,7 +155,21 @@ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
||||
}
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
|
||||
salt, _ := h.auth.GetSalt(in.Username)
|
||||
salt, exists := h.auth.GetSalt(in.Username)
|
||||
// The response deliberately hides whether the user exists (fake salt),
|
||||
// but the audit trail should not: repeated probes for a nonexistent
|
||||
// account are a strong signal of username enumeration. Record the first
|
||||
// probe per connection only; the close summary carries the total.
|
||||
if !exists {
|
||||
c.statUnknownSalt++
|
||||
if !c.auditUnknownSaltLogged {
|
||||
c.auditUnknownSaltLogged = true
|
||||
h.audit(c, true, auditSaltProbe, map[string]any{
|
||||
"username": in.Username,
|
||||
"reason": "unknown_user",
|
||||
})
|
||||
}
|
||||
}
|
||||
// GetSalt now returns a deterministic fake salt (16 hex chars) for
|
||||
// unknown users — same shape as a real salt — so an attacker can't
|
||||
// tell from this response alone whether the username exists.
|
||||
@@ -151,6 +187,8 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.statLoginFailures++
|
||||
h.audit(c, true, auditLoginFail, map[string]any{"reason": "malformed_request"})
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
|
||||
return
|
||||
}
|
||||
@@ -161,7 +199,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
// known account (admin). Either dimension tripping rejects the call
|
||||
// with a uniform "credentials" error so the limit is not detectable.
|
||||
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
|
||||
h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr)
|
||||
c.statLoginFailures++
|
||||
h.audit(c, true, auditLoginThrot, map[string]any{
|
||||
"username": in.Username,
|
||||
"reason": "rate_limited",
|
||||
})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||
// Rotate the challenge: burns the previous nonce (replay protection)
|
||||
@@ -174,6 +216,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
// 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.statLoginFailures++
|
||||
h.audit(c, true, auditLoginFail, map[string]any{
|
||||
"username": in.Username,
|
||||
"reason": "invalid_challenge",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
|
||||
h.rotateChallenge(c)
|
||||
return
|
||||
@@ -181,6 +228,16 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
|
||||
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
|
||||
if err != nil {
|
||||
// Record whether the account exists — an attack that keeps hammering
|
||||
// a real account (or scans for names) reads very differently from a
|
||||
// user mistyping their own password.
|
||||
_, exists := h.auth.GetSalt(in.Username)
|
||||
c.statLoginFailures++
|
||||
h.audit(c, true, auditLoginFail, map[string]any{
|
||||
"username": in.Username,
|
||||
"reason": "invalid_credentials",
|
||||
"user_exists": exists,
|
||||
})
|
||||
// Fixed delay on failure: makes online brute force impractical
|
||||
// even within the rate-limit budget, and erases the timing
|
||||
// difference between "wrong password" and "wrong nonce".
|
||||
@@ -193,10 +250,20 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
||||
// user who fat-fingered a few times doesn't stay throttled.
|
||||
h.resetLoginThrottle(c, in.Username)
|
||||
|
||||
// Identity fields are read by audit() from hub goroutines, so publish
|
||||
// them under h.mu.
|
||||
h.mu.Lock()
|
||||
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.user = in.Username
|
||||
h.mu.Unlock()
|
||||
|
||||
fields := c.wsRequestFields()
|
||||
fields["method"] = "password"
|
||||
fields["actor"] = in.Username
|
||||
fields["role"] = role
|
||||
h.audit(c, false, auditLoginOK, fields)
|
||||
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": "login_result",
|
||||
@@ -257,6 +324,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
||||
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
|
||||
// get a consistent view of the watch set.
|
||||
var closed []string
|
||||
starts := make(map[string]time.Time)
|
||||
h.mu.Lock()
|
||||
if in.MultiWatch && in.ID != "" {
|
||||
// New frontend: drop only this device; the other wall tiles keep
|
||||
@@ -264,6 +332,8 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
||||
if c.isWatching(in.ID) {
|
||||
delete(c.watchIDs, in.ID)
|
||||
delete(c.keyframeAt, in.ID)
|
||||
starts[in.ID] = c.watchStartedAt[in.ID]
|
||||
delete(c.watchStartedAt, in.ID)
|
||||
closed = append(closed, in.ID)
|
||||
}
|
||||
} else {
|
||||
@@ -271,9 +341,11 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
||||
// one device, so clear everything.
|
||||
for id := range c.watchIDs {
|
||||
closed = append(closed, id)
|
||||
starts[id] = c.watchStartedAt[id]
|
||||
}
|
||||
c.watchIDs = make(map[string]struct{})
|
||||
c.keyframeAt = make(map[string]int64)
|
||||
c.watchStartedAt = make(map[string]time.Time)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
@@ -288,6 +360,13 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
||||
|
||||
// deferredCloseScreen takes h.mu, so it must run outside the lock above.
|
||||
for _, id := range closed {
|
||||
if !starts[id].IsZero() {
|
||||
h.audit(c, false, auditScreenStop, map[string]any{
|
||||
"device_id": id,
|
||||
"duration_ms": durationMs(starts[id]),
|
||||
"reason": "disconnect",
|
||||
})
|
||||
}
|
||||
if h.countWatchers(id) == 0 {
|
||||
h.deferredCloseScreen(id)
|
||||
}
|
||||
@@ -393,8 +472,10 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
}
|
||||
|
||||
var stopped []string
|
||||
var tooMany bool
|
||||
starts := make(map[string]time.Time)
|
||||
var tooMany, already bool
|
||||
h.mu.Lock()
|
||||
_, already = c.watchIDs[in.ID]
|
||||
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
|
||||
// A pending deferred close means the previous session is still alive for
|
||||
// a few seconds; cancel it so the cached resolution + IDR hot path wins.
|
||||
@@ -406,10 +487,24 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
t.Stop()
|
||||
delete(h.screenCloseTimers, in.ID)
|
||||
}
|
||||
// Repeated connect (focus switch / wall re-subscribe) keeps the
|
||||
// original start time so its audit duration covers the whole watch.
|
||||
if _, ok := c.watchStartedAt[in.ID]; !ok {
|
||||
c.watchStartedAt[in.ID] = time.Now()
|
||||
}
|
||||
for _, id := range stopped {
|
||||
starts[id] = c.watchStartedAt[id]
|
||||
delete(c.watchStartedAt, id)
|
||||
delete(c.keyframeAt, id)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if tooMany {
|
||||
h.audit(c, true, auditScreenDenied, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"reason": "too_many_watches",
|
||||
})
|
||||
c.queue(mustJSON(reply(false, "Too many devices on one connection")))
|
||||
return
|
||||
}
|
||||
@@ -417,6 +512,13 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
// screen sessions are torn down outside the lock; deferredCloseScreen is
|
||||
// a no-op while another browser still watches the device.
|
||||
for _, id := range stopped {
|
||||
if !starts[id].IsZero() {
|
||||
h.audit(c, false, auditScreenStop, map[string]any{
|
||||
"device_id": id,
|
||||
"duration_ms": durationMs(starts[id]),
|
||||
"reason": "replaced",
|
||||
})
|
||||
}
|
||||
if h.countWatchers(id) == 0 {
|
||||
h.deferredCloseScreen(id)
|
||||
}
|
||||
@@ -429,6 +531,15 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
// next IDR (~15 s).
|
||||
cache := h.devices.ScreenState(in.ID)
|
||||
if cache.Active {
|
||||
if !already {
|
||||
h.audit(c, false, auditScreenStart, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "cached",
|
||||
"multi_watch": in.MultiWatch,
|
||||
"width": cache.Width,
|
||||
"height": cache.Height,
|
||||
})
|
||||
}
|
||||
m := reply(true, "")
|
||||
m["width"] = cache.Width
|
||||
m["height"] = cache.Height
|
||||
@@ -461,16 +572,30 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
||||
h.mu.Lock()
|
||||
delete(c.watchIDs, in.ID)
|
||||
delete(c.keyframeAt, in.ID)
|
||||
delete(c.watchStartedAt, in.ID)
|
||||
h.mu.Unlock()
|
||||
msg := "Device offline"
|
||||
reason := "device_offline"
|
||||
if err != hub.ErrDeviceOffline {
|
||||
msg = "Failed to start screen capture"
|
||||
reason = "start_failed"
|
||||
h.log.Error("SendToDevice(%s): %v", in.ID, err)
|
||||
}
|
||||
h.audit(c, true, auditScreenDenied, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"reason": reason,
|
||||
})
|
||||
c.queue(mustJSON(reply(false, msg)))
|
||||
return
|
||||
}
|
||||
h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
|
||||
if !already {
|
||||
h.audit(c, false, auditScreenStart, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "started",
|
||||
"multi_watch": in.MultiWatch,
|
||||
})
|
||||
}
|
||||
|
||||
c.queue(mustJSON(reply(true, "")))
|
||||
}
|
||||
@@ -561,7 +686,10 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
|
||||
}
|
||||
deviceID := h.resolveWatchDevice(c, raw)
|
||||
if deviceID == "" {
|
||||
h.log.Warn("rdp_reset: no device watched (addr=%s role=%s)", c.addr, c.role)
|
||||
h.audit(c, true, auditRdpReset, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "no_device",
|
||||
})
|
||||
return
|
||||
}
|
||||
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client
|
||||
@@ -569,10 +697,18 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
|
||||
// the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
|
||||
// main conn would silently no-op.
|
||||
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil {
|
||||
h.log.Warn("rdp_reset: device=%s: %v", deviceID, err)
|
||||
h.audit(c, true, auditRdpReset, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": false,
|
||||
"reason": "send_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.log.Info("rdp_reset sent: device=%s", deviceID)
|
||||
h.audit(c, false, auditRdpReset, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
||||
@@ -614,21 +750,46 @@ func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
|
||||
}
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
if in.Token == "" {
|
||||
h.auditBadToken(c, replyCmd, "missing_token")
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||
return false
|
||||
}
|
||||
sess, err := h.auth.ValidateToken(in.Token)
|
||||
if err != nil {
|
||||
h.auditBadToken(c, replyCmd, "invalid_token")
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||
return false
|
||||
}
|
||||
resumed := false
|
||||
h.mu.Lock()
|
||||
if c.token == "" {
|
||||
c.token = in.Token
|
||||
c.role = sess.Role
|
||||
c.user = sess.Username
|
||||
resumed = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if resumed {
|
||||
h.audit(c, false, auditSession, map[string]any{"method": "token"})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// auditBadToken counts rejected tokens and records only the first one per
|
||||
// connection: an attacker can loop protected commands with garbage tokens,
|
||||
// and one line plus the close-summary count is enough to see it.
|
||||
func (h *wsHub) auditBadToken(c *wsClient, replyCmd, reason string) {
|
||||
c.statInvalidToken++
|
||||
if c.auditInvalidTokenLogged {
|
||||
return
|
||||
}
|
||||
c.auditInvalidTokenLogged = true
|
||||
h.audit(c, true, auditBadToken, map[string]any{
|
||||
"command": strings.TrimSuffix(replyCmd, "_result"),
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -796,19 +957,39 @@ func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) {
|
||||
}
|
||||
deviceID := h.resolveWatchDevice(c, raw)
|
||||
if deviceID == "" {
|
||||
h.audit(c, true, auditClipSet, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "no_device",
|
||||
})
|
||||
return
|
||||
}
|
||||
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
|
||||
if err != nil {
|
||||
h.log.Warn("clipboard_set: GBK encode failed: %v", err)
|
||||
h.audit(c, true, auditClipSet, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": false,
|
||||
"reason": "encode_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
|
||||
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
|
||||
h.log.Warn("clipboard_set: device=%s: %v", deviceID, err)
|
||||
h.audit(c, true, auditClipSet, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": false,
|
||||
"reason": "send_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.log.Info("clipboard_set sent: device=%s len=%d", deviceID, len(gbk))
|
||||
// Record the size only — clipboard contents may be sensitive.
|
||||
h.audit(c, false, auditClipSet, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": true,
|
||||
"bytes": len(gbk),
|
||||
"source_utf8": len(in.Text),
|
||||
})
|
||||
}
|
||||
|
||||
// handleClipboardGet asks the remote device for its clipboard text via
|
||||
@@ -820,12 +1001,25 @@ func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
|
||||
}
|
||||
deviceID := h.resolveWatchDevice(c, raw)
|
||||
if deviceID == "" {
|
||||
h.audit(c, true, auditClipGet, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "no_device",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
|
||||
h.log.Warn("clipboard_get: device=%s: %v", deviceID, err)
|
||||
h.audit(c, true, auditClipGet, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": false,
|
||||
"reason": "send_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.audit(c, false, auditClipGet, map[string]any{
|
||||
"device_id": deviceID,
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
||||
@@ -844,6 +1038,10 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
||||
AllowedGroups []string `json:"allowed_groups"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
h.audit(c, true, auditUserCreate, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "invalid_json",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||
return
|
||||
}
|
||||
@@ -851,11 +1049,22 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
||||
in.Role = "viewer"
|
||||
}
|
||||
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
|
||||
h.audit(c, true, auditUserCreate, map[string]any{
|
||||
"target": in.Username,
|
||||
"target_role": in.Role,
|
||||
"ok": false,
|
||||
"reason": "create_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
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))
|
||||
h.audit(c, false, auditUserCreate, map[string]any{
|
||||
"target": in.Username,
|
||||
"target_role": in.Role,
|
||||
"groups": len(in.AllowedGroups),
|
||||
"ok": true,
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||
}
|
||||
|
||||
@@ -877,6 +1086,10 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "invalid_id",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false, "msg": "Invalid device id",
|
||||
}))
|
||||
@@ -893,7 +1106,11 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
|
||||
if h.history != nil {
|
||||
h.history.Remove(in.ID)
|
||||
}
|
||||
h.log.Info("device uninstall requested by %s: id=%s (record removed)", c.role, in.ID)
|
||||
h.audit(c, false, auditDeviceAdmin, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "uninstall",
|
||||
"ok": true,
|
||||
})
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall",
|
||||
@@ -903,7 +1120,13 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
|
||||
// Lost the race with a disconnect between IsOnline and the send:
|
||||
// fall through to removing the (now offline) history record.
|
||||
if !errors.Is(err, hub.ErrDeviceOffline) {
|
||||
h.log.Warn("device uninstall failed for %s: %v", in.ID, err)
|
||||
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "uninstall",
|
||||
"ok": false,
|
||||
"reason": "uninstall_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false, "msg": err.Error(),
|
||||
}))
|
||||
@@ -912,12 +1135,22 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
|
||||
}
|
||||
|
||||
if h.history == nil || !h.history.Remove(in.ID) {
|
||||
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "removed",
|
||||
"ok": false,
|
||||
"reason": "not_found",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false, "msg": "Device not found",
|
||||
}))
|
||||
return
|
||||
}
|
||||
h.log.Info("device record removed by %s: id=%s", c.role, in.ID)
|
||||
h.audit(c, false, auditDeviceAdmin, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"mode": "removed",
|
||||
"ok": true,
|
||||
})
|
||||
// Other browsers (and this one) refresh their lists; the reply below
|
||||
// still gives the initiator a precise result/toast.
|
||||
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||
@@ -937,14 +1170,27 @@ func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
h.audit(c, true, auditUserDelete, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "invalid_json",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||
return
|
||||
}
|
||||
if err := h.auth.DeleteUser(in.Username); err != nil {
|
||||
h.audit(c, true, auditUserDelete, map[string]any{
|
||||
"target": in.Username,
|
||||
"ok": false,
|
||||
"reason": "delete_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
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)
|
||||
h.audit(c, false, auditUserDelete, map[string]any{
|
||||
"target": in.Username,
|
||||
"ok": true,
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||
}
|
||||
|
||||
@@ -1034,34 +1280,57 @@ func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) {
|
||||
// screen path in handleConnect.
|
||||
h.mu.Lock()
|
||||
if c.termWatching != "" && c.termWatching != in.ID {
|
||||
active := c.termWatching
|
||||
h.mu.Unlock()
|
||||
h.audit(c, true, auditTermDenied, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"reason": "other_terminal_open",
|
||||
"active": active,
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Close current terminal before opening another",
|
||||
}))
|
||||
return
|
||||
}
|
||||
newSession := c.termWatching != in.ID
|
||||
c.termWatching = in.ID
|
||||
c.termCloseReason = ""
|
||||
if newSession || c.termStartedAt.IsZero() {
|
||||
c.termStartedAt = time.Now()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
|
||||
h.mu.Lock()
|
||||
c.termWatching = ""
|
||||
c.termStartedAt = time.Time{}
|
||||
h.mu.Unlock()
|
||||
msg := "Device offline"
|
||||
reason := "device_offline"
|
||||
switch err {
|
||||
case hub.ErrTerminalBusy:
|
||||
msg = "Terminal already open by another viewer"
|
||||
reason = "terminal_busy"
|
||||
case hub.ErrDeviceOffline:
|
||||
msg = "Device offline"
|
||||
default:
|
||||
msg = "Failed to start terminal"
|
||||
reason = "start_failed"
|
||||
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
|
||||
}
|
||||
h.audit(c, true, auditTermDenied, map[string]any{
|
||||
"device_id": in.ID,
|
||||
"reason": reason,
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
|
||||
return
|
||||
}
|
||||
h.log.Info("term_open: device=%s role=%s", in.ID, c.role)
|
||||
if newSession {
|
||||
h.audit(c, false, auditTermStart, map[string]any{
|
||||
"device_id": in.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleTermInput forwards xterm.js keystrokes to the device's shell
|
||||
@@ -1082,7 +1351,10 @@ func (h *wsHub) handleTermInput(c *wsClient, raw []byte) {
|
||||
if in.ID == "" || in.Data == "" {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
h.mu.RLock()
|
||||
watching := c.termWatching == in.ID
|
||||
h.mu.RUnlock()
|
||||
if !watching {
|
||||
return // someone else's session, or no session
|
||||
}
|
||||
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data))
|
||||
@@ -1106,7 +1378,10 @@ func (h *wsHub) handleTermResize(c *wsClient, raw []byte) {
|
||||
if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
h.mu.RLock()
|
||||
watching := c.termWatching == in.ID
|
||||
h.mu.RUnlock()
|
||||
if !watching {
|
||||
return
|
||||
}
|
||||
if !h.devices.TerminalIsPTY(in.ID) {
|
||||
@@ -1128,7 +1403,15 @@ func (h *wsHub) handleTermClose(c *wsClient, raw []byte) {
|
||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||
return
|
||||
}
|
||||
if c.termWatching != in.ID {
|
||||
h.mu.Lock()
|
||||
watching := c.termWatching == in.ID
|
||||
if watching {
|
||||
// Consumed by OnTerminalClosed for the remote.terminal.stop audit
|
||||
// record (it fires synchronously inside CloseTerminalSession).
|
||||
c.termCloseReason = "user"
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !watching {
|
||||
return
|
||||
}
|
||||
h.devices.CloseTerminalSession(in.ID)
|
||||
|
||||
Reference in new issue
Block a user