package web import ( "crypto/rand" "encoding/hex" "net/http" "time" ) // Stable audit event names. Keep these stable: operators build alerts and // dashboards on them. Every record also carries audit=true. const ( auditWSConnect = "web.ws.connect" auditWSReject = "web.ws.reject" auditWSClose = "web.ws.close" auditLoginOK = "web.login.success" auditLoginFail = "web.login.failure" auditLoginThrot = "web.login.throttled" auditSession = "web.session.resume" auditBadToken = "web.auth.invalid_token" auditDenied = "web.authz.denied" auditUnknownCmd = "web.command.unknown" auditSaltProbe = "web.salt.unknown_user" auditScreenStart = "remote.screen.start" auditScreenStop = "remote.screen.stop" auditScreenDenied = "remote.screen.denied" auditTermStart = "remote.terminal.start" auditTermStop = "remote.terminal.stop" auditTermDenied = "remote.terminal.denied" auditClipSet = "remote.clipboard.set" auditClipGet = "remote.clipboard.get" auditRdpReset = "remote.rdp_reset" auditUserCreate = "admin.user.create" auditUserDelete = "admin.user.delete" auditDeviceAdmin = "admin.device.delete" auditQuery = "admin.audit.query" ) // audit emits one audit record for a WebSocket client. Identity fields are // read under h.mu because requireAuth/handleLogin install them there and // broadcast goroutines may observe the same client concurrently. // // Callers MUST NOT hold h.mu when calling this method. func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) { if fields == nil { fields = make(map[string]any) } if c != nil { h.mu.RLock() connID := c.id addr := c.addr peer := c.peerAddr user := c.user role := c.role h.mu.RUnlock() if _, ok := fields["conn"]; !ok && connID != "" { fields["conn"] = connID } if _, ok := fields["ip"]; !ok && addr != "" { fields["ip"] = addr } if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr { fields["peer_ip"] = peer } if _, ok := fields["actor"]; !ok && user != "" { fields["actor"] = user } if _, ok := fields["role"]; !ok && role != "" { fields["role"] = role } } if warn { h.log.AuditWarn(event, fields) } else { h.log.Audit(event, fields) } } // auditClose records the end of a browser WebSocket. Anonymous scanners that // connect and do nothing produce only the connect event; authenticated // sessions, failed-login probes, and command traffic always produce a close // record with counters that help spot brute-force / probing behavior. func (h *wsHub) auditClose(c *wsClient, screens int) { suspicious := c.statInvalidToken > 0 || c.statLoginFailures > 0 || c.statAdminDenied > 0 || c.statUnknownSalt > 0 || c.statUnknownCmd > 0 if c.user == "" && c.statCommands == 0 && !suspicious { return } fields := map[string]any{ "duration_ms": time.Since(c.connectedAt).Milliseconds(), "commands": c.statCommands, } if screens > 0 { fields["screens"] = screens } if c.statInvalidToken > 0 { fields["invalid_tokens"] = c.statInvalidToken } if c.statLoginFailures > 0 { fields["login_failures"] = c.statLoginFailures } if c.statAdminDenied > 0 { fields["admin_denied"] = c.statAdminDenied } if c.statUnknownSalt > 0 { fields["unknown_salt"] = c.statUnknownSalt } if c.statUnknownCmd > 0 { fields["unknown_commands"] = c.statUnknownCmd } h.audit(c, suspicious, auditWSClose, fields) } // requestFields builds the audit context available before a WebSocket client // exists (upgrade rejected) or for plain HTTP endpoints (SSO). func requestFields(r *http.Request, trustForwardedFor bool) map[string]any { fields := make(map[string]any) if r == nil { return fields } ip := clientIP(r, trustForwardedFor) if ip != "" { fields["ip"] = ip } if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip { fields["peer_ip"] = peer } if origin := r.Header.Get("Origin"); origin != "" { fields["origin"] = origin } if ua := r.UserAgent(); ua != "" { fields["ua"] = ua } return fields } // wsRequestFields returns request metadata worth attaching to login events. func (c *wsClient) wsRequestFields() map[string]any { fields := make(map[string]any) if c.origin != "" { fields["origin"] = c.origin } if c.userAgent != "" { fields["ua"] = c.userAgent } return fields } func durationMs(start time.Time) int64 { if start.IsZero() { return 0 } return time.Since(start).Milliseconds() } // newConnID returns a short random identifier used to correlate all audit // records belonging to one browser WebSocket connection. func newConnID() string { var b [4]byte if _, err := rand.Read(b[:]); err != nil { return "conn" } return hex.EncodeToString(b[:]) }