feat: 添加新插件支持及多项功能改进
- 新增磁盘加密、打印审计和剪贴板管控插件支持 - 优化水印插件显示效果,支持中文及更多Unicode字符 - 改进硬件资产收集逻辑,更准确获取磁盘和显卡信息 - 增强API错误处理,添加详细日志记录 - 完善前端界面,新增插件管理页面 - 修复多个UI问题,优化页面过渡效果 - 添加环境变量覆盖配置功能 - 实现插件状态管理API - 更新文档和变更日志 - 添加安装程序脚本支持
This commit is contained in:
212
crates/client/src/clipboard_control/mod.rs
Normal file
212
crates/client/src/clipboard_control/mod.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{info, warn};
|
||||
use csm_protocol::{Frame, MessageType, ClipboardRule, ClipboardViolationPayload};
|
||||
|
||||
/// Clipboard control configuration pushed from server
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClipboardControlConfig {
|
||||
pub enabled: bool,
|
||||
pub rules: Vec<ClipboardRule>,
|
||||
}
|
||||
|
||||
/// Start the clipboard control plugin.
|
||||
/// Periodically checks clipboard content against rules and reports violations.
|
||||
pub async fn start(
|
||||
mut config_rx: watch::Receiver<ClipboardControlConfig>,
|
||||
data_tx: tokio::sync::mpsc::Sender<Frame>,
|
||||
device_uid: String,
|
||||
) {
|
||||
info!("Clipboard control plugin started");
|
||||
let mut config = ClipboardControlConfig::default();
|
||||
let mut check_interval = tokio::time::interval(Duration::from_secs(2));
|
||||
check_interval.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = config_rx.changed() => {
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
let new_config = config_rx.borrow_and_update().clone();
|
||||
info!(
|
||||
"Clipboard control config updated: enabled={}, rules={}",
|
||||
new_config.enabled,
|
||||
new_config.rules.len()
|
||||
);
|
||||
config = new_config;
|
||||
}
|
||||
_ = check_interval.tick() => {
|
||||
if !config.enabled || config.rules.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let uid = device_uid.clone();
|
||||
let rules = config.rules.clone();
|
||||
let result = tokio::task::spawn_blocking(move || check_clipboard(&uid, &rules)).await;
|
||||
match result {
|
||||
Ok(Some(payload)) => {
|
||||
if let Ok(frame) = Frame::new_json(MessageType::ClipboardViolation, &payload) {
|
||||
if data_tx.send(frame).await.is_err() {
|
||||
warn!("Failed to send clipboard violation: channel closed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!("Clipboard check task failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check clipboard content against rules. Returns a violation payload if a rule matched.
|
||||
fn check_clipboard(device_uid: &str, rules: &[ClipboardRule]) -> Option<ClipboardViolationPayload> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let clipboard_text = get_clipboard_text();
|
||||
let foreground_process = get_foreground_process();
|
||||
|
||||
for rule in rules {
|
||||
if rule.rule_type != "block" {
|
||||
continue;
|
||||
}
|
||||
// Check direction — only interested in "out" or "both"
|
||||
if !matches!(rule.direction.as_str(), "out" | "both") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check source process filter
|
||||
if let Some(ref src_pattern) = rule.source_process {
|
||||
if let Some(ref fg_proc) = foreground_process {
|
||||
if !pattern_match(src_pattern, fg_proc) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check content pattern
|
||||
if let Some(ref content_pattern) = rule.content_pattern {
|
||||
if let Some(ref text) = clipboard_text {
|
||||
if !content_matches(content_pattern, text) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Rule matched — generate violation (never send raw content)
|
||||
let preview = clipboard_text.as_ref().map(|t| format!("[{} chars]", t.len()));
|
||||
|
||||
// Clear clipboard to enforce block
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", "Set-Clipboard -Value ''"])
|
||||
.output();
|
||||
|
||||
info!("Clipboard blocked: rule_id={}", rule.id);
|
||||
return Some(ClipboardViolationPayload {
|
||||
device_uid: device_uid.to_string(),
|
||||
source_process: foreground_process,
|
||||
target_process: None,
|
||||
content_preview: preview,
|
||||
action_taken: "blocked".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (device_uid, rules);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_clipboard_text() -> Option<String> {
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_foreground_process() -> Option<String> {
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
r#"Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class WinAPI {
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
}
|
||||
"@
|
||||
$hwnd = [WinAPI]::GetForegroundWindow()
|
||||
$pid = 0
|
||||
[WinAPI]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
|
||||
if ($pid -gt 0) { (Get-Process -Id $pid -ErrorAction SilentlyContinue).ProcessName } else { "" }"#,
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple case-insensitive wildcard pattern matching. Supports `*` as wildcard.
|
||||
fn pattern_match(pattern: &str, text: &str) -> bool {
|
||||
let p = pattern.to_lowercase();
|
||||
let t = text.to_lowercase();
|
||||
if !p.contains('*') {
|
||||
return t.contains(&p);
|
||||
}
|
||||
let parts: Vec<&str> = p.split('*').collect();
|
||||
if parts.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut pos = 0usize;
|
||||
let mut matched_any = false;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
matched_any = true;
|
||||
if i == 0 && !parts[0].is_empty() {
|
||||
if !t.starts_with(part) {
|
||||
return false;
|
||||
}
|
||||
pos = part.len();
|
||||
} else {
|
||||
match t[pos..].find(part) {
|
||||
Some(idx) => pos += idx + part.len(),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched_any && !parts.last().map_or(true, |p| p.is_empty()) {
|
||||
return t.ends_with(parts.last().unwrap());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn content_matches(pattern: &str, text: &str) -> bool {
|
||||
text.to_lowercase().contains(&pattern.to_lowercase())
|
||||
}
|
||||
Reference in New Issue
Block a user