feat: dns block lists

This commit is contained in:
zhom
2026-03-31 14:21:31 +04:00
parent cb8093fbde
commit 35723de96a
39 changed files with 1880 additions and 579 deletions
+1
View File
@@ -611,6 +611,7 @@ async fn create_profile(
wayfern_config,
request.group_id.clone(),
false,
None,
)
.await
{
+5
View File
@@ -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]
+1
View File
@@ -699,6 +699,7 @@ mod tests {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
}
}
+15 -1
View File
@@ -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
+1
View File
@@ -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);
+30
View File
@@ -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
{
+343
View File
@@ -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));
}
}
+1
View File
@@ -277,6 +277,7 @@ mod tests {
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
}
}
+20 -3
View File
@@ -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")
+89
View File
@@ -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 {
+44
View File
@@ -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
}
+2
View File
@@ -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 {
+3
View File
@@ -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)?;
+17
View File
@@ -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);
}
+4 -2
View File
@@ -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
+217 -7
View File
@@ -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"));
}
}
+8
View File
@@ -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 {