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:
yuanyuanxianganddeepseek-flash committed 2026-10-18 18:44:17 +02:00
1 parent b1ddec1b8a
commit a522424491
12 files changed
+2272 -88

No files matched your search

+369 -15
View File
@@ -374,6 +374,107 @@
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
/* ===== Audit Log page ===== */
.icon-btn.audit { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); display: none; }
.icon-btn.audit.visible { display: inline-flex; }
.icon-btn.audit:hover { box-shadow: 0 4px 12px rgba(230, 126, 34, 0.4); }
#audit-page { max-width: 1500px; margin: 0 auto; }
.audit-title-wrap { display: flex; align-items: center; gap: 12px; }
.audit-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px 14px;
background: rgba(22, 33, 62, 0.8);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.audit-filters label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: #9aa4b2;
}
.audit-filters input[type="text"],
.audit-filters input[type="datetime-local"],
.audit-filters select {
padding: 8px 10px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(15, 52, 96, 0.7);
color: #fff;
font-size: 13px;
outline: none;
min-width: 0;
}
.audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; }
.audit-filters label.audit-warn {
flex-direction: row;
align-items: center;
padding-top: 18px;
}
.audit-filters label.audit-warn input { width: 15px; height: 15px; margin-right: 6px; }
.audit-actions { display: flex; gap: 10px; align-items: flex-end; }
.audit-actions button {
padding: 9px 18px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.audit-search-btn { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); color: #fff; }
.audit-search-btn:hover { transform: translateY(-1px); }
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
.audit-reset-btn:hover { background: rgba(255,255,255,0.15); }
.audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; }
.audit-status.warn { color: #e67e22; }
.audit-table-wrap {
overflow-x: auto;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
background: rgba(22, 33, 62, 0.8);
}
.audit-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.audit-table th {
text-align: left;
padding: 10px 12px;
color: #9aa4b2;
font-weight: 600;
white-space: nowrap;
border-bottom: 1px solid rgba(255,255,255,0.1);
background: rgba(15, 52, 96, 0.5);
}
.audit-table td {
padding: 9px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
color: #d5dbe3;
vertical-align: top;
white-space: nowrap;
}
.audit-table tr:last-child td { border-bottom: none; }
.audit-table tr:hover td { background: rgba(230, 126, 34, 0.06); }
.audit-table td.audit-details {
white-space: normal;
word-break: break-all;
color: #8f9aa8;
min-width: 220px;
font-family: ui-monospace, Consolas, monospace;
font-size: 11.5px;
}
.audit-level { padding: 2px 8px; border-radius: 10px; font-size: 11px; }
.audit-level.info { background: rgba(76, 175, 80, 0.15); color: #4caf50; }
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
.audit-empty { padding: 40px; text-align: center; color: #666; }
@media (max-width: 640px) {
.audit-filters { grid-template-columns: 1fr 1fr; }
.audit-actions { grid-column: 1 / -1; }
.audit-table td, .audit-table th { padding: 8px; }
}
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
.confirm-modal-content {
@@ -1517,6 +1618,14 @@
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="icon-btn audit" id="audit-btn" onclick="openAuditPage()" title="Audit Log" aria-label="Audit Log">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="16" y2="17"/>
</svg>
</button>
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
@@ -1670,6 +1779,72 @@
</div>
</div>
<!-- Audit Log Page (admin only) -->
<div id="audit-page" class="page">
<div class="page-header">
<div class="audit-title-wrap">
<button class="back-btn" onclick="showPage('devices-page')" title="Back to devices">&#8592;</button>
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
</div>
<div class="toolbar">
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
</div>
</div>
<div class="audit-filters">
<label>From
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>To
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Event (exact or prefix)
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<datalist id="audit-event-options"></datalist>
<label>Actor
<input type="text" id="audit-actor" placeholder="username" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>IP
<input type="text" id="audit-ip" placeholder="203.0.113.1" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Device ID
<input type="text" id="audit-device" placeholder="device id" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Keyword
<input type="text" id="audit-keyword" placeholder="reason, target, ..." onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Limit
<select id="audit-limit">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</label>
<label class="audit-warn">
<input type="checkbox" id="audit-warn-only"> Warnings only
</label>
<div class="audit-actions">
<button class="audit-search-btn" onclick="queryAudit(0)">Search</button>
<button class="audit-reset-btn" onclick="resetAuditFilters()">Reset</button>
</div>
</div>
<div id="audit-status" class="audit-status"></div>
<div class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th>Time</th><th>Level</th><th>Event</th><th>Actor</th>
<th>Role</th><th>IP</th><th>Device</th><th>Details</th>
</tr>
</thead>
<tbody id="audit-rows"></tbody>
</table>
</div>
<div id="audit-pagination" class="pagination"></div>
</div>
<!-- User Management Modal -->
<div class="modal-overlay" id="users-modal">
<div class="modal-content">
@@ -1781,7 +1956,7 @@
};
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false };
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false, auditLog: false };
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
@@ -2207,10 +2382,12 @@
serverCaps = {
multiWatch: !!(msg.caps && msg.caps.multiWatch),
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest),
offlineDevices: !!(msg.caps && msg.caps.offlineDevices)
offlineDevices: !!(msg.caps && msg.caps.offlineDevices),
auditLog: !!(msg.caps && msg.caps.auditLog)
};
updateWallAvailability();
applyOfflineCapability();
updateAdminButtons();
console.log('Received challenge nonce; caps=', serverCaps);
break;
case 'salt':
@@ -2228,24 +2405,25 @@
localStorage.setItem('token', token);
localStorage.setItem('role', currentUserRole);
document.getElementById('login-error').textContent = '';
// Show Users button for admin only
const usersBtn = document.getElementById('users-btn');
if (currentUserRole === 'admin') {
usersBtn.classList.add('visible');
} else {
usersBtn.classList.remove('visible');
}
// Show Users / Audit Log buttons for admin only
updateAdminButtons();
showPage('devices-page');
getDevices();
} else {
document.getElementById('login-error').textContent = msg.msg || 'Login failed';
}
break;
case 'audit_result':
handleAuditResult(msg);
break;
case 'device_list':
if (msg.ok === false) {
// Token invalid (e.g., server restarted), go back to login
token = null;
localStorage.removeItem('token');
localStorage.removeItem('role');
currentUserRole = 'viewer';
updateAdminButtons();
showPage('login-page');
break;
}
@@ -4202,11 +4380,13 @@
challengeNonce = '';
connectWebSocket(); // Reconnect immediately
token = null;
currentUserRole = 'viewer';
localStorage.removeItem('token');
localStorage.removeItem('role');
devices = [];
showPage('login-page');
// Hide users button
document.getElementById('users-btn').classList.remove('visible');
// Hide admin-only buttons
updateAdminButtons();
}
// ===== 删除设备 =====
@@ -4331,6 +4511,181 @@
}
}
// ===== Audit Log (admin only) =====
// Server-side handler: query_audit -> audit_result (see web/audit_query.go).
// Records are newest-first and already redacted at write time.
const AUDIT_EVENTS = [
'web.ws.connect', 'web.ws.reject', 'web.ws.close',
'web.login.success', 'web.login.failure', 'web.login.throttled',
'web.session.resume', 'web.auth.invalid_token', 'web.authz.denied',
'web.command.unknown', 'web.salt.unknown_user',
'remote.screen.start', 'remote.screen.stop', 'remote.screen.denied',
'remote.terminal.start', 'remote.terminal.stop', 'remote.terminal.denied',
'remote.clipboard.set', 'remote.clipboard.get', 'remote.rdp_reset',
'admin.user.create', 'admin.user.delete', 'admin.device.delete', 'admin.audit.query',
'device.online', 'device.offline',
'device.auth.success', 'device.auth.failure', 'device.auth.throttled',
'device.auth.heartbeat.success',
'device.connauth.rejected', 'device.subconn.foreign_ip'
];
let auditState = { offset: 0, limit: 100, total: 0, loaded: false };
// Show the admin-only toolbar buttons (Users / Audit Log). Called after
// login, SSO restore, logout and capability negotiation. The audit
// button also requires the server capability: the C++ server does not
// implement query_audit.
function updateAdminButtons() {
const admin = currentUserRole === 'admin';
const usersBtn = document.getElementById('users-btn');
if (usersBtn) usersBtn.classList.toggle('visible', admin);
const auditBtn = document.getElementById('audit-btn');
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
}
function openAuditPage() {
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
fillAuditEventOptions();
showPage('audit-page');
queryAudit(0);
}
function fillAuditEventOptions() {
const dl = document.getElementById('audit-event-options');
if (!dl || dl.children.length > 0) return;
dl.innerHTML = AUDIT_EVENTS.map(e => '<option value="' + e + '"></option>').join('');
}
// datetime-local values are in the browser's local zone; the server
// compares against RFC3339 audit timestamps, so convert explicitly.
function isoFromLocalInput(id) {
const v = document.getElementById(id).value;
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
function queryAudit(offset) {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
if (currentUserRole !== 'admin') return;
const payload = {
cmd: 'query_audit',
token,
limit: parseInt(document.getElementById('audit-limit').value, 10) || 100,
offset: Math.max(0, offset | 0),
event: document.getElementById('audit-event').value.trim(),
actor: document.getElementById('audit-actor').value.trim(),
ip: document.getElementById('audit-ip').value.trim(),
device_id: document.getElementById('audit-device').value.trim(),
keyword: document.getElementById('audit-keyword').value.trim(),
warn_only: document.getElementById('audit-warn-only').checked
};
const from = isoFromLocalInput('audit-from');
if (from) payload.from = from;
const to = isoFromLocalInput('audit-to');
if (to) payload.to = to;
setAuditStatus('Querying...', false);
ws.send(JSON.stringify(payload));
}
function resetAuditFilters() {
['audit-from', 'audit-to', 'audit-event', 'audit-actor', 'audit-ip', 'audit-device', 'audit-keyword']
.forEach(id => { document.getElementById(id).value = ''; });
document.getElementById('audit-warn-only').checked = false;
queryAudit(0);
}
function setAuditStatus(text, warn) {
const el = document.getElementById('audit-status');
el.textContent = text || '';
el.className = 'audit-status' + (warn ? ' warn' : '');
}
function handleAuditResult(msg) {
if (!msg.ok) {
setAuditStatus(msg.msg || 'Query failed', true);
return;
}
auditState.total = msg.total || 0;
auditState.offset = msg.offset || 0;
auditState.limit = msg.limit || auditState.limit;
auditState.loaded = true;
renderAuditRows(msg.events || []);
renderAuditPagination();
const parts = [];
if (!msg.file_exists) {
parts.push('No audit file yet — no security events have been recorded.');
} else {
parts.push(auditState.total + ' matching record(s)');
const shown = (msg.events || []).length;
if (shown !== auditState.total) parts.push('showing ' + shown);
if (msg.file) parts.push('file: ' + msg.file);
}
if (msg.truncated) {
parts.push('only the newest ' +
Math.round((msg.scanned_bytes || 0) / 1048576) + ' MB of the file was scanned');
}
setAuditStatus(parts.join(' · '), !!msg.truncated);
}
function formatAuditTime(s) {
if (!s) return '';
const d = new Date(s);
return isNaN(d.getTime()) ? escapeHtml(s) : d.toLocaleString();
}
const AUDIT_STD_FIELDS = new Set(['time', 'level', 'event', 'actor', 'role', 'ip', 'device_id', 'audit', 'message', 'module']);
function formatAuditDetails(rec) {
return Object.keys(rec)
.filter(k => !AUDIT_STD_FIELDS.has(k))
.sort()
.map(k => {
let v = rec[k];
if (v !== null && typeof v === 'object') v = JSON.stringify(v);
return escapeHtml(k) + '=' + escapeHtml(String(v));
})
.join(' ');
}
function renderAuditRows(events) {
document.getElementById('audit-count').textContent = auditState.total;
const tbody = document.getElementById('audit-rows');
if (!events.length) {
tbody.innerHTML = '<tr><td colspan="8" class="audit-empty">No audit records match the filters</td></tr>';
return;
}
tbody.innerHTML = events.map(rec => {
const level = rec.level === 'warn' ? 'warn' : 'info';
return '<tr>' +
'<td title="' + escapeHtml(rec.time || '') + '">' + formatAuditTime(rec.time) + '</td>' +
'<td><span class="audit-level ' + level + '">' + level + '</span></td>' +
'<td>' + escapeHtml(rec.event || '') + '</td>' +
'<td>' + escapeHtml(rec.actor || '') + '</td>' +
'<td>' + escapeHtml(rec.role || '') + '</td>' +
'<td>' + escapeHtml(rec.ip || '') + '</td>' +
'<td>' + escapeHtml(rec.device_id || '') + '</td>' +
'<td class="audit-details">' + formatAuditDetails(rec) + '</td>' +
'</tr>';
}).join('');
}
function renderAuditPagination() {
const container = document.getElementById('audit-pagination');
if (auditState.total <= auditState.limit) {
container.innerHTML = '';
return;
}
const page = Math.floor(auditState.offset / auditState.limit) + 1;
const pages = Math.ceil(auditState.total / auditState.limit);
const prev = Math.max(0, auditState.offset - auditState.limit);
const next = auditState.offset + auditState.limit;
container.innerHTML =
'<button onclick="queryAudit(' + prev + ')" ' + (page <= 1 ? 'disabled' : '') + '>Prev</button>' +
'<span class="page-info">Page ' + page + ' of ' + pages + '</span>' +
'<button onclick="queryAudit(' + next + ')" ' + (page >= pages ? 'disabled' : '') + '>Next</button>';
}
function renderUsersList(users) {
const container = document.getElementById('users-list');
if (!users || users.length === 0) {
@@ -6400,10 +6755,9 @@
// (not logged in, or SSO disabled) fall back to the restored
// session or the built-in login form.
trySSO().then(() => {
// Show Users button for admin only (final role known after SSO).
if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible');
}
// Show admin-only buttons once the final role is known (SSO or local login).
if (!token) currentUserRole = 'viewer';
updateAdminButtons();
connectWebSocket();
});
};