235 lines
8.4 KiB
Rust
235 lines
8.4 KiB
Rust
//! Audit log browser — three tabs (conn / file / alarm), each capped at the
|
|
//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in
|
|
//! a follow-up if the operator outgrows this view.
|
|
|
|
use super::shared::{fmt_unix, html_escape, require_admin};
|
|
use crate::api::admin::i18n::{t, tf1, Lang};
|
|
use crate::api::error::ApiError;
|
|
use crate::api::middleware::AuthedUser;
|
|
use crate::api::state::AppState;
|
|
use axum::extract::{Extension, Query};
|
|
use axum::response::Html;
|
|
use serde::Deserialize;
|
|
use std::fmt::Write as _;
|
|
use std::sync::Arc;
|
|
|
|
const PAGE_SIZE: i64 = 200;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TabQuery {
|
|
#[serde(default)]
|
|
pub tab: Option<String>,
|
|
}
|
|
|
|
pub async fn index(
|
|
Extension(state): Extension<Arc<AppState>>,
|
|
admin: AuthedUser,
|
|
lang: Lang,
|
|
Query(q): Query<TabQuery>,
|
|
) -> Result<Html<String>, ApiError> {
|
|
require_admin(&admin)?;
|
|
let tab = q.tab.as_deref().unwrap_or("conn");
|
|
let body = match tab {
|
|
"file" => render_file(&state, lang).await?,
|
|
"alarm" => render_alarm(&state, lang).await?,
|
|
_ => render_conn(&state, lang).await?,
|
|
};
|
|
let pill = |id: &str, label: &str| {
|
|
let active = id == tab;
|
|
let cls = if active {
|
|
"bg-slate-800 text-sky-300 border-sky-800"
|
|
} else {
|
|
"bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200"
|
|
};
|
|
format!(
|
|
r##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
|
|
id = id,
|
|
cls = cls,
|
|
label = label,
|
|
)
|
|
};
|
|
Ok(Html(format!(
|
|
r##"<div class="space-y-4">
|
|
<header class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">{heading}</h2>
|
|
<p class="text-xs text-slate-500">{latest}</p>
|
|
</header>
|
|
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
|
|
{body}
|
|
</div>"##,
|
|
heading = t(lang, "audit.heading"),
|
|
latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
|
|
pill_conn = pill("conn", t(lang, "audit.tab_conn")),
|
|
pill_file = pill("file", t(lang, "audit.tab_file")),
|
|
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
|
|
body = body,
|
|
)))
|
|
}
|
|
|
|
async fn render_conn(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
|
let rows = state
|
|
.db
|
|
.audit_conn_list(PAGE_SIZE)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
if rows.is_empty() {
|
|
return Ok(empty_table(t(lang, "audit.no_conn")));
|
|
}
|
|
let mut s = String::new();
|
|
let _ = write!(
|
|
s,
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
|
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_conn}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_ip}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_action}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_note}</th>
|
|
</tr></thead>
|
|
<tbody class="divide-y divide-slate-800">"##,
|
|
c_when = t(lang, "audit.col_when"),
|
|
c_peer = t(lang, "audit.col_peer"),
|
|
c_conn = t(lang, "audit.col_conn_session"),
|
|
c_ip = t(lang, "audit.col_ip"),
|
|
c_action = t(lang, "audit.col_action"),
|
|
c_note = t(lang, "audit.col_note"),
|
|
);
|
|
for r in &rows {
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
|
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
|
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
|
|
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
|
|
<td class="px-3 py-2 text-slate-300">{action}</td>
|
|
<td class="px-3 py-2 text-slate-400">{note}</td>
|
|
</tr>"##,
|
|
when = html_escape(&fmt_unix(r.started_at)),
|
|
peer = html_escape(&r.peer_id),
|
|
conn = r.conn_id,
|
|
sess = r.session_id,
|
|
ip = html_escape(&r.ip),
|
|
action = html_escape(&r.action),
|
|
note = html_escape(&r.note)
|
|
);
|
|
}
|
|
s.push_str("</tbody></table></div>");
|
|
Ok(s)
|
|
}
|
|
|
|
async fn render_file(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
|
let rows = state
|
|
.db
|
|
.audit_file_list(PAGE_SIZE)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
if rows.is_empty() {
|
|
return Ok(empty_table(t(lang, "audit.no_file")));
|
|
}
|
|
let mut s = String::new();
|
|
let _ = write!(
|
|
s,
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
|
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_dir}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_path}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_remote}</th>
|
|
</tr></thead>
|
|
<tbody class="divide-y divide-slate-800">"##,
|
|
c_when = t(lang, "audit.col_when"),
|
|
c_peer = t(lang, "audit.col_peer"),
|
|
c_dir = t(lang, "audit.col_direction"),
|
|
c_path = t(lang, "audit.col_path"),
|
|
c_remote = t(lang, "audit.col_remote"),
|
|
);
|
|
for r in &rows {
|
|
let dir = match r.direction {
|
|
0 => t(lang, "audit.dir_to_remote"),
|
|
1 => t(lang, "audit.dir_from_remote"),
|
|
_ => "?",
|
|
};
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
|
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
|
<td class="px-3 py-2 text-slate-400">{dir}</td>
|
|
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
|
|
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
|
|
</tr>"##,
|
|
when = html_escape(&fmt_unix(r.at)),
|
|
peer = html_escape(&r.peer_id),
|
|
dir = dir,
|
|
path = html_escape(&r.path),
|
|
remote = html_escape(&r.remote_peer)
|
|
);
|
|
}
|
|
s.push_str("</tbody></table></div>");
|
|
Ok(s)
|
|
}
|
|
|
|
async fn render_alarm(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
|
|
let rows = state
|
|
.db
|
|
.audit_alarm_list(PAGE_SIZE)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
|
if rows.is_empty() {
|
|
return Ok(empty_table(t(lang, "audit.no_alarm")));
|
|
}
|
|
let mut s = String::new();
|
|
let _ = write!(
|
|
s,
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
|
<th class="text-left font-medium px-3 py-2">{c_when}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_type}</th>
|
|
<th class="text-left font-medium px-3 py-2">{c_info}</th>
|
|
</tr></thead>
|
|
<tbody class="divide-y divide-slate-800">"##,
|
|
c_when = t(lang, "audit.col_when"),
|
|
c_peer = t(lang, "audit.col_peer"),
|
|
c_type = t(lang, "audit.col_type"),
|
|
c_info = t(lang, "audit.col_info"),
|
|
);
|
|
for r in &rows {
|
|
let typ = match r.typ {
|
|
0 => "IpWhitelist",
|
|
1 => "ExceedThirtyAttempts",
|
|
2 => "SixAttemptsWithinOneMinute",
|
|
6 => "ExceedIPv6PrefixAttempts",
|
|
n => return Ok(format!("(unknown alarm type {})", n)),
|
|
};
|
|
let _ = write!(
|
|
s,
|
|
r##"<tr>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
|
|
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
|
|
<td class="px-3 py-2 text-amber-300">{typ}</td>
|
|
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
|
|
</tr>"##,
|
|
when = html_escape(&fmt_unix(r.at)),
|
|
peer = html_escape(&r.peer_id),
|
|
typ = typ,
|
|
info = html_escape(&r.info_json)
|
|
);
|
|
}
|
|
s.push_str("</tbody></table></div>");
|
|
Ok(s)
|
|
}
|
|
|
|
fn empty_table(msg: &str) -> String {
|
|
format!(
|
|
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
|
|
html_escape(msg)
|
|
)
|
|
}
|