新增: 无人值守安全系列(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:
lxy
2026-10-06 15:59:10 +08:00
parent 7c252cff96
commit 438d47bc9f
17 changed files with 987 additions and 12 deletions
+236
View File
@@ -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 即通过
}
}
+75
View File
@@ -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;