Files
csm/crates/client/src/clipboard_control/mod.rs
iven b5333d8c93 feat: 添加新插件支持及多项功能改进
- 新增磁盘加密、打印审计和剪贴板管控插件支持
- 优化水印插件显示效果,支持中文及更多Unicode字符
- 改进硬件资产收集逻辑,更准确获取磁盘和显卡信息
- 增强API错误处理,添加详细日志记录
- 完善前端界面,新增插件管理页面
- 修复多个UI问题,优化页面过渡效果
- 添加环境变量覆盖配置功能
- 实现插件状态管理API
- 更新文档和变更日志
- 添加安装程序脚本支持
2026-04-10 22:21:05 +08:00

213 lines
7.1 KiB
Rust

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())
}