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
+126
-16
@@ -48,11 +48,19 @@ type wsClient struct {
|
||||
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
|
||||
addr string // client address for logs
|
||||
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
|
||||
@@ -60,9 +68,30 @@ type wsClient struct {
|
||||
// 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
|
||||
@@ -97,7 +126,7 @@ func (c *wsClient) 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}`
|
||||
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
|
||||
@@ -286,8 +315,12 @@ func (h *wsHub) OnDeviceOffline(id string) {
|
||||
// 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()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
if !c.isWatching(id) {
|
||||
continue
|
||||
@@ -297,6 +330,19 @@ func (h *wsHub) OnDeviceOffline(id string) {
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,14 +461,35 @@ func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
|
||||
"ok": true,
|
||||
"reason": reason,
|
||||
})
|
||||
type closed struct {
|
||||
c *wsClient
|
||||
start time.Time
|
||||
why string
|
||||
}
|
||||
var done []closed
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
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
|
||||
@@ -467,26 +534,49 @@ func (h *wsHub) unregister(c *wsClient) {
|
||||
// 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.
|
||||
if c.termWatching != "" {
|
||||
h.devices.CloseTerminalSession(c.termWatching)
|
||||
c.termWatching = ""
|
||||
// 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
|
||||
@@ -521,6 +611,16 @@ func (h *wsHub) deferredCloseScreen(deviceID string) {
|
||||
// ----- 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
|
||||
@@ -528,7 +628,10 @@ 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)
|
||||
fields := requestFields(r, h.trustForwardedFor)
|
||||
fields["reason"] = "upgrade_failed"
|
||||
fields["error"] = err.Error()
|
||||
h.log.AuditWarn(auditWSReject, fields)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(wsReadLimit)
|
||||
@@ -541,15 +644,22 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
client := &wsClient{
|
||||
conn: conn,
|
||||
send: make(chan wsMsg, wsSendBuffer),
|
||||
closed: make(chan struct{}),
|
||||
nonce: nonce,
|
||||
addr: clientIP(r, h.trustForwardedFor),
|
||||
watchIDs: make(map[string]struct{}),
|
||||
keyframeAt: make(map[string]int64),
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user