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
@@ -0,0 +1,171 @@
|
||||
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[:])
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Audit-log query support for the admin-only "Audit Log" page.
|
||||
//
|
||||
// The server reads its own JSON-lines audit file (logs/audit.log) back to the
|
||||
// browser. Records are already redacted at write time (see logger.AuditAt), so
|
||||
// returning raw lines is safe as long as we never widen what is written.
|
||||
//
|
||||
// Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz])
|
||||
// are deliberately out of scope: they need gzip support and a separate index,
|
||||
// and the UI is meant for recent-window triage ("what happened just now").
|
||||
const (
|
||||
auditQueryDefaultLimit = 100
|
||||
auditQueryMaxLimit = 500
|
||||
auditQueryMaxOffset = 10000
|
||||
|
||||
// auditQueryScanBudget bounds how much of the audit file a single query
|
||||
// reads. The tail is read when the file is larger, so the newest records
|
||||
// (what operators actually triage) are always covered and a multi-GB log
|
||||
// cannot stall the WS goroutine.
|
||||
auditQueryScanBudget = 32 << 20 // 32 MB
|
||||
)
|
||||
|
||||
// auditQueryRequest is the JSON body of the `query_audit` command.
|
||||
type auditQueryRequest struct {
|
||||
From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional
|
||||
To string `json:"to"` // RFC3339, optional
|
||||
Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or ""
|
||||
Actor string `json:"actor"` // exact, case-insensitive
|
||||
IP string `json:"ip"` // exact
|
||||
DeviceID string `json:"device_id"`
|
||||
Keyword string `json:"keyword"` // case-insensitive substring of the raw record
|
||||
WarnOnly bool `json:"warn_only"` // only level=warn records
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"` // skip the newest N matching records
|
||||
}
|
||||
|
||||
// auditQueryResult is the internal result of scanning the audit file.
|
||||
type auditQueryResult struct {
|
||||
Events []map[string]any
|
||||
Total int // matching records in the scanned window
|
||||
FileExists bool
|
||||
FileSize int64
|
||||
ScannedBytes int64
|
||||
Truncated bool // the file was larger than auditQueryScanBudget
|
||||
}
|
||||
|
||||
// handleQueryAudit implements the admin-only audit query command. The reply is
|
||||
// `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first.
|
||||
func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) {
|
||||
const replyCmd = "audit_result"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
var in auditQueryRequest
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"}))
|
||||
return
|
||||
}
|
||||
in.Limit = clampAuditLimit(in.Limit)
|
||||
in.Offset = clampAuditOffset(in.Offset)
|
||||
|
||||
path := h.log.AuditPath()
|
||||
if path == "" {
|
||||
h.audit(c, true, auditQuery, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "audit_log_disabled",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := queryAuditLog(path, in)
|
||||
if err != nil {
|
||||
h.audit(c, true, auditQuery, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "read_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Failed to read audit log: " + err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Record the query itself: an audit viewer is privileged, and reading the
|
||||
// trail is itself an auditable action. Filter terms are included so an
|
||||
// investigator can see what was searched for.
|
||||
h.audit(c, false, auditQuery, map[string]any{
|
||||
"ok": true,
|
||||
"event_filter": in.Event,
|
||||
"actor_filter": in.Actor,
|
||||
"ip_filter": in.IP,
|
||||
"device_filter": in.DeviceID,
|
||||
"keyword": in.Keyword,
|
||||
"warn_only": in.WarnOnly,
|
||||
"matches": res.Total,
|
||||
"returned": len(res.Events),
|
||||
"offset": in.Offset,
|
||||
})
|
||||
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd,
|
||||
"ok": true,
|
||||
"events": res.Events,
|
||||
"total": res.Total,
|
||||
"offset": in.Offset,
|
||||
"limit": in.Limit,
|
||||
"file": path,
|
||||
"file_exists": res.FileExists,
|
||||
"file_size": res.FileSize,
|
||||
"scanned_bytes": res.ScannedBytes,
|
||||
"truncated": res.Truncated,
|
||||
}))
|
||||
}
|
||||
|
||||
func clampAuditLimit(n int) int {
|
||||
if n <= 0 {
|
||||
return auditQueryDefaultLimit
|
||||
}
|
||||
if n > auditQueryMaxLimit {
|
||||
return auditQueryMaxLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func clampAuditOffset(n int) int {
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
if n > auditQueryMaxOffset {
|
||||
return auditQueryMaxOffset
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// queryAuditLog scans path for audit records matching q. The returned events
|
||||
// are the newest limit records after skipping offset, in newest-first order.
|
||||
func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) {
|
||||
res := auditQueryResult{Events: []map[string]any{}}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No audit record has been written yet (lumberjack creates the file
|
||||
// lazily). An empty result is correct, not an error.
|
||||
return res, nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.FileExists = true
|
||||
res.FileSize = info.Size()
|
||||
|
||||
// For very large files, scan only the tail; the newest matches live there.
|
||||
start := int64(0)
|
||||
if info.Size() > auditQueryScanBudget {
|
||||
start = info.Size() - auditQueryScanBudget
|
||||
if _, err := f.Seek(start, 0); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Truncated = true
|
||||
}
|
||||
|
||||
from, hasFrom := parseAuditTime(q.From)
|
||||
to, hasTo := parseAuditTime(q.To)
|
||||
|
||||
keep := q.Offset + q.Limit
|
||||
ring := make([]map[string]any, keep)
|
||||
filled := 0
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
// Sanitized values are capped at 4096 bytes each; a record with many
|
||||
// fields stays well under 1 MB. Allow headroom rather than aborting the
|
||||
// whole query on one long line.
|
||||
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
first := true
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if start > 0 && first {
|
||||
// Seeking into the middle of the file lands mid-line. Drop that
|
||||
// partial record.
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
first = false
|
||||
res.ScannedBytes += int64(len(line)) + 1
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
continue // tolerate a torn line instead of failing the page
|
||||
}
|
||||
if _, ok := rec["event"].(string); !ok {
|
||||
continue
|
||||
}
|
||||
// Only audit records belong on the page. The dedicated file contains
|
||||
// nothing else, but a hand-edited or mixed file must not surface
|
||||
// ordinary operational lines here.
|
||||
if rec["audit"] != true {
|
||||
continue
|
||||
}
|
||||
if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) {
|
||||
continue
|
||||
}
|
||||
// Ring buffer of the last `keep` matches (file order is chronological).
|
||||
ring[res.Total%keep] = rec
|
||||
if filled < keep {
|
||||
filled++
|
||||
}
|
||||
res.Total++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Newest first, skipping Offset. The match with 0-based index i lives at
|
||||
// ring[i%keep] as long as i >= Total-filled.
|
||||
for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- {
|
||||
res.Events = append(res.Events, ring[i%keep])
|
||||
if len(res.Events) >= q.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// auditRecordMatches applies the non-time and time filters to one record.
|
||||
// line is the raw JSON line, used for the keyword substring search.
|
||||
func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool {
|
||||
if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) {
|
||||
return false
|
||||
}
|
||||
if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) {
|
||||
return false
|
||||
}
|
||||
if q.IP != "" && auditStringField(rec, "ip") != q.IP {
|
||||
return false
|
||||
}
|
||||
if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID {
|
||||
return false
|
||||
}
|
||||
if q.WarnOnly && auditStringField(rec, "level") != "warn" {
|
||||
return false
|
||||
}
|
||||
if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) {
|
||||
return false
|
||||
}
|
||||
if hasFrom || hasTo {
|
||||
t, ok := parseAuditTime(auditStringField(rec, "time"))
|
||||
if !ok {
|
||||
return false // cannot prove it is in range
|
||||
}
|
||||
if hasFrom && t.Before(from) {
|
||||
return false
|
||||
}
|
||||
if hasTo && t.After(to) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// auditEventFilterMatches supports three forms so the UI stays simple:
|
||||
//
|
||||
// "web.login.failure" exact
|
||||
// "web.*" prefix (trailing "*")
|
||||
// "web." prefix (trailing ".")
|
||||
func auditEventFilterMatches(filter, event string) bool {
|
||||
switch {
|
||||
case strings.HasSuffix(filter, "*"):
|
||||
return strings.HasPrefix(event, strings.TrimSuffix(filter, "*"))
|
||||
case strings.HasSuffix(filter, "."):
|
||||
return strings.HasPrefix(event, filter)
|
||||
default:
|
||||
return event == filter
|
||||
}
|
||||
}
|
||||
|
||||
func auditStringField(rec map[string]any, key string) string {
|
||||
s, _ := rec[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// parseAuditTime accepts the formats operators and browsers realistically send.
|
||||
// Layouts without an explicit zone are parsed as UTC; the frontend always sends
|
||||
// Date.toISOString(), so the RFC3339 path is what production uses.
|
||||
func parseAuditTime(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated
|
||||
// audit file, mirroring a server started with YAMA_AUDIT_LOG=off.
|
||||
func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) {
|
||||
t.Helper()
|
||||
lg := logger.New(logger.Config{
|
||||
Level: logger.LevelInfo,
|
||||
Console: false,
|
||||
File: filepath.Join(dir, "server.log"),
|
||||
})
|
||||
t.Cleanup(func() { _ = lg.Close() })
|
||||
wh := newWSHub(wsauth.New(), hub.New(), nil, lg)
|
||||
t.Cleanup(wh.stop)
|
||||
return wh, ""
|
||||
}
|
||||
|
||||
func readQueuedJSON(t *testing.T, c *wsClient) map[string]any {
|
||||
t.Helper()
|
||||
select {
|
||||
case m := <-c.send:
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(m.data, &v); err != nil {
|
||||
t.Fatalf("queued reply is not JSON: %q: %v", m.data, err)
|
||||
}
|
||||
return v
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for a queued reply")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeAuditFixture writes JSON-lines audit records (oldest first) and returns
|
||||
// the file path. Times are UTC RFC3339 so the tests are timezone-independent.
|
||||
func writeAuditFixture(t *testing.T, records []map[string]any) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.log")
|
||||
var b strings.Builder
|
||||
for _, rec := range records {
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal fixture: %v", err)
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func auditFixtureRecords() []map[string]any {
|
||||
at := func(hhmm string) string {
|
||||
tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tm.Format(time.RFC3339)
|
||||
}
|
||||
return []map[string]any{
|
||||
{"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"},
|
||||
{"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"},
|
||||
{"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"},
|
||||
{"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"},
|
||||
}
|
||||
}
|
||||
|
||||
func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult {
|
||||
t.Helper()
|
||||
q.Limit = clampAuditLimit(q.Limit)
|
||||
q.Offset = clampAuditOffset(q.Offset)
|
||||
res, err := queryAuditLog(path, q)
|
||||
if err != nil {
|
||||
t.Fatalf("queryAuditLog: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func eventNames(res auditQueryResult) []string {
|
||||
out := make([]string, 0, len(res.Events))
|
||||
for _, e := range res.Events {
|
||||
out = append(out, auditStringField(e, "event"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAuditQueryNoFilterNewestFirst(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||
if res.Total != 4 || len(res.Events) != 4 {
|
||||
t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events))
|
||||
}
|
||||
got := eventNames(res)
|
||||
want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryEventFilters(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
cases := []struct {
|
||||
filter string
|
||||
want []string
|
||||
}{
|
||||
{"web.", []string{"web.login.failure", "web.login.success"}},
|
||||
{"web.*", []string{"web.login.failure", "web.login.success"}},
|
||||
{"web.login.failure", []string{"web.login.failure"}},
|
||||
{"remote.*", []string{"remote.screen.start"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100})
|
||||
got := eventNames(res)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryFieldFilters(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z")
|
||||
to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q auditQueryRequest
|
||||
want []string
|
||||
}{
|
||||
{"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}},
|
||||
{"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}},
|
||||
{"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}},
|
||||
{"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}},
|
||||
{"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}},
|
||||
{"time range", auditQueryRequest{
|
||||
From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100,
|
||||
}, []string{"remote.screen.start", "web.login.failure"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res := runAuditQuery(t, path, tc.q)
|
||||
got := eventNames(res)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("%s → %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryOffsetAndLimit(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1})
|
||||
if res.Total != 4 {
|
||||
t.Fatalf("total = %d, want 4", res.Total)
|
||||
}
|
||||
got := eventNames(res)
|
||||
want := []string{"remote.screen.start", "web.login.failure"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Offsetting past the end yields no events but keeps the total.
|
||||
res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4})
|
||||
if res.Total != 4 || len(res.Events) != 0 {
|
||||
t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.log")
|
||||
content := `{"event":"device.online","actor":"x"}
|
||||
not json at all
|
||||
{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||
if res.Total != 1 || len(res.Events) != 1 {
|
||||
t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryMissingFile(t *testing.T) {
|
||||
res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10})
|
||||
if res.FileExists {
|
||||
t.Error("FileExists = true for a missing file")
|
||||
}
|
||||
if res.Total != 0 || len(res.Events) != 0 {
|
||||
t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res))
|
||||
}
|
||||
if res.Events == nil {
|
||||
t.Error("Events is nil; JSON would encode null instead of []")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryClampsPaging(t *testing.T) {
|
||||
if got := clampAuditLimit(0); got != auditQueryDefaultLimit {
|
||||
t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||
}
|
||||
if got := clampAuditLimit(-3); got != auditQueryDefaultLimit {
|
||||
t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||
}
|
||||
if got := clampAuditLimit(9999); got != auditQueryMaxLimit {
|
||||
t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit)
|
||||
}
|
||||
if got := clampAuditLimit(50); got != 50 {
|
||||
t.Errorf("limit(50) = %d, want 50", got)
|
||||
}
|
||||
if got := clampAuditOffset(-5); got != 0 {
|
||||
t.Errorf("offset(-5) = %d, want 0", got)
|
||||
}
|
||||
if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset {
|
||||
t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerAdminOnly(t *testing.T) {
|
||||
wh, _, _ := auditTestHub(t)
|
||||
viewer, tok := auditTestClient(wh, "viewer")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
wh.handleQueryAudit(viewer, raw)
|
||||
|
||||
msg := readQueuedJSON(t, viewer)
|
||||
if msg["cmd"] != "audit_result" || msg["ok"] != false {
|
||||
t.Fatalf("viewer reply = %+v", msg)
|
||||
}
|
||||
if _, ok := msg["events"]; ok {
|
||||
t.Error("denied viewer got events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerReturnsEvents(t *testing.T) {
|
||||
wh, _, _ := auditTestHub(t)
|
||||
// Two records written directly through the same logger the hub queries.
|
||||
wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"})
|
||||
wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"})
|
||||
|
||||
admin, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10})
|
||||
wh.handleQueryAudit(admin, raw)
|
||||
|
||||
msg := readQueuedJSON(t, admin)
|
||||
if msg["cmd"] != "audit_result" || msg["ok"] != true {
|
||||
t.Fatalf("reply = %+v", msg)
|
||||
}
|
||||
events, ok := msg["events"].([]any)
|
||||
if !ok || len(events) < 2 {
|
||||
t.Fatalf("events = %#v, want at least the two written records", msg["events"])
|
||||
}
|
||||
// requireAuth writes a web.session.resume record for this same request, so
|
||||
// that one legitimately appears newer. Assert the two written records are
|
||||
// present and still newest-first relative to each other.
|
||||
var names []string
|
||||
for _, e := range events {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
names = append(names, auditStringField(m, "event"))
|
||||
}
|
||||
}
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") {
|
||||
t.Fatalf("events = %v, want both written records", names)
|
||||
}
|
||||
if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") {
|
||||
t.Fatalf("events = %v, want newest-first order", names)
|
||||
}
|
||||
if msg["truncated"] != false || msg["file_exists"] != true {
|
||||
t.Errorf("meta = %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerDisabledFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wh, _ := newAuditTestHubWithoutAuditFile(t, dir)
|
||||
admin, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
wh.handleQueryAudit(admin, raw)
|
||||
|
||||
msg := readQueuedJSON(t, admin)
|
||||
if msg["ok"] != false {
|
||||
t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// auditTestHub builds a wsHub whose logger writes both the main log and the
|
||||
// dedicated audit log to a temp directory.
|
||||
func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
auditPath = filepath.Join(dir, "audit.log")
|
||||
lg := logger.New(logger.Config{
|
||||
Level: logger.LevelInfo,
|
||||
Console: false,
|
||||
File: filepath.Join(dir, "server.log"),
|
||||
AuditFile: auditPath,
|
||||
})
|
||||
t.Cleanup(func() { _ = lg.Close() })
|
||||
|
||||
devices = hub.New()
|
||||
wh = newWSHub(wsauth.New(), devices, nil, lg)
|
||||
t.Cleanup(wh.stop)
|
||||
return wh, devices, auditPath
|
||||
}
|
||||
|
||||
func auditTestClient(wh *wsHub, role string) (*wsClient, string) {
|
||||
tok, err := wh.auth.IssueToken("tester", role)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := &wsClient{
|
||||
send: make(chan wsMsg, 64),
|
||||
closed: make(chan struct{}),
|
||||
id: newConnID(),
|
||||
addr: "203.0.113.5",
|
||||
peerAddr: "203.0.113.5",
|
||||
connectedAt: time.Now(),
|
||||
watchIDs: make(map[string]struct{}),
|
||||
watchStartedAt: make(map[string]time.Time),
|
||||
keyframeAt: make(map[string]int64),
|
||||
}
|
||||
wh.register(c)
|
||||
return c, tok
|
||||
}
|
||||
|
||||
func readAuditEvents(t *testing.T, path string) []map[string]any {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open audit log: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []map[string]any
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("bad audit line %q: %v", line, err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
t.Fatalf("scan audit log: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findAuditEvent(t *testing.T, path, event string) map[string]any {
|
||||
t.Helper()
|
||||
for _, m := range readAuditEvents(t, path) {
|
||||
if m["event"] == event {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginFailureIsAudited(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
wh.auth.AddAdminFromPlainPassword("admin", "hunter2")
|
||||
c, _ := auditTestClient(wh, "viewer")
|
||||
|
||||
nonce, err := wsauth.NewNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("NewNonce: %v", err)
|
||||
}
|
||||
c.nonce = nonce
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"username": "admin",
|
||||
"response": "not-the-right-response",
|
||||
"nonce": nonce,
|
||||
})
|
||||
wh.handleLogin(c, raw)
|
||||
|
||||
rec := findAuditEvent(t, auditPath, auditLoginFail)
|
||||
if rec["reason"] != "invalid_credentials" {
|
||||
t.Errorf("reason = %v", rec["reason"])
|
||||
}
|
||||
if rec["username"] != "admin" {
|
||||
t.Errorf("username = %v", rec["username"])
|
||||
}
|
||||
if rec["user_exists"] != true {
|
||||
t.Errorf("user_exists = %v, want true", rec["user_exists"])
|
||||
}
|
||||
if rec["ip"] != "203.0.113.5" {
|
||||
t.Errorf("ip = %v", rec["ip"])
|
||||
}
|
||||
if _, ok := rec["response"]; ok {
|
||||
t.Error("audit record leaked the login response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDenialIsAudited(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
c, tok := auditTestClient(wh, "viewer")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
|
||||
if wh.requireAdmin(c, raw, "delete_device_result") {
|
||||
t.Fatal("viewer passed requireAdmin")
|
||||
}
|
||||
rec := findAuditEvent(t, auditPath, auditDenied)
|
||||
if rec["operation"] != "delete_device" {
|
||||
t.Errorf("operation = %v", rec["operation"])
|
||||
}
|
||||
if rec["role"] != "viewer" || rec["actor"] != "tester" {
|
||||
t.Errorf("identity fields = %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenStartAndOfflineStopAreAudited(t *testing.T) {
|
||||
wh, devices, auditPath := auditTestHub(t)
|
||||
devices.SetSender(func(*connection.Context, []byte) error { return nil })
|
||||
hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"}
|
||||
devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx)
|
||||
|
||||
c, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"token": tok, "id": "dev1", "multiWatch": true, "req": "1",
|
||||
})
|
||||
wh.handleConnect(c, raw)
|
||||
|
||||
start := findAuditEvent(t, auditPath, auditScreenStart)
|
||||
if start["device_id"] != "dev1" || start["mode"] != "started" {
|
||||
t.Errorf("screen start = %+v", start)
|
||||
}
|
||||
|
||||
wh.OnDeviceOffline("dev1")
|
||||
|
||||
stop := findAuditEvent(t, auditPath, auditScreenStop)
|
||||
if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" {
|
||||
t.Errorf("screen stop = %+v", stop)
|
||||
}
|
||||
if _, ok := stop["duration_ms"]; !ok {
|
||||
t.Error("screen stop has no duration_ms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommandRecordedOnce(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
c, _ := auditTestClient(wh, "viewer")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wh.dispatch(c, "definitely_not_a_command", []byte(`{}`))
|
||||
}
|
||||
count := 0
|
||||
for _, m := range readAuditEvents(t, auditPath) {
|
||||
if m["event"] == auditUnknownCmd {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("unknown-command audit records = %d, want 1", count)
|
||||
}
|
||||
if c.statUnknownCmd != 5 {
|
||||
t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
username, role, ok := s.sso.Detect(r)
|
||||
if !ok {
|
||||
// No usable portal credential: the normal fallback to the login form.
|
||||
// Not audited — portal users without YAMA access hit this routinely.
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
// Only the "admin" role may skip the login form via SSO. Every other role
|
||||
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
||||
// a valid portal session.
|
||||
// a valid portal session. A valid portal identity being refused here is
|
||||
// worth an audit record: it is an authorization decision, not a login-form
|
||||
// fallback.
|
||||
if role != "admin" {
|
||||
fields := requestFields(r, s.trustForwardedFor)
|
||||
fields["method"] = "sso"
|
||||
fields["actor"] = username
|
||||
fields["role"] = role
|
||||
fields["reason"] = "role_not_allowed"
|
||||
s.log.AuditWarn(auditLoginFail, fields)
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.auth.IssueToken(username, role)
|
||||
if err != nil {
|
||||
fields := requestFields(r, s.trustForwardedFor)
|
||||
fields["method"] = "sso"
|
||||
fields["actor"] = username
|
||||
fields["role"] = role
|
||||
fields["reason"] = "token_issue_failed"
|
||||
fields["error"] = err.Error()
|
||||
s.log.AuditWarn(auditLoginFail, fields)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("sso: user=%s role=%s", username, role)
|
||||
fields := requestFields(r, s.trustForwardedFor)
|
||||
fields["method"] = "sso"
|
||||
fields["actor"] = username
|
||||
fields["role"] = role
|
||||
s.log.Audit(auditLoginOK, fields)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
|
||||
+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)
|
||||
|
||||
+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