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
+79
-17
@@ -269,7 +269,12 @@ func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) {
|
||||
if err := h.srv.Send(ctx, ack); err != nil {
|
||||
h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
h.log.Warn("ConnAuth rejected conn=%d ip=%s status=%d", ctx.ID, ctx.GetPeerIP(), status)
|
||||
h.log.AuditWarn("device.connauth.rejected", map[string]any{
|
||||
"conn": ctx.ID,
|
||||
"ip": ctx.GetPeerIP(),
|
||||
"status": status,
|
||||
"device_id": ctx.GetInfo().ClientID,
|
||||
})
|
||||
_ = ctx.Close()
|
||||
}
|
||||
|
||||
@@ -307,8 +312,13 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
|
||||
// closed term_close already), or a hijack attempt from a source IP
|
||||
// other than the device's login conn. Log the latter distinctly.
|
||||
if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
||||
h.log.Warn("terminal sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s",
|
||||
devID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
|
||||
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
|
||||
"kind": "terminal",
|
||||
"device_id": devID,
|
||||
"conn": ctx.ID,
|
||||
"ip": ctx.GetPeerIP(),
|
||||
"want_ip": mc.GetPeerIP(),
|
||||
})
|
||||
} else {
|
||||
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
||||
devID, ctx.ID, isPTY)
|
||||
@@ -350,8 +360,13 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
|
||||
// MasterID from a different source IP than the device's login conn.
|
||||
// The latter is a security signal worth surfacing distinctly.
|
||||
if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
||||
h.log.Warn("screen sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s",
|
||||
deviceID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
|
||||
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
|
||||
"kind": "screen",
|
||||
"device_id": deviceID,
|
||||
"conn": ctx.ID,
|
||||
"ip": ctx.GetPeerIP(),
|
||||
"want_ip": mc.GetPeerIP(),
|
||||
})
|
||||
} else {
|
||||
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
||||
}
|
||||
@@ -614,7 +629,11 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
||||
// definitive answer instead of hanging, while a brute-forcer is capped.
|
||||
ip := ctx.GetPeerIP()
|
||||
if !h.loginLimit.Allow(ip) {
|
||||
h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
|
||||
h.log.AuditWarn("device.auth.throttled", map[string]any{
|
||||
"conn": ctx.ID,
|
||||
"ip": ip,
|
||||
"reason": "rate_limited",
|
||||
})
|
||||
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
||||
if err := h.srv.Send(ctx, resp); err != nil {
|
||||
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
||||
@@ -630,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
||||
authType = "V2"
|
||||
}
|
||||
|
||||
// Device auth records are audit events (not just debug logs): a flood of
|
||||
// failures against one address is how passcode guessing looks. The
|
||||
// submitted passcode is NEVER logged — only its presence and length.
|
||||
fields := map[string]any{
|
||||
"conn": ctx.ID,
|
||||
"ip": ip,
|
||||
"auth_version": authType,
|
||||
"device_id": info.ClientID,
|
||||
"computer": info.ComputerName,
|
||||
"sn": result.SN,
|
||||
"passcode_len": len(result.Passcode),
|
||||
"has_passcode": result.Passcode != "",
|
||||
}
|
||||
if result.Valid {
|
||||
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
||||
h.log.Audit("device.auth.success", fields)
|
||||
} else {
|
||||
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
||||
h.log.AuditWarn("device.auth.failure", fields)
|
||||
}
|
||||
|
||||
// Build and send response
|
||||
@@ -707,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
||||
if !ctx.IsAuthorized.Load() {
|
||||
ctx.IsAuthorized.Store(true)
|
||||
info := ctx.GetInfo()
|
||||
version := "V1"
|
||||
if authResult.IsV2 {
|
||||
// V2 authorization
|
||||
h.log.Info("Heartbeat auth V2 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode)
|
||||
} else {
|
||||
// V1 authorization
|
||||
h.log.Info("Heartbeat auth V1 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s pwdHmac=%d",
|
||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode, authResult.PwdHmac)
|
||||
version = "V2"
|
||||
}
|
||||
// Same redaction rule as TOKEN_AUTH: never log the passcode
|
||||
// or the password HMAC, only that they were supplied.
|
||||
h.log.Audit("device.auth.heartbeat.success", map[string]any{
|
||||
"conn": ctx.ID,
|
||||
"ip": ctx.GetPeerIP(),
|
||||
"auth_version": version,
|
||||
"device_id": info.ClientID,
|
||||
"computer": info.ComputerName,
|
||||
"sn": authResult.SN,
|
||||
"passcode_len": len(authResult.Passcode),
|
||||
"has_pwd_hmac": authResult.PwdHmac != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -902,8 +939,30 @@ func main() {
|
||||
logCfg.MaxAge = 30 // 30 days
|
||||
logCfg.Compress = true
|
||||
|
||||
// Security audit trail: a separate JSON-lines file so login attempts,
|
||||
// authz denials, remote-control sessions and admin mutations are not
|
||||
// buried in debug output and can be retained / shipped independently.
|
||||
// YAMA_AUDIT_LOG overrides the path; "off"/"none"/"0"/"-" disables the
|
||||
// separate file (records still reach server.log).
|
||||
auditFile := strings.TrimSpace(os.Getenv("YAMA_AUDIT_LOG"))
|
||||
if auditFile == "" {
|
||||
auditFile = "logs/audit.log"
|
||||
}
|
||||
switch strings.ToLower(auditFile) {
|
||||
case "off", "none", "0", "-":
|
||||
auditFile = ""
|
||||
}
|
||||
logCfg.AuditFile = auditFile
|
||||
logCfg.AuditMaxSize = 100
|
||||
logCfg.AuditMaxBackups = 20
|
||||
logCfg.AuditMaxAge = 90
|
||||
logCfg.AuditCompress = true
|
||||
|
||||
log := logger.New(logCfg)
|
||||
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
||||
if auditFile != "" {
|
||||
log.Info("Security audit log: %s", auditFile)
|
||||
}
|
||||
|
||||
// Track env vars where we fell back to a built-in default. Printed once
|
||||
// at the end of startup so the operator sees what's in effect — vars the
|
||||
@@ -1270,4 +1329,7 @@ func main() {
|
||||
log.Warn("device history flush: %v", err)
|
||||
}
|
||||
log.Info("Server stopped")
|
||||
// Flush and release the rotated log handles. Without this the last audit
|
||||
// records can sit in lumberjack's buffered writer on shutdown.
|
||||
_ = log.Close()
|
||||
}
|
||||
Reference in new issue
Block a user