新增: 无人值守安全系列(H1-H3) + P5 审计日志与部署运维 + #14 异步委托
- H3 外部干预感知: run_agentic_loop 增 h3_watch_dir 参数, loop 启动取 git HEAD 基线, 每 5 轮探测, HEAD 被外部改写(切分支/reset/他话提交)即 emit_task_parked(stop_kind=external_change) + teardown 停靠; 4 调用方(发送/编辑/强制发送/审批续跑)从 allowed_dirs.persistent 排序取首目录, 修复初版 current_dir 恒空缺陷 - P5 审计日志: df-im-gateway audit.rs append-only SQLite(tunnel_audit 表, payload 4KB 截断, recent/by_request 查询), main.rs 8 打点点位(inbound/command/outbound), 写失败不阻断主链路, 17 tests passed - P5 部署运维: publish-im-gateway.ps1(musl 交叉编译→scp .new→systemd 原子切换→9191 验证→自动回滚) + systemd unit + nginx im.1216.top 反代 + 运维手册 - #14 异步委托: 受理/完成两段式 + 桌面任务卡(后台进行中徽章/轮次/切换) + 飞书双端同构, 关联缺陷 #8-#12 速清 验收: cargo check workspace EXIT 0, cargo test workspace 全绿(711+560+33+12...), vue-tsc EXIT 0
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
//! 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录(SQLite + WAL,与 SessionStore 同机库文件)
|
||||
//!
|
||||
//! 设计口径:
|
||||
//! - **append-only**:只写不改,无 UPDATE/DELETE——审计的公信力来自不可篡改。
|
||||
//! - **双向打点**:inbound(IM 用户 → 网关)与 outbound(网关/桌面端 → IM)各记一行,
|
||||
//! 同一 request_id 关联请求与响应(排障时按 id 串链)。
|
||||
//! - **失败不阻断**:审计写入失败仅 tracing::warn,绝不影响主链路(审计是增益非闸门)。
|
||||
//! - 表:tunnel_audit(id 自增 / ts 毫秒 / direction / platform / chat_id / user_id /
|
||||
//! conv_id / action / request_id / payload 截断 4KB / outcome ok|error|denied)。
|
||||
//! payload 截断防长消息撑爆库;敏感凭证不经过此层(config 层已隔离)。
|
||||
//!
|
||||
//! 查询接口(运维用):recent 按时间倒序限量;by_request 串同请求双向链。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
const MAX_PAYLOAD: usize = 4096;
|
||||
|
||||
/// 审计方向
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// IM 用户 → 网关(入站消息/审批点击/新会话)
|
||||
Inbound,
|
||||
/// 网关/桌面端 → IM(流式输出/审批卡片/通知)
|
||||
Outbound,
|
||||
/// 网关 → 桌面端(TunnelCommand 下发)
|
||||
Command,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Direction::Inbound => "inbound",
|
||||
Direction::Outbound => "outbound",
|
||||
Direction::Command => "command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuditLog {
|
||||
conn: Arc<std::sync::Mutex<Connection>>,
|
||||
}
|
||||
|
||||
/// 打点参数(字段可选,None 写空串,保持列 NOT NULL 简化查询)
|
||||
pub struct AuditEntry {
|
||||
pub direction: Direction,
|
||||
pub platform: String,
|
||||
pub chat_id: String,
|
||||
pub user_id: String,
|
||||
pub conv_id: String,
|
||||
/// 动作名:message/new_session/approval_click/approval_card/stream/completed/notice/send_cmd/approve_cmd
|
||||
pub action: String,
|
||||
/// 关联 ID:入站↔出站串链用(消息场景可用 chat+ts 派生,审批场景用 approval_id)
|
||||
pub request_id: String,
|
||||
pub payload: String,
|
||||
/// ok / error / denied(白名单拒/无 channel)
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// 打开(或创建)审计库。默认与 sessions.db 同目录独立文件(隔离写放大)。
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tunnel_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
conv_id TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON tunnel_audit(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_request ON tunnel_audit(request_id);",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
/// 内存库(测试)
|
||||
#[allow(dead_code)]
|
||||
pub fn open_in_memory() -> anyhow::Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS tunnel_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL, platform TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '',
|
||||
conv_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok');",
|
||||
)?;
|
||||
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 追加一条审计(失败仅告警不冒泡)
|
||||
pub fn record(&self, e: AuditEntry) {
|
||||
let payload = if e.payload.len() > MAX_PAYLOAD {
|
||||
format!("{}…[截断]", &e.payload[..MAX_PAYLOAD])
|
||||
} else {
|
||||
e.payload
|
||||
};
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().map_err(|err| anyhow::anyhow!("audit 锁中毒: {err}"))?;
|
||||
conn.execute(
|
||||
"INSERT INTO tunnel_audit (ts, direction, platform, chat_id, user_id, conv_id, action, request_id, payload, outcome) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
rusqlite::params![
|
||||
Self::now_ms(), e.direction.as_str(), e.platform, e.chat_id,
|
||||
e.user_id, e.conv_id, e.action, e.request_id, payload, e.outcome,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(err) = result {
|
||||
tracing::warn!("[audit] 写入失败(不影响主链路): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近 N 条(时间倒序,运维排障)
|
||||
#[allow(dead_code)]
|
||||
pub fn recent(&self, limit: usize) -> anyhow::Result<Vec<String>> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ts, direction, platform, chat_id, user_id, conv_id, action, request_id, outcome \
|
||||
FROM tunnel_audit ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit as i64], |row| {
|
||||
Ok(format!(
|
||||
"{} [{}] {} plat={} chat={} user={} conv={} req={} outcome={}",
|
||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?,
|
||||
))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// 按 request_id 串双向链(入站→命令→出站全链)
|
||||
#[allow(dead_code)]
|
||||
pub fn by_request(&self, request_id: &str) -> anyhow::Result<Vec<String>> {
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ts, direction, action, outcome FROM tunnel_audit WHERE request_id = ?1 ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([request_id], |row| {
|
||||
Ok(format!(
|
||||
"{} [{}] {} outcome={}",
|
||||
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(direction: Direction, action: &str, req: &str, outcome: &str) -> AuditEntry {
|
||||
AuditEntry {
|
||||
direction,
|
||||
platform: "feishu".into(),
|
||||
chat_id: "oc_123".into(),
|
||||
user_id: "u_1".into(),
|
||||
conv_id: "feishu:oc_123".into(),
|
||||
action: action.into(),
|
||||
request_id: req.into(),
|
||||
payload: "hello".into(),
|
||||
outcome: outcome.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_recent_roundtrip() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
log.record(entry(Direction::Inbound, "message", "req-1", "ok"));
|
||||
log.record(entry(Direction::Command, "send_cmd", "req-1", "ok"));
|
||||
log.record(entry(Direction::Outbound, "stream", "req-1", "ok"));
|
||||
log.record(entry(Direction::Inbound, "message", "req-2", "denied"));
|
||||
let rows = log.recent(10).unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
// 倒序:最新在前
|
||||
assert!(rows[0].contains("req-2"));
|
||||
assert!(rows[0].contains("denied"));
|
||||
let chain = log.by_request("req-1").unwrap();
|
||||
assert_eq!(chain.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_truncated() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
let mut e = entry(Direction::Inbound, "message", "req-x", "ok");
|
||||
e.payload = "x".repeat(10_000);
|
||||
log.record(e);
|
||||
let conn_count = |log: &AuditLog| -> i64 {
|
||||
let conn = log.conn.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM tunnel_audit", [], |r| r.get(0)).unwrap()
|
||||
};
|
||||
assert_eq!(conn_count(&log), 1);
|
||||
let conn = log.conn.lock().unwrap();
|
||||
let stored: String = conn
|
||||
.query_row("SELECT payload FROM tunnel_audit", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(stored.len() < 5_000);
|
||||
assert!(stored.ends_with("…[截断]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_never_panics_on_poisoned_lock() {
|
||||
let log = AuditLog::open_in_memory().unwrap();
|
||||
// 毒锁:持锁 panic 使 Mutex poisoned,record 应 warn 而非 panic
|
||||
let guard = log.conn.lock().unwrap();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
std::panic::panic_any("poison");
|
||||
}))
|
||||
.err();
|
||||
drop(guard);
|
||||
log.record(entry(Direction::Inbound, "message", "req-p", "ok")); // 不 panic 即通过
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//! ```
|
||||
|
||||
mod adapters;
|
||||
mod audit;
|
||||
mod commander;
|
||||
mod config;
|
||||
mod feishu;
|
||||
@@ -43,6 +44,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
.unwrap_or_else(|_| PathBuf::from("data"));
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let store = SessionStore::open(&data_dir.join("sessions.db"))?;
|
||||
// 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录,写失败不阻断主链路
|
||||
let audit = audit::AuditLog::open(&data_dir.join("audit.db"))?;
|
||||
|
||||
info_banner(&cfg);
|
||||
|
||||
@@ -153,11 +156,25 @@ async fn main() -> anyhow::Result<()> {
|
||||
let tail = throttle.finish(&conv_id);
|
||||
let full = tail.unwrap_or_default();
|
||||
let idx = platform_of(&conv_id);
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Outbound,
|
||||
platform: String::new(), chat_id: conv_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "completed".into(), request_id: String::new(),
|
||||
payload: full.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = adapters[idx]
|
||||
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
|
||||
.await;
|
||||
}
|
||||
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Outbound,
|
||||
platform: String::new(), chat_id: conv_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approval_card".into(), request_id: approval_id.clone(),
|
||||
payload: description.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let idx = platform_of(&conv_id);
|
||||
let _ = adapters[idx]
|
||||
.dispatch(OutboundAction::ApprovalCard {
|
||||
@@ -182,12 +199,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 回复适配器:按来源平台回路由
|
||||
let reply_idx = platform_of(&platform);
|
||||
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: String::new(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "denied".into(),
|
||||
});
|
||||
let _ = adapters[reply_idx]
|
||||
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
let Some(channel_id) = cfg_loop.route(&text) else {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: String::new(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "error".into(),
|
||||
});
|
||||
let _ = adapters[reply_idx]
|
||||
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
|
||||
.await;
|
||||
@@ -202,14 +233,45 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 走查 #3:新建会话时清理流式残留(上轮异常中止无
|
||||
// Completed 事件,streams entry 永驻会错覆写旧消息)
|
||||
adapters[reply_idx].reset_stream(&chat_id).await;
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv.clone(),
|
||||
action: "new_session".into(), request_id: String::new(),
|
||||
payload: String::new(), outcome: "ok".into(),
|
||||
});
|
||||
conv
|
||||
}
|
||||
};
|
||||
// 入站消息审计:request_id 用 conv+毫秒派生,出站 Completed 事件无法
|
||||
// 回带,此处以 (direction=inbound,message) 为链头,command/outbound
|
||||
// 侧用同一 conv_id 关联(recent 按时间倒序即还原会话时间线)。
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv_id.clone(),
|
||||
action: "message".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "ok".into(),
|
||||
});
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Command,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: user_id.clone(), conv_id: conv_id.clone(),
|
||||
action: "send_cmd".into(), request_id: String::new(),
|
||||
payload: text.clone(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = cmd_tx
|
||||
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
|
||||
.await;
|
||||
}
|
||||
InboundEvent::NewSession { platform, chat_id } => {
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: String::new(), conv_id: String::new(),
|
||||
action: "new_session_reset".into(), request_id: String::new(),
|
||||
payload: String::new(), outcome: "ok".into(),
|
||||
});
|
||||
let _ = store.remove(&platform, &chat_id);
|
||||
let reply_idx = platform_of(&platform);
|
||||
let _ = adapters[reply_idx]
|
||||
@@ -227,6 +289,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
.get_conv(&platform, &chat_id)
|
||||
.map(|m| m.conv_id)
|
||||
.unwrap_or_else(|| format!("{platform}:{chat_id}"));
|
||||
audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Inbound,
|
||||
platform: platform.clone(), chat_id: chat_id.clone(),
|
||||
user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approval_click".into(), request_id: approval_id.clone(),
|
||||
payload: if approved { "approved".into() } else { "rejected".into() },
|
||||
outcome: "ok".into(),
|
||||
}); audit.record(audit::AuditEntry {
|
||||
direction: audit::Direction::Command,
|
||||
platform, chat_id, user_id: String::new(), conv_id: conv_id.clone(),
|
||||
action: "approve_cmd".into(), request_id: approval_id.clone(), payload: if approved { "approved".into() } else { "rejected".into() },
|
||||
outcome: "ok".into(),
|
||||
});
|
||||
let _ = cmd_tx
|
||||
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user