mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-23 09:50:58 +02:00
feat: dns block lists
This commit is contained in:
@@ -611,6 +611,7 @@ async fn create_profile(
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -104,6 +104,10 @@ pub fn extensions_dir() -> PathBuf {
|
||||
data_dir().join("extensions")
|
||||
}
|
||||
|
||||
pub fn dns_blocklist_dir() -> PathBuf {
|
||||
cache_dir().join("dns_blocklists")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
@@ -188,6 +192,7 @@ mod tests {
|
||||
assert!(proxy_workers_dir().ends_with("proxy_workers"));
|
||||
assert!(vpn_dir().ends_with("vpn"));
|
||||
assert!(extensions_dir().ends_with("extensions"));
|
||||
assert!(dns_blocklist_dir().ends_with("dns_blocklists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -699,6 +699,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,11 @@ async fn main() {
|
||||
Arg::new("bypass-rules")
|
||||
.long("bypass-rules")
|
||||
.help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("blocklist-file")
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -235,8 +240,17 @@ async fn main() {
|
||||
.get_one::<String>("bypass-rules")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
|
||||
match start_proxy_process_with_profile(
|
||||
upstream_url,
|
||||
port,
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
// Use println! here because this needs to go to stdout for parsing
|
||||
|
||||
@@ -1216,6 +1216,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
let path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
@@ -38,6 +38,26 @@ impl BrowserRunner {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Resolve the DNS blocklist level to a cached file path.
|
||||
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
|
||||
async fn resolve_blocklist_file(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(ref level_str) = profile.dns_blocklist else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if level == crate::dns_blocklist::BlocklistLevel::None {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
|
||||
Ok(Some(path.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
|
||||
@@ -168,6 +188,7 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Camoufox - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
||||
let local_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -175,6 +196,7 @@ impl BrowserRunner {
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -427,6 +449,7 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
||||
let local_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -434,6 +457,7 @@ impl BrowserRunner {
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -751,6 +775,9 @@ impl BrowserRunner {
|
||||
let profile_id_str = profile.id.to_string();
|
||||
|
||||
// Start local proxy - if this fails, DO NOT launch browser
|
||||
let blocklist_file = Self::resolve_blocklist_file(profile)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
let internal_proxy = PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -758,6 +785,7 @@ impl BrowserRunner {
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -2280,6 +2308,7 @@ pub async fn launch_browser_profile(
|
||||
|
||||
// Always start a local proxy, even if there's no upstream proxy
|
||||
// This allows for traffic monitoring and future features
|
||||
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
@@ -2287,6 +2316,7 @@ pub async fn launch_browser_profile(
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile_for_launch.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::app_dirs;
|
||||
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(43200); // 12 hours
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlocklistLevel {
|
||||
#[default]
|
||||
None,
|
||||
Light,
|
||||
Normal,
|
||||
Pro,
|
||||
ProPlus,
|
||||
Ultimate,
|
||||
}
|
||||
|
||||
impl BlocklistLevel {
|
||||
pub fn parse_level(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"light" => Some(Self::Light),
|
||||
"normal" => Some(Self::Normal),
|
||||
"pro" => Some(Self::Pro),
|
||||
"pro_plus" => Some(Self::ProPlus),
|
||||
"ultimate" => Some(Self::Ultimate),
|
||||
"none" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Light => "light",
|
||||
Self::Normal => "normal",
|
||||
Self::Pro => "pro",
|
||||
Self::ProPlus => "pro_plus",
|
||||
Self::Ultimate => "ultimate",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::Light => "Light",
|
||||
Self::Normal => "Normal",
|
||||
Self::Pro => "Pro",
|
||||
Self::ProPlus => "Pro++",
|
||||
Self::Ultimate => "Ultimate",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
}
|
||||
Self::Normal => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
||||
}
|
||||
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
||||
Self::ProPlus => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
||||
}
|
||||
Self::Ultimate => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Light => Some("light.txt"),
|
||||
Self::Normal => Some("multi.txt"),
|
||||
Self::Pro => Some("pro.txt"),
|
||||
Self::ProPlus => Some("pro.plus.txt"),
|
||||
Self::Ultimate => Some("ultimate.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_downloadable() -> &'static [BlocklistLevel] {
|
||||
&[
|
||||
Self::Light,
|
||||
Self::Normal,
|
||||
Self::Pro,
|
||||
Self::ProPlus,
|
||||
Self::Ultimate,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlocklistCacheStatus {
|
||||
pub level: String,
|
||||
pub display_name: String,
|
||||
pub entry_count: usize,
|
||||
pub file_size_bytes: u64,
|
||||
pub last_updated: Option<u64>,
|
||||
pub is_fresh: bool,
|
||||
pub is_cached: bool,
|
||||
}
|
||||
|
||||
pub struct BlocklistManager;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
}
|
||||
|
||||
impl BlocklistManager {
|
||||
pub fn instance() -> &'static BlocklistManager {
|
||||
&BLOCKLIST_MANAGER
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
app_dirs::dns_blocklist_dir()
|
||||
}
|
||||
|
||||
pub fn cached_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||
level.filename().map(|f| Self::cache_dir().join(f))
|
||||
}
|
||||
|
||||
pub fn is_cache_fresh(level: BlocklistLevel) -> bool {
|
||||
let Some(path) = Self::cached_file_path(level) else {
|
||||
return false;
|
||||
};
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::metadata(&path).and_then(|m| m.modified()) {
|
||||
Ok(modified) => SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.map(|age| age < REFRESH_INTERVAL)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
let url = level
|
||||
.url()
|
||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||
let path =
|
||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
|
||||
let response = HTTP_CLIENT
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
std::fs::write(&tmp_path, &body).map_err(|e| format!("Failed to write blocklist: {e}"))?;
|
||||
std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename blocklist: {e}"))?;
|
||||
|
||||
let entry_count = body
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
||||
.count();
|
||||
log::info!(
|
||||
"[dns-blocklist] Cached {} ({} domains)",
|
||||
level.display_name(),
|
||||
entry_count
|
||||
);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
if let Some(path) = Self::cached_file_path(level) {
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Self::fetch_blocklist(level).await
|
||||
}
|
||||
|
||||
pub async fn refresh_all_stale(&self) {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
if !Self::is_cache_fresh(level) {
|
||||
if let Err(e) = Self::fetch_blocklist(level).await {
|
||||
log::error!(
|
||||
"[dns-blocklist] Failed to refresh {}: {e}",
|
||||
level.display_name()
|
||||
);
|
||||
let _ = crate::events::emit(
|
||||
"dns-blocklist-refresh-failed",
|
||||
serde_json::json!({
|
||||
"level": level.as_str(),
|
||||
"error": e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||
Self::cached_file_path(level).filter(|p| p.exists())
|
||||
}
|
||||
|
||||
pub fn get_cache_status() -> Vec<BlocklistCacheStatus> {
|
||||
BlocklistLevel::all_downloadable()
|
||||
.iter()
|
||||
.map(|&level| {
|
||||
let path = Self::cached_file_path(level);
|
||||
let metadata = path.as_ref().and_then(|p| std::fs::metadata(p).ok());
|
||||
let is_cached = metadata.is_some();
|
||||
|
||||
let entry_count = if is_cached {
|
||||
path
|
||||
.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|content| {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let file_size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let last_updated = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
|
||||
BlocklistCacheStatus {
|
||||
level: level.as_str().to_string(),
|
||||
display_name: level.display_name().to_string(),
|
||||
entry_count,
|
||||
file_size_bytes,
|
||||
last_updated,
|
||||
is_fresh: Self::is_cache_fresh(level),
|
||||
is_cached,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager;
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_dns_blocklist_cache_status() -> Result<Vec<BlocklistCacheStatus>, String> {
|
||||
Ok(BlocklistManager::get_cache_status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_dns_blocklists() -> Result<(), String> {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
BlocklistManager::fetch_blocklist(level).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_level_roundtrip() {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
let s = level.as_str();
|
||||
let parsed = BlocklistLevel::parse_level(s);
|
||||
assert_eq!(parsed, Some(level), "Roundtrip failed for {s}");
|
||||
}
|
||||
assert_eq!(
|
||||
BlocklistLevel::parse_level("none"),
|
||||
Some(BlocklistLevel::None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_level_urls_all_present() {
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
assert!(
|
||||
level.url().is_some(),
|
||||
"{} should have a URL",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
level.filename().is_some(),
|
||||
"{} should have a filename",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
assert!(BlocklistLevel::None.url().is_none());
|
||||
assert!(BlocklistLevel::None.filename().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_status_returns_all_levels() {
|
||||
let statuses = BlocklistManager::get_cache_status();
|
||||
assert_eq!(statuses.len(), 5);
|
||||
assert_eq!(statuses[0].level, "light");
|
||||
assert_eq!(statuses[1].level, "normal");
|
||||
assert_eq!(statuses[2].level, "pro");
|
||||
assert_eq!(statuses[3].level, "pro_plus");
|
||||
assert_eq!(statuses[4].level, "ultimate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fresh_returns_false_when_missing() {
|
||||
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::Light));
|
||||
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::None));
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,7 @@ mod tests {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -19,6 +19,7 @@ mod browser_version_manager;
|
||||
pub mod camoufox;
|
||||
mod camoufox_manager;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
@@ -65,9 +66,9 @@ use browser_runner::{
|
||||
|
||||
use profile::manager::{
|
||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note,
|
||||
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||
update_wayfern_config,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_dns_blocklist,
|
||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
||||
update_profile_tags, update_profile_vpn, update_wayfern_config,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
@@ -1132,6 +1133,7 @@ async fn generate_sample_fingerprint(
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
if browser == "camoufox" {
|
||||
@@ -1462,6 +1464,17 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// DNS blocklist refresh task (every 12 hours)
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let manager = dns_blocklist::BlocklistManager::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
||||
interval.tick().await; // Skip the immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
manager.refresh_all_stale().await;
|
||||
}
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let updater = app_auto_updater::AppAutoUpdater::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
|
||||
@@ -1805,6 +1818,7 @@ pub fn run() {
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
update_profile_proxy_bypass_rules,
|
||||
update_profile_dns_blocklist,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -1952,6 +1966,9 @@ pub fn run() {
|
||||
synchronizer::stop_sync_session,
|
||||
synchronizer::remove_sync_follower,
|
||||
synchronizer::get_sync_sessions,
|
||||
// DNS blocklist commands
|
||||
dns_blocklist::get_dns_blocklist_cache_status,
|
||||
dns_blocklist::refresh_dns_blocklists,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -1009,6 +1009,36 @@ impl McpServer {
|
||||
"required": ["profile_id", "rules"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile_dns_blocklist".to_string(),
|
||||
description:
|
||||
"Update the DNS blocklist level for a profile. Blocks ads, trackers, and malware domains at the proxy level."
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to update"
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": ["none", "light", "normal", "pro", "pro_plus", "ultimate"],
|
||||
"description": "DNS blocklist level. 'none' disables blocking."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "level"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_dns_blocklist_status".to_string(),
|
||||
description: "Get the cache status of all DNS blocklist tiers including entry counts and freshness.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_extensions".to_string(),
|
||||
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
|
||||
@@ -1482,6 +1512,9 @@ impl McpServer {
|
||||
.handle_update_profile_proxy_bypass_rules(&arguments)
|
||||
.await
|
||||
}
|
||||
// DNS blocklist management
|
||||
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
|
||||
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
@@ -1806,6 +1839,7 @@ impl McpServer {
|
||||
let mut profile = ProfileManager::instance()
|
||||
.create_profile_with_group(
|
||||
app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
@@ -3119,6 +3153,61 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_update_profile_dns_blocklist(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let level = arguments
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing level".to_string(),
|
||||
})?;
|
||||
|
||||
let dns_blocklist = if level == "none" {
|
||||
None
|
||||
} else {
|
||||
Some(level.to_string())
|
||||
};
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.update_profile_dns_blocklist(profile_id, dns_blocklist)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update DNS blocklist: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"DNS blocklist updated for profile '{}': {}",
|
||||
profile.name,
|
||||
level
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_dns_blocklist_status(&self) -> Result<serde_json::Value, McpError> {
|
||||
let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status();
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&statuses).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
|
||||
@@ -50,6 +50,7 @@ impl ProfileManager {
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
@@ -158,6 +159,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -257,6 +259,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -310,6 +313,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist,
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -760,6 +764,30 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_dns_blocklist(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.dns_blocklist = dns_blocklist;
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -902,6 +930,7 @@ impl ProfileManager {
|
||||
proxy_bypass_rules: source.proxy_bypass_rules,
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: source.dns_blocklist,
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1957,6 +1986,7 @@ pub async fn create_browser_profile_with_group(
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
@@ -1972,6 +2002,7 @@ pub async fn create_browser_profile_with_group(
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral,
|
||||
dns_blocklist,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
@@ -2047,6 +2078,17 @@ pub fn update_profile_proxy_bypass_rules(
|
||||
.map_err(|e| format!("Failed to update proxy bypass rules: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_dns_blocklist(
|
||||
profile_id: String,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_dns_blocklist(&profile_id, dns_blocklist)
|
||||
.map_err(|e| format!("Failed to update DNS blocklist: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2085,6 +2127,7 @@ pub async fn create_browser_profile_new(
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: Option<bool>,
|
||||
dns_blocklist: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
@@ -2112,6 +2155,7 @@ pub async fn create_browser_profile_new(
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral.unwrap_or(false),
|
||||
dns_blocklist,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct BrowserProfile {
|
||||
pub created_by_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dns_blocklist: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -582,6 +582,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -660,6 +661,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -709,6 +711,7 @@ impl ProfileImporter {
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
};
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
@@ -77,6 +77,7 @@ pub struct ProxyInfo {
|
||||
pub local_port: u16,
|
||||
// Optional profile ID to which this proxy instance is logically tied
|
||||
pub profile_id: Option<String>,
|
||||
pub blocklist_file: Option<String>,
|
||||
}
|
||||
|
||||
// Proxy check result cache
|
||||
@@ -1675,6 +1676,7 @@ impl ProxyManager {
|
||||
browser_pid: u32,
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
if let Some(name) = profile_id {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
@@ -1802,6 +1804,11 @@ impl ProxyManager {
|
||||
proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json);
|
||||
}
|
||||
|
||||
// Add blocklist file path if provided
|
||||
if let Some(ref path) = blocklist_file {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
}
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The donut-proxy binary should start the worker and then exit
|
||||
let output = proxy_cmd
|
||||
@@ -1847,6 +1854,7 @@ impl ProxyManager {
|
||||
.unwrap_or_else(|| "DIRECT".to_string()),
|
||||
local_port,
|
||||
profile_id: profile_id.map(|s| s.to_string()),
|
||||
blocklist_file: blocklist_file.clone(),
|
||||
};
|
||||
|
||||
// Wait for the local proxy port to be ready to accept connections
|
||||
@@ -2345,6 +2353,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
profile_id: None,
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
@@ -2671,6 +2680,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: port,
|
||||
profile_id: profile_id.map(|s| s.to_string()),
|
||||
blocklist_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2898,6 +2908,7 @@ mod tests {
|
||||
pid: Some(live_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2908,6 +2919,7 @@ mod tests {
|
||||
pid: Some(dead_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2946,6 +2958,7 @@ mod tests {
|
||||
pid: Some(12345),
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3064,6 +3077,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 9201,
|
||||
profile_id: Some("profile_alpha".to_string()),
|
||||
blocklist_file: None,
|
||||
};
|
||||
let info_b = ProxyInfo {
|
||||
id: "px_shared_b".to_string(),
|
||||
@@ -3073,6 +3087,7 @@ mod tests {
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 9202,
|
||||
profile_id: Some("profile_beta".to_string()),
|
||||
blocklist_file: None,
|
||||
};
|
||||
|
||||
pm.insert_active_proxy(3001, info_a);
|
||||
@@ -3260,6 +3275,7 @@ mod tests {
|
||||
pid: Some(dead_pid),
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
@@ -3432,6 +3448,7 @@ mod tests {
|
||||
upstream_type: ptype.to_string(),
|
||||
local_port: 9300 + i as u16,
|
||||
profile_id: Some(format!("profile_{ptype}")),
|
||||
blocklist_file: None,
|
||||
};
|
||||
pm.insert_active_proxy(4000 + i as u32, info);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
@@ -20,6 +20,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
port: Option<u16>,
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -33,7 +34,8 @@ pub async fn start_proxy_process_with_profile(
|
||||
|
||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules);
|
||||
.with_bypass_rules(bypass_rules)
|
||||
.with_blocklist_file(blocklist_file);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
@@ -7,6 +7,7 @@ use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use regex_lite::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
@@ -51,6 +52,58 @@ impl BypassMatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlocklistMatcher {
|
||||
domains: Arc<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for BlocklistMatcher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlocklistMatcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
domains: Arc::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let domains: HashSet<String> = content
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
|
||||
.map(|line| line.trim().to_lowercase())
|
||||
.collect();
|
||||
log::info!("[blocklist] Loaded {} domains from {}", domains.len(), path);
|
||||
Ok(Self {
|
||||
domains: Arc::new(domains),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, host: &str) -> bool {
|
||||
if self.domains.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host_lower = host.to_lowercase();
|
||||
// Exact match
|
||||
if self.domains.contains(host_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
// Suffix matching: check parent domains (like uBlock)
|
||||
let mut start = 0;
|
||||
while let Some(dot_pos) = host_lower[start..].find('.') {
|
||||
start += dot_pos + 1;
|
||||
if self.domains.contains(&host_lower[start..]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper stream that counts bytes read and written
|
||||
struct CountingStream<S> {
|
||||
inner: S,
|
||||
@@ -167,20 +220,22 @@ async fn handle_request(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Handle CONNECT method for HTTPS tunneling
|
||||
if req.method() == Method::CONNECT {
|
||||
return handle_connect(req, upstream_url, bypass_matcher).await;
|
||||
return handle_connect(req, upstream_url, bypass_matcher, blocklist_matcher).await;
|
||||
}
|
||||
|
||||
// Handle regular HTTP requests
|
||||
handle_http(req, upstream_url, bypass_matcher).await
|
||||
handle_http(req, upstream_url, bypass_matcher, blocklist_matcher).await
|
||||
}
|
||||
|
||||
async fn handle_connect(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let authority = req.uri().authority().cloned();
|
||||
|
||||
@@ -196,6 +251,14 @@ async fn handle_connect(
|
||||
(&target_addr[..], 443)
|
||||
};
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(target_host) {
|
||||
log::debug!("[blocklist] Blocked CONNECT to {}", target_host);
|
||||
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
|
||||
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// If no upstream proxy, or bypass rule matches, connect directly
|
||||
if upstream_url.is_none()
|
||||
|| upstream_url
|
||||
@@ -711,6 +774,7 @@ async fn handle_http(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Extract domain for traffic tracking
|
||||
let domain = req
|
||||
@@ -719,6 +783,14 @@ async fn handle_http(
|
||||
.map(|h| h.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(&domain) {
|
||||
log::debug!("[blocklist] Blocked HTTP request to {}", domain);
|
||||
let mut response = Response::new(Full::new(Bytes::from("Blocked by DNS blocklist")));
|
||||
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
log::error!(
|
||||
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
|
||||
req.method(),
|
||||
@@ -888,6 +960,7 @@ pub async fn handle_proxy_connection(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
@@ -942,8 +1015,14 @@ pub async fn handle_proxy_connection(
|
||||
}
|
||||
}
|
||||
|
||||
let _ =
|
||||
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
|
||||
let _ = handle_connect_from_buffer(
|
||||
stream,
|
||||
full_request,
|
||||
upstream_url,
|
||||
bypass_matcher,
|
||||
blocklist_matcher,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -955,8 +1034,14 @@ pub async fn handle_proxy_connection(
|
||||
inner: stream,
|
||||
};
|
||||
let io = TokioIo::new(prepended_reader);
|
||||
let service =
|
||||
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
|
||||
let service = service_fn(move |req| {
|
||||
handle_request(
|
||||
req,
|
||||
upstream_url.clone(),
|
||||
bypass_matcher.clone(),
|
||||
blocklist_matcher.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
let _ = http1::Builder::new().serve_connection(io, service).await;
|
||||
}
|
||||
@@ -1128,6 +1213,17 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
});
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
|
||||
match BlocklistMatcher::from_file(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::error!("[blocklist] Failed to load from {}: {}", path, e);
|
||||
BlocklistMatcher::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BlocklistMatcher::new()
|
||||
};
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
@@ -1136,8 +1232,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
Ok((stream, _peer_addr)) => {
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
let blocker = blocklist_matcher.clone();
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher).await;
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1155,6 +1252,7 @@ async fn handle_connect_from_buffer(
|
||||
request_buffer: Vec<u8>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Parse the CONNECT request from the buffer
|
||||
let request_str = String::from_utf8_lossy(&request_buffer);
|
||||
@@ -1185,6 +1283,15 @@ async fn handle_connect_from_buffer(
|
||||
(target, 443)
|
||||
};
|
||||
|
||||
// Block if domain is in the DNS blocklist (before any connection)
|
||||
if blocklist_matcher.is_blocked(target_host) {
|
||||
log::debug!("[blocklist] Blocked CONNECT tunnel to {}", target_host);
|
||||
let _ = client_stream
|
||||
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 24\r\n\r\nBlocked by DNS blocklist")
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Record domain access in traffic tracker
|
||||
let domain = target_host.to_string();
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
@@ -1362,3 +1469,106 @@ async fn handle_connect_from_buffer(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_exact_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
domains.insert("tracker.net".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("example.com"));
|
||||
assert!(matcher.is_blocked("tracker.net"));
|
||||
assert!(!matcher.is_blocked("safe.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_subdomain_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("foo.example.com"));
|
||||
assert!(matcher.is_blocked("bar.baz.example.com"));
|
||||
assert!(matcher.is_blocked("a.b.c.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_no_false_positives() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
// "notexample.com" should NOT match "example.com"
|
||||
assert!(!matcher.is_blocked("notexample.com"));
|
||||
assert!(!matcher.is_blocked("myexample.com"));
|
||||
// But subdomain should
|
||||
assert!(matcher.is_blocked("sub.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_empty_blocks_nothing() {
|
||||
let matcher = BlocklistMatcher::new();
|
||||
assert!(!matcher.is_blocked("anything.com"));
|
||||
assert!(!matcher.is_blocked("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_case_insensitive() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
let mut domains = HashSet::new();
|
||||
domains.insert("example.com".to_string());
|
||||
matcher.domains = Arc::new(domains);
|
||||
|
||||
assert!(matcher.is_blocked("EXAMPLE.COM"));
|
||||
assert!(matcher.is_blocked("Example.Com"));
|
||||
assert!(matcher.is_blocked("FOO.EXAMPLE.COM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_from_file() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
writeln!(tmpfile, "# This is a comment").unwrap();
|
||||
writeln!(tmpfile).unwrap();
|
||||
writeln!(tmpfile, "tracker.example.com").unwrap();
|
||||
writeln!(tmpfile, "ads.network.com").unwrap();
|
||||
writeln!(tmpfile, "# Another comment").unwrap();
|
||||
writeln!(tmpfile, "malware.site").unwrap();
|
||||
tmpfile.flush().unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(matcher.is_blocked("tracker.example.com"));
|
||||
assert!(matcher.is_blocked("ads.network.com"));
|
||||
assert!(matcher.is_blocked("malware.site"));
|
||||
assert!(matcher.is_blocked("sub.malware.site"));
|
||||
assert!(!matcher.is_blocked("safe.com"));
|
||||
// Comments and empty lines should be skipped: 3 domains loaded
|
||||
assert_eq!(matcher.domains.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_comments_skipped() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
writeln!(tmpfile, "# Title: HaGeZi's Light DNS Blocklist").unwrap();
|
||||
writeln!(tmpfile, "# Description: test").unwrap();
|
||||
writeln!(tmpfile, "# Version: 2026.0330.0928.01").unwrap();
|
||||
writeln!(tmpfile).unwrap();
|
||||
writeln!(tmpfile, "domain1.com").unwrap();
|
||||
writeln!(tmpfile, "domain2.com").unwrap();
|
||||
tmpfile.flush().unwrap();
|
||||
|
||||
let matcher = BlocklistMatcher::from_file(tmpfile.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(matcher.domains.len(), 2);
|
||||
assert!(matcher.is_blocked("domain1.com"));
|
||||
assert!(matcher.is_blocked("domain2.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct ProxyConfig {
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -27,6 +29,7 @@ impl ProxyConfig {
|
||||
pid: None,
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +42,11 @@ impl ProxyConfig {
|
||||
self.bypass_rules = bypass_rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_blocklist_file(mut self, blocklist_file: Option<String>) -> Self {
|
||||
self.blocklist_file = blocklist_file;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
|
||||
Reference in New Issue
Block a user