mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05ab23404 | |||
| 8511535d69 | |||
| 29dd5abb34 | |||
| b2d1456aa9 | |||
| e3fc715cfa | |||
| 2cf9013d28 | |||
| 76dd0d84e8 |
@@ -273,10 +273,10 @@ jobs:
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
mkdir -p /tmp/repo
|
||||
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
|
||||
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
|
||||
aws s3 cp "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
|
||||
aws s3 cp "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
|
||||
|
||||
- name: Generate repository with repogen
|
||||
run: |
|
||||
@@ -299,14 +299,14 @@ jobs:
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 cp /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
|
||||
- name: Verify upload
|
||||
env:
|
||||
@@ -317,6 +317,6 @@ jobs:
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "DEB repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
|
||||
echo "RPM repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
# Prevent pushing the 'nightly' tag — it is managed by CI
|
||||
if git rev-parse nightly >/dev/null 2>&1; then
|
||||
LOCAL_NIGHTLY=$(git rev-parse nightly)
|
||||
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
|
||||
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
|
||||
# Delete the local nightly tag so --tags won't try to push it
|
||||
git tag -d nightly >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
|
||||
Generated
+1
-1
@@ -1717,7 +1717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -308,12 +308,40 @@ impl Downloader {
|
||||
.resolve_download_url(browser_type.clone(), version, download_info)
|
||||
.await?;
|
||||
|
||||
// Determine if we have a partial file to resume
|
||||
// Check existing file size — if it matches the expected size, skip download
|
||||
let mut existing_size: u64 = 0;
|
||||
if let Ok(meta) = std::fs::metadata(&file_path) {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// Do a HEAD request to get the expected file size for skip/resume decisions
|
||||
let head_response = self
|
||||
.client
|
||||
.head(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
|
||||
|
||||
// If existing file matches expected size, skip download entirely
|
||||
if existing_size > 0 {
|
||||
if let Some(expected) = expected_size {
|
||||
if existing_size == expected {
|
||||
log::info!(
|
||||
"Archive {} already exists with correct size ({} bytes), skipping download",
|
||||
file_path.display(),
|
||||
existing_size
|
||||
);
|
||||
return Ok(file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
|
||||
// Satisfiable), delete the partial file and retry once without the Range header.
|
||||
let response = {
|
||||
@@ -683,11 +711,16 @@ impl Downloader {
|
||||
// Do not remove the archive here. We keep it until verification succeeds.
|
||||
}
|
||||
Err(e) => {
|
||||
// Do not remove the archive or extracted files. Just drop the registry entry
|
||||
// so it won't be reported as downloaded.
|
||||
log::error!("Extraction failed for {browser_str} {version}: {e}");
|
||||
|
||||
// Delete the corrupt/invalid archive so a fresh download happens next time
|
||||
if download_path.exists() {
|
||||
log::info!("Deleting corrupt archive: {}", download_path.display());
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
}
|
||||
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
// Remove browser-version pair from downloading set on error
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
@@ -696,6 +729,20 @@ impl Downloader {
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Emit error stage so the UI shows a toast
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(format!("Failed to extract browser: {e}").into());
|
||||
}
|
||||
}
|
||||
|
||||
+23
-22
@@ -7,7 +7,7 @@ use crate::downloader::DownloadProgress;
|
||||
use crate::events;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs::create_dir_all;
|
||||
@@ -232,17 +232,8 @@ impl Extractor {
|
||||
&self,
|
||||
file_path: &Path,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// First check file extension for DMG files since they're common on macOS
|
||||
// and can have misleading magic numbers
|
||||
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
|
||||
if ext.to_lowercase() == "dmg" {
|
||||
return Ok("dmg".to_string());
|
||||
}
|
||||
if ext.to_lowercase() == "msi" {
|
||||
return Ok("msi".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Always check magic bytes first — the file extension may be wrong
|
||||
// (e.g. CDN serving a ZIP with .dmg extension)
|
||||
let mut file = File::open(file_path)?;
|
||||
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
|
||||
file.read_exact(&mut buffer)?;
|
||||
@@ -357,16 +348,20 @@ impl Extractor {
|
||||
.args([
|
||||
"attach",
|
||||
"-nobrowse",
|
||||
"-noverify",
|
||||
"-noautoopen",
|
||||
"-mountpoint",
|
||||
mount_point.to_str().unwrap(),
|
||||
dmg_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
|
||||
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
|
||||
|
||||
// Clean up mount point before returning error
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
@@ -382,12 +377,13 @@ impl Extractor {
|
||||
let app_entry = match app_result {
|
||||
Ok(app_path) => app_path,
|
||||
Err(e) => {
|
||||
log::info!("Failed to find .app in mount point: {e}");
|
||||
log::error!("Failed to find .app in mount point: {e}");
|
||||
|
||||
// Try to unmount before returning error
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
|
||||
return Err("No .app found after extraction".into());
|
||||
@@ -407,16 +403,18 @@ impl Extractor {
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!("Failed to copy app: {stderr}");
|
||||
log::error!("Failed to copy app: {stderr}");
|
||||
|
||||
// Unmount before returning error
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
|
||||
return Err(format!("Failed to copy app: {stderr}").into());
|
||||
@@ -427,18 +425,21 @@ impl Extractor {
|
||||
// Remove quarantine attributes
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", app_path.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
|
||||
log::info!("Removed quarantine attributes");
|
||||
|
||||
// Unmount the DMG
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["detach", mount_point.to_str().unwrap()])
|
||||
.output()?;
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
@@ -174,6 +174,13 @@ impl GeoIPDownloader {
|
||||
|
||||
let mmdb_path = Self::get_mmdb_file_path()?;
|
||||
|
||||
// Always download to a temp file first, then atomically rename.
|
||||
// This prevents corruption if the app is closed mid-download.
|
||||
let temp_path = mmdb_path.with_extension("mmdb.downloading");
|
||||
|
||||
// Remove any leftover temp file from a previous interrupted download
|
||||
let _ = fs::remove_file(&temp_path).await;
|
||||
|
||||
// Download the file
|
||||
let response = self.client.get(&download_url).send().await?;
|
||||
|
||||
@@ -189,7 +196,7 @@ impl GeoIPDownloader {
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut file = fs::File::create(&mmdb_path).await?;
|
||||
let mut file = fs::File::create(&temp_path).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -237,6 +244,10 @@ impl GeoIPDownloader {
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
// Atomically replace the old database with the new one
|
||||
fs::rename(&temp_path, &mmdb_path).await?;
|
||||
|
||||
// Write download timestamp
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
|
||||
@@ -293,9 +293,17 @@ async fn fetch_dynamic_proxy(
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<crate::browser::ProxySettings, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
let settings = crate::proxy_manager::PROXY_MANAGER
|
||||
.fetch_dynamic_proxy(&url, &format)
|
||||
.await?;
|
||||
|
||||
// Validate the proxy actually works by routing through a temporary local proxy
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity("_dynamic_test", &settings)
|
||||
.await
|
||||
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -928,19 +928,33 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by making HTTP requests through it
|
||||
// Check if a proxy is valid by routing through a temporary local donut-proxy.
|
||||
// This tests the exact same code path the browser uses, ensuring that if the
|
||||
// check passes, the browser connection will work too.
|
||||
pub async fn check_proxy_validity(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
proxy_settings: &ProxySettings,
|
||||
) -> Result<ProxyCheckResult, String> {
|
||||
let proxy_url = Self::build_proxy_url(proxy_settings);
|
||||
let upstream_url = Self::build_proxy_url(proxy_settings);
|
||||
|
||||
// Fetch public IP through the proxy using shared IP utilities
|
||||
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
|
||||
// Start a temporary local proxy that tunnels through the upstream
|
||||
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
|
||||
|
||||
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
||||
let proxy_id_clone = proxy_config.id.clone();
|
||||
|
||||
// Fetch public IP through the local proxy (same path the browser uses)
|
||||
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
|
||||
|
||||
// Stop the temporary proxy regardless of result
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
|
||||
|
||||
let ip = match ip_result {
|
||||
Ok(ip) => ip,
|
||||
Err(e) => {
|
||||
// Save failed check result
|
||||
let failed_result = ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
@@ -1054,9 +1068,10 @@ impl ProxyManager {
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.or_else(|| obj.get("protocol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("http")
|
||||
.to_string();
|
||||
.to_lowercase();
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
@@ -3437,6 +3452,22 @@ mod tests {
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "socks4");
|
||||
|
||||
// "protocol" field alias
|
||||
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_normalizes_case() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1062,14 +1062,16 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
let matcher = bypass_matcher.clone();
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
// Read first bytes to detect CONNECT requests
|
||||
// CONNECT requests need special handling for tunneling
|
||||
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
|
||||
// Wait for the stream to have readable data before attempting to read.
|
||||
// This prevents read() from returning 0 on a fresh connection before
|
||||
// the client's data arrives.
|
||||
if stream.readable().await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut peek_buffer = [0u8; 16];
|
||||
match stream.read(&mut peek_buffer).await {
|
||||
Ok(0) => {
|
||||
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
|
||||
}
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
// Check if this looks like a CONNECT request
|
||||
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -1121,3 +1121,180 @@ async fn test_no_bypass_rules_all_through_upstream(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a minimal SOCKS5 proxy that tunnels connections to the real destination.
|
||||
/// Returns (port, JoinHandle).
|
||||
async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Ok((mut client, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// SOCKS5 handshake: client sends version + methods
|
||||
let mut buf = [0u8; 256];
|
||||
let n = client.read(&mut buf).await.unwrap_or(0);
|
||||
if n < 2 || buf[0] != 0x05 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reply: version 5, no auth required
|
||||
client.write_all(&[0x05, 0x00]).await.ok();
|
||||
|
||||
// Read connect request: VER CMD RSV ATYP DST.ADDR DST.PORT
|
||||
let n = client.read(&mut buf).await.unwrap_or(0);
|
||||
if n < 7 || buf[1] != 0x01 {
|
||||
client
|
||||
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let (target_host, target_port) = match buf[3] {
|
||||
0x01 => {
|
||||
// IPv4
|
||||
if n < 10 {
|
||||
return;
|
||||
}
|
||||
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
(ip, port)
|
||||
}
|
||||
0x03 => {
|
||||
// Domain
|
||||
let domain_len = buf[4] as usize;
|
||||
if n < 5 + domain_len + 2 {
|
||||
return;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + domain_len], buf[6 + domain_len]]);
|
||||
(domain, port)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Connect to target
|
||||
let target =
|
||||
match tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Success reply
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Bidirectional relay
|
||||
let (mut cr, mut cw) = tokio::io::split(client);
|
||||
let (mut tr, mut tw) = tokio::io::split(target);
|
||||
tokio::select! {
|
||||
_ = tokio::io::copy(&mut cr, &mut tw) => {}
|
||||
_ = tokio::io::copy(&mut tr, &mut cw) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
/// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy.
|
||||
/// Starts a mock SOCKS5 server, a mock HTTP target server,
|
||||
/// then routes requests through donut-proxy -> SOCKS5 -> target.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_local_proxy_with_socks5_upstream(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
// Start a mock HTTP server as the final destination
|
||||
let (target_port, target_handle) = start_mock_http_server("SOCKS5-TARGET-RESPONSE").await;
|
||||
println!("Mock target HTTP server on port {target_port}");
|
||||
|
||||
// Start a mock SOCKS5 proxy
|
||||
let (socks_port, socks_handle) = start_mock_socks5_server().await;
|
||||
println!("Mock SOCKS5 server on port {socks_port}");
|
||||
|
||||
// Helper to start a socks5 proxy
|
||||
async fn start_socks5_proxy(
|
||||
binary_path: &std::path::PathBuf,
|
||||
socks_port: u16,
|
||||
) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let output = TestUtils::execute_command(
|
||||
binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&socks_port.to_string(),
|
||||
"--type",
|
||||
"socks5",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Proxy start failed: {stderr}").into());
|
||||
}
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let id = config["id"].as_str().unwrap().to_string();
|
||||
let port = config["localPort"].as_u64().unwrap() as u16;
|
||||
|
||||
// Wait for proxy to be fully ready by verifying it accepts and responds
|
||||
for _ in 0..20 {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Extra settle time for the accept loop to be fully initialized
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
Ok((id, port))
|
||||
}
|
||||
|
||||
// Test 1: HTTP request through donut-proxy -> SOCKS5 -> target
|
||||
let (proxy_id, local_port) = start_socks5_proxy(&binary_path, socks_port).await?;
|
||||
tracker.track_proxy(proxy_id);
|
||||
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = vec![0u8; 8192];
|
||||
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut response))
|
||||
.await
|
||||
.map_err(|_| "HTTP request through SOCKS5 timed out")?
|
||||
.map_err(|e| format!("Read error: {e}"))?;
|
||||
let response_str = String::from_utf8_lossy(&response[..n]);
|
||||
|
||||
assert!(
|
||||
response_str.contains("SOCKS5-TARGET-RESPONSE"),
|
||||
"HTTP request should be tunneled through SOCKS5 to target, got: {}",
|
||||
&response_str[..response_str.len().min(500)]
|
||||
);
|
||||
println!("SOCKS5 upstream proxy test passed");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
target_handle.abort();
|
||||
socks_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -335,6 +335,23 @@ export function useBrowserDownload() {
|
||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
} else if (progress.stage === "error") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(progress.browser);
|
||||
return next;
|
||||
});
|
||||
dismissToast(
|
||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
showErrorToast(
|
||||
`${browserName} ${progress.version}: extraction failed`,
|
||||
{
|
||||
description:
|
||||
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||
},
|
||||
);
|
||||
} else if (progress.stage === "completed") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port",
|
||||
"testUrl": "Test URL",
|
||||
"testing": "Testing...",
|
||||
"testSuccess": "Dynamic proxy resolved to {{host}}:{{port}}",
|
||||
"testFailed": "Failed to fetch proxy: {{error}}",
|
||||
"testSuccess": "Proxy working: {{host}}:{{port}}",
|
||||
"testFailed": "Proxy test failed: {{error}}",
|
||||
"fetchFailed": "Failed to fetch dynamic proxy: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Espera texto como: host:port:username:password o protocol://user:pass@host:port",
|
||||
"testUrl": "Probar URL",
|
||||
"testing": "Probando...",
|
||||
"testSuccess": "El proxy dinámico se resolvió a {{host}}:{{port}}",
|
||||
"testFailed": "Error al obtener el proxy: {{error}}",
|
||||
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
|
||||
"testFailed": "Prueba de proxy fallida: {{error}}",
|
||||
"fetchFailed": "Error al obtener el proxy dinámico: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Attend du texte comme : host:port:username:password ou protocol://user:pass@host:port",
|
||||
"testUrl": "Tester l'URL",
|
||||
"testing": "Test en cours...",
|
||||
"testSuccess": "Le proxy dynamique a été résolu en {{host}}:{{port}}",
|
||||
"testFailed": "Échec de la récupération du proxy : {{error}}",
|
||||
"testSuccess": "Proxy fonctionnel : {{host}}:{{port}}",
|
||||
"testFailed": "Échec du test de proxy : {{error}}",
|
||||
"fetchFailed": "Échec de la récupération du proxy dynamique : {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "テキスト形式: host:port:username:password または protocol://user:pass@host:port",
|
||||
"testUrl": "URLをテスト",
|
||||
"testing": "テスト中...",
|
||||
"testSuccess": "ダイナミックプロキシは {{host}}:{{port}} に解決されました",
|
||||
"testFailed": "プロキシの取得に失敗しました: {{error}}",
|
||||
"testSuccess": "プロキシ動作中: {{host}}:{{port}}",
|
||||
"testFailed": "プロキシテスト失敗: {{error}}",
|
||||
"fetchFailed": "ダイナミックプロキシの取得に失敗しました: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Espera texto como: host:port:username:password ou protocol://user:pass@host:port",
|
||||
"testUrl": "Testar URL",
|
||||
"testing": "Testando...",
|
||||
"testSuccess": "O proxy dinâmico foi resolvido para {{host}}:{{port}}",
|
||||
"testFailed": "Falha ao obter o proxy: {{error}}",
|
||||
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
|
||||
"testFailed": "Falha no teste de proxy: {{error}}",
|
||||
"fetchFailed": "Falha ao obter o proxy dinâmico: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Ожидается текст вида: host:port:username:password или protocol://user:pass@host:port",
|
||||
"testUrl": "Проверить URL",
|
||||
"testing": "Проверка...",
|
||||
"testSuccess": "Динамический прокси разрешён в {{host}}:{{port}}",
|
||||
"testFailed": "Не удалось получить прокси: {{error}}",
|
||||
"testSuccess": "Прокси работает: {{host}}:{{port}}",
|
||||
"testFailed": "Тест прокси не пройден: {{error}}",
|
||||
"fetchFailed": "Не удалось получить динамический прокси: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "期望文本格式: host:port:username:password 或 protocol://user:pass@host:port",
|
||||
"testUrl": "测试URL",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "动态代理已解析为 {{host}}:{{port}}",
|
||||
"testFailed": "获取代理失败: {{error}}",
|
||||
"testSuccess": "代理正常运行: {{host}}:{{port}}",
|
||||
"testFailed": "代理测试失败: {{error}}",
|
||||
"fetchFailed": "获取动态代理失败: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
|
||||
Reference in New Issue
Block a user