mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-21 14:10:06 +02:00
refactor: switch local proxy from http to socks
This commit is contained in:
@@ -162,6 +162,11 @@ async fn main() {
|
||||
Arg::new("blocklist-file")
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("local-protocol")
|
||||
.long("local-protocol")
|
||||
.help("Protocol served to the browser: http (default) or socks5"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -251,6 +256,7 @@ async fn main() {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(
|
||||
upstream_url,
|
||||
@@ -258,6 +264,7 @@ async fn main() {
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
local_protocol,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -261,6 +261,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated
|
||||
// Windows build) keeps the local HTTP proxy: Firefox's QUIC stack
|
||||
// bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT
|
||||
// covers everything. SOCKS5 is reserved for Wayfern.
|
||||
"http",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -527,6 +532,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
||||
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
||||
// leaking the real IP, rather than being forced direct as they
|
||||
// would be over an HTTP CONNECT proxy.
|
||||
"socks5",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -535,8 +545,9 @@ impl BrowserRunner {
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Format proxy URL for wayfern - always use HTTP for the local proxy
|
||||
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
|
||||
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||
|
||||
// Set proxy in wayfern config
|
||||
wayfern_config.proxy = Some(proxy_url);
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
|
||||
@@ -1478,6 +1478,7 @@ impl ProxyManager {
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1486,6 +1487,10 @@ impl ProxyManager {
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
// Protocol the local worker serves the browser: "http" (Camoufox) or
|
||||
// "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type
|
||||
// so the caller formats the right local proxy URL scheme.
|
||||
local_protocol: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
if let Some(name) = profile_id {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
@@ -1519,7 +1524,7 @@ impl ProxyManager {
|
||||
if proxies.contains_key(&browser_pid) {
|
||||
// Already mapped, reuse it
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1559,7 +1564,7 @@ impl ProxyManager {
|
||||
if profile_id_matches {
|
||||
// Reuse existing local proxy (settings and profile_id match)
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1618,6 +1623,9 @@ impl ProxyManager {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
}
|
||||
|
||||
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The donut-proxy binary should start the worker and then exit
|
||||
let output = proxy_cmd
|
||||
@@ -1709,7 +1717,7 @@ impl ProxyManager {
|
||||
|
||||
// Return proxy settings for the browser
|
||||
Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy_info.local_port,
|
||||
username: None,
|
||||
@@ -2885,6 +2893,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2896,6 +2905,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2935,6 +2945,7 @@ mod tests {
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3253,6 +3264,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
@@ -160,7 +160,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(), None).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
@@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
local_protocol: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -183,7 +184,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_blocklist_file(blocklist_file);
|
||||
.with_blocklist_file(blocklist_file)
|
||||
.with_local_protocol(local_protocol);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
@@ -21,9 +21,9 @@ use tokio::net::TcpStream;
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
|
||||
/// Shadowsocks through the same bidirectional-copy path.
|
||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
|
||||
type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
pub(crate) type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
use url::Url;
|
||||
|
||||
enum CompiledRule {
|
||||
@@ -1247,10 +1247,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
log::info!("Successfully bound to port {}", actual_port);
|
||||
|
||||
// Update config with actual port and local_url
|
||||
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
|
||||
let local_protocol = config.local_protocol_or_default();
|
||||
let serve_socks5 = local_protocol == "socks5";
|
||||
|
||||
// Update config with actual port and local_url (scheme matches the protocol
|
||||
// we serve, so the parent's readiness check and any consumer see the truth)
|
||||
let mut updated_config = config.clone();
|
||||
updated_config.local_port = Some(actual_port);
|
||||
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
|
||||
updated_config.local_url = Some(format!(
|
||||
"{}://127.0.0.1:{}",
|
||||
if serve_socks5 { "socks5" } else { "http" },
|
||||
actual_port
|
||||
));
|
||||
|
||||
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
||||
log::error!("Failed to update proxy config");
|
||||
@@ -1371,9 +1380,15 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
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, blocker).await;
|
||||
});
|
||||
if serve_socks5 {
|
||||
tokio::task::spawn(async move {
|
||||
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
} else {
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error accepting connection: {:?}", e);
|
||||
@@ -1444,20 +1459,51 @@ async fn handle_connect_from_buffer(
|
||||
);
|
||||
|
||||
// Connect to target (directly or via upstream proxy).
|
||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
||||
let target_stream = connect_to_target_via_upstream(
|
||||
target_host,
|
||||
target_port,
|
||||
upstream_url.as_deref(),
|
||||
&bypass_matcher,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
tunnel_streams(client_stream, target_stream, domain).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Establish a stream to `target_host:target_port`, either directly or through
|
||||
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
|
||||
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
|
||||
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
|
||||
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
|
||||
pub(crate) async fn connect_to_target_via_upstream(
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
upstream_url: Option<&str>,
|
||||
bypass_matcher: &BypassMatcher,
|
||||
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
|
||||
let should_bypass = bypass_matcher.should_bypass(target_host);
|
||||
// Helper: configure outbound TCP to match browser TCP fingerprint
|
||||
let configure_tcp = |stream: &TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
};
|
||||
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some(url) if url == "DIRECT" => {
|
||||
Some("DIRECT") => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
@@ -1616,20 +1662,18 @@ async fn handle_connect_from_buffer(
|
||||
}
|
||||
};
|
||||
|
||||
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
|
||||
// For encrypted streams (Shadowsocks), the underlying TCP connection
|
||||
// is managed by the library and nodelay is handled internally.
|
||||
Ok(target_stream)
|
||||
}
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
// Now tunnel data bidirectionally with counting
|
||||
/// Bidirectionally relay `client_stream` <-> `target_stream` until either side
|
||||
/// closes, counting bytes for traffic stats and attributing them to `domain`.
|
||||
/// The caller is responsible for having already sent any protocol-specific
|
||||
/// success reply (HTTP `200` or SOCKS5 reply) before calling this.
|
||||
pub(crate) async fn tunnel_streams(
|
||||
client_stream: TcpStream,
|
||||
target_stream: BoxedAsyncStream,
|
||||
domain: String,
|
||||
) {
|
||||
// Wrap streams to count bytes transferred
|
||||
let counting_client = CountingStream::new(client_stream);
|
||||
let counting_target = CountingStream::new(target_stream);
|
||||
@@ -1692,8 +1736,6 @@ async fn handle_connect_from_buffer(
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.update_domain_bytes(&domain, final_sent, final_recv);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct ProxyConfig {
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
/// Protocol the local worker serves to the browser: "http" (default, used
|
||||
/// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and
|
||||
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
||||
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||
#[serde(default)]
|
||||
pub local_protocol: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -30,6 +36,7 @@ impl ProxyConfig {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +54,20 @@ impl ProxyConfig {
|
||||
self.blocklist_file = blocklist_file;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||
self.local_protocol = local_protocol;
|
||||
self
|
||||
}
|
||||
|
||||
/// "socks5" or "http" (default). Lowercased for case-insensitive matching.
|
||||
pub fn local_protocol_or_default(&self) -> String {
|
||||
self
|
||||
.local_protocol
|
||||
.as_deref()
|
||||
.unwrap_or("http")
|
||||
.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Local SOCKS5 server served to the browser (Wayfern/Chromium).
|
||||
//!
|
||||
//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel
|
||||
//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the
|
||||
//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP
|
||||
//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and
|
||||
//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS
|
||||
//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically.
|
||||
//!
|
||||
//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it
|
||||
//! cannot expose the host IP — directly when there is no upstream proxy, or
|
||||
//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot
|
||||
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
||||
//! the association) the request is refused, so Chromium falls back to proxied
|
||||
//! TCP rather than sending UDP from the real IP.
|
||||
|
||||
use crate::proxy_server::{
|
||||
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||
};
|
||||
use crate::traffic_stats::get_traffic_tracker;
|
||||
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use url::Url;
|
||||
|
||||
// SOCKS5 reply codes (RFC 1928 §6).
|
||||
const REP_SUCCEEDED: u8 = 0x00;
|
||||
const REP_GENERAL_FAILURE: u8 = 0x01;
|
||||
const REP_NOT_ALLOWED: u8 = 0x02;
|
||||
const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07;
|
||||
|
||||
// SOCKS5 commands (RFC 1928 §4).
|
||||
const CMD_CONNECT: u8 = 0x01;
|
||||
const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
||||
|
||||
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
||||
const UDP_BUF: usize = 65_536;
|
||||
|
||||
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||
/// IP never leaks.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum UdpMode {
|
||||
/// No upstream proxy: relay UDP directly (the host IP is the profile's IP,
|
||||
/// so there is nothing to hide).
|
||||
Direct,
|
||||
/// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if
|
||||
/// the upstream grants it; refuses (no leak) if it does not.
|
||||
Socks5Upstream,
|
||||
/// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so
|
||||
/// Chromium falls back to proxied TCP instead of leaking UDP.
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// Decide the leak-safe UDP policy for an upstream URL.
|
||||
fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
|
||||
match upstream_url {
|
||||
None => UdpMode::Direct,
|
||||
Some("DIRECT") => UdpMode::Direct,
|
||||
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
|
||||
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
|
||||
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
|
||||
_ => UdpMode::Refuse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `0.0.0.0:0` — used for BND fields in replies where the bound address is
|
||||
/// irrelevant to the client (e.g. CONNECT).
|
||||
fn unspecified() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
|
||||
}
|
||||
|
||||
/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn
|
||||
/// contract of `proxy_server::handle_proxy_connection`.
|
||||
pub async fn handle_socks5_connection(
|
||||
mut stream: TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
if let Err(e) = negotiate_method(&mut stream).await {
|
||||
log::debug!("SOCKS5 method negotiation failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let request = match read_request(&mut stream).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::debug!("SOCKS5 request parse failed: {e}");
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match request.cmd {
|
||||
CMD_CONNECT => {
|
||||
handle_connect(
|
||||
stream,
|
||||
request.host,
|
||||
request.port,
|
||||
upstream_url,
|
||||
bypass_matcher,
|
||||
blocklist_matcher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CMD_UDP_ASSOCIATE => {
|
||||
handle_udp_associate(stream, upstream_url).await;
|
||||
}
|
||||
other => {
|
||||
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is
|
||||
/// loopback-only, so no authentication is required (Chromium offers no-auth).
|
||||
async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||
let mut head = [0u8; 2];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"not a SOCKS5 greeting",
|
||||
));
|
||||
}
|
||||
let nmethods = head[1] as usize;
|
||||
let mut methods = vec![0u8; nmethods];
|
||||
stream.read_exact(&mut methods).await?;
|
||||
|
||||
if methods.contains(&0x00) {
|
||||
stream.write_all(&[0x05, 0x00]).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
// No acceptable methods.
|
||||
let _ = stream.write_all(&[0x05, 0xFF]).await;
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"no no-auth method offered",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct Socks5Request {
|
||||
cmd: u8,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT.
|
||||
async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request> {
|
||||
let mut head = [0u8; 4];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"bad SOCKS5 request version",
|
||||
));
|
||||
}
|
||||
let cmd = head[1];
|
||||
let atyp = head[3];
|
||||
let host = read_addr(stream, atyp).await?;
|
||||
let mut port = [0u8; 2];
|
||||
stream.read_exact(&mut port).await?;
|
||||
Ok(Socks5Request {
|
||||
cmd,
|
||||
host,
|
||||
port: u16::from_be_bytes(port),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
|
||||
/// a domain name; `connect_to_target_via_upstream` handles both).
|
||||
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
|
||||
match atyp {
|
||||
0x01 => {
|
||||
let mut b = [0u8; 4];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string())
|
||||
}
|
||||
0x04 => {
|
||||
let mut b = [0u8; 16];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv6Addr::from(b).to_string())
|
||||
}
|
||||
0x03 => {
|
||||
let mut len = [0u8; 1];
|
||||
stream.read_exact(&mut len).await?;
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
stream.read_exact(&mut domain).await?;
|
||||
Ok(String::from_utf8_lossy(&domain).to_string())
|
||||
}
|
||||
other => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("unsupported SOCKS5 address type {other:#04x}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a SOCKS5 reply with the given code and bound address.
|
||||
async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> {
|
||||
let mut resp = vec![0x05, rep, 0x00];
|
||||
push_addr(&mut resp, bnd);
|
||||
stream.write_all(&resp).await
|
||||
}
|
||||
|
||||
/// Append an ATYP + address + port to a SOCKS5 message buffer.
|
||||
fn push_addr(buf: &mut Vec<u8>, addr: SocketAddr) {
|
||||
match addr.ip() {
|
||||
IpAddr::V4(v4) => {
|
||||
buf.push(0x01);
|
||||
buf.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
buf.push(0x04);
|
||||
buf.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
|
||||
/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel,
|
||||
/// reusing the same code path as the HTTP CONNECT proxy.
|
||||
async fn handle_connect(
|
||||
mut stream: TcpStream,
|
||||
host: String,
|
||||
port: u16,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
if blocklist_matcher.is_blocked(&host) {
|
||||
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
|
||||
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.record_request(&host, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||
host,
|
||||
port,
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
|
||||
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||
// error inside the match arm so it is never held across the await below.
|
||||
let target =
|
||||
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(target) = target else {
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
};
|
||||
|
||||
if send_reply(&mut stream, REP_SUCCEEDED, unspecified())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
tunnel_streams(stream, target, host).await;
|
||||
}
|
||||
|
||||
/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]).
|
||||
///
|
||||
/// `control` is the TCP control connection; the UDP association lives exactly
|
||||
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
||||
/// the browser closes it.
|
||||
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
|
||||
let mode = udp_mode(upstream_url.as_deref());
|
||||
|
||||
if mode == UdpMode::Refuse {
|
||||
log::info!(
|
||||
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// The UDP relay socket the browser sends its datagrams to. Loopback-only.
|
||||
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP relay socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let relay_addr = match relay.local_addr() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read UDP relay addr: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match mode {
|
||||
UdpMode::Direct => {
|
||||
// Bind the egress socket before replying so a failure surfaces as a
|
||||
// refusal (no half-open association).
|
||||
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP egress socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||
run_udp_relay_direct(control, relay, out).await;
|
||||
}
|
||||
UdpMode::Socks5Upstream => {
|
||||
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||
// refuse to the browser too (no leak).
|
||||
let upstream = upstream_url.as_deref().unwrap_or("");
|
||||
let datagram = match associate_upstream(upstream).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||
run_udp_relay_socks5(control, relay, datagram).await;
|
||||
}
|
||||
UdpMode::Refuse => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a SOCKS5 UDP association against the upstream proxy.
|
||||
async fn associate_upstream(
|
||||
upstream_url: &str,
|
||||
) -> Result<SocksDatagram<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let upstream = Url::parse(upstream_url)?;
|
||||
let host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let auth = if !upstream.username().is_empty() {
|
||||
Some(Auth {
|
||||
username: upstream.username().to_string(),
|
||||
password: upstream.password().unwrap_or("").to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let proxy_stream = TcpStream::connect((host, port)).await?;
|
||||
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
||||
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
||||
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
|
||||
Ok(datagram)
|
||||
}
|
||||
|
||||
/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the
|
||||
/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are
|
||||
/// rejected by the caller.
|
||||
struct UdpHeader {
|
||||
frag: u8,
|
||||
dst: AddrKind,
|
||||
data_offset: usize,
|
||||
}
|
||||
|
||||
fn parse_udp_header(buf: &[u8]) -> Option<UdpHeader> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let frag = buf[2];
|
||||
let atyp = buf[3];
|
||||
match atyp {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)),
|
||||
data_offset: 10,
|
||||
})
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&buf[4..20]);
|
||||
let ip = Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)),
|
||||
data_offset: 22,
|
||||
})
|
||||
}
|
||||
0x03 => {
|
||||
let dlen = *buf.get(4)? as usize;
|
||||
let needed = 5 + dlen + 2;
|
||||
if buf.len() < needed {
|
||||
return None;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Domain(domain, port),
|
||||
data_offset: needed,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the
|
||||
/// browser, naming `peer` as the source.
|
||||
fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
push_addr(&mut out, peer);
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
||||
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
||||
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_target = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Control connection closed => association ends.
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
}
|
||||
}
|
||||
// Browser -> target.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue; // fragmentation unsupported
|
||||
}
|
||||
let payload = &from_client[header.data_offset..n];
|
||||
let dst = match resolve_addr(&header.dst).await {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let _ = out.send_to(payload, dst).await;
|
||||
}
|
||||
// Target -> browser.
|
||||
r = out.recv_from(&mut from_target) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(peer, &from_target[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||
async fn run_udp_relay_socks5(
|
||||
mut control: TcpStream,
|
||||
relay: UdpSocket,
|
||||
datagram: SocksDatagram<TcpStream>,
|
||||
) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_upstream = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
// Browser -> upstream.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue;
|
||||
}
|
||||
let payload = from_client[header.data_offset..n].to_vec();
|
||||
let _ = datagram.send_to(&payload, header.dst).await;
|
||||
}
|
||||
// Upstream -> browser.
|
||||
r = datagram.recv_from(&mut from_upstream) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||
async fn resolve_addr(addr: &AddrKind) -> Option<SocketAddr> {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => Some(*s),
|
||||
AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}"))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut it| it.next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort conversion of an upstream-reported source address into a
|
||||
/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to
|
||||
/// `0.0.0.0:port`, which clients treat as "from the proxy".
|
||||
fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => *s,
|
||||
AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn udp_mode_direct_for_none_and_direct() {
|
||||
assert_eq!(udp_mode(None), UdpMode::Direct);
|
||||
assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_socks5_upstream() {
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://user:pass@1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_refuses_tcp_only_upstreams() {
|
||||
// HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP
|
||||
// ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak).
|
||||
assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse);
|
||||
assert_eq!(
|
||||
udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")),
|
||||
UdpMode::Refuse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_ipv4() {
|
||||
// RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi"
|
||||
let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i'];
|
||||
let h = parse_udp_header(&buf).expect("ipv4 header");
|
||||
assert_eq!(h.frag, 0);
|
||||
assert_eq!(h.data_offset, 10);
|
||||
assert_eq!(
|
||||
h.dst,
|
||||
AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443))
|
||||
);
|
||||
assert_eq!(&buf[h.data_offset..], b"hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_domain() {
|
||||
// ATYP=3, len=3, "abc", port 8080, payload "x"
|
||||
let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90];
|
||||
buf.push(b'x');
|
||||
let h = parse_udp_header(&buf).expect("domain header");
|
||||
assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080));
|
||||
assert_eq!(&buf[h.data_offset..], b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_rejects_truncated() {
|
||||
assert!(parse_udp_header(&[0, 0, 0]).is_none());
|
||||
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_udp_response_prefixes_header() {
|
||||
let resp = build_udp_response(
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||
b"data",
|
||||
);
|
||||
// RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data"
|
||||
assert_eq!(
|
||||
resp,
|
||||
vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ use boringtun::x25519::{PublicKey, StaticSecret};
|
||||
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
||||
use smoltcp::socket::udp;
|
||||
use smoltcp::time::Instant as SmolInstant;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
|
||||
use std::collections::VecDeque;
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
||||
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
||||
const SMOLTCP_UDP_BUF: usize = 65536;
|
||||
|
||||
/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint
|
||||
/// and the payload offset, or None if malformed, fragmented, or domain-typed.
|
||||
/// Only literal IPs are routed through the tunnel: resolving a domain on the
|
||||
/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs.
|
||||
fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> {
|
||||
if buf.len() < 4 || buf[2] != 0 {
|
||||
// too short, or FRAG != 0 (fragmentation unsupported)
|
||||
return None;
|
||||
}
|
||||
match buf[3] {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10))
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut o = [0u8; 16];
|
||||
o.copy_from_slice(&buf[4..20]);
|
||||
let ip = smoltcp::wire::Ipv6Address::from(o);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming
|
||||
/// `src` as the origin, for delivery back to the browser's relay socket.
|
||||
fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
match src.addr {
|
||||
IpAddress::Ipv4(v4) => {
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddress::Ipv6(v6) => {
|
||||
out.push(0x04);
|
||||
out.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&src.port.to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
struct WgDevice {
|
||||
tunn: Arc<Mutex<Box<Tunn>>>,
|
||||
@@ -432,6 +485,15 @@ impl WireGuardSocks5Server {
|
||||
|
||||
let mut sockets = SocketSet::new(vec![]);
|
||||
|
||||
// A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends
|
||||
// datagrams to, and the browser's learned source address. The tunnel-side
|
||||
// smoltcp UDP socket lives in `sockets`, keyed by the connection's
|
||||
// (repurposed) `smol_handle`.
|
||||
struct UdpAssoc {
|
||||
relay: UdpSocket,
|
||||
client_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
smol_handle: SocketHandle,
|
||||
tcp_stream: TcpStream,
|
||||
@@ -440,6 +502,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: bool,
|
||||
read_buf: Vec<u8>,
|
||||
dest_addr: Option<SocketAddr>,
|
||||
udp: Option<UdpAssoc>,
|
||||
}
|
||||
|
||||
let mut connections: Vec<Connection> = Vec::new();
|
||||
@@ -463,6 +526,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: false,
|
||||
read_buf: Vec::new(),
|
||||
dest_addr: None,
|
||||
udp: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,8 +604,17 @@ impl WireGuardSocks5Server {
|
||||
}
|
||||
|
||||
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
// SOCKS5 connect request
|
||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
||||
// SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03)
|
||||
if conn.read_buf[0] != 0x05 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
let cmd = conn.read_buf[1];
|
||||
if cmd != 0x01 && cmd != 0x03 {
|
||||
// command not supported
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
@@ -613,6 +686,75 @@ impl WireGuardSocks5Server {
|
||||
};
|
||||
|
||||
conn.read_buf.drain(..addr_len);
|
||||
|
||||
if cmd == 0x03 {
|
||||
// === SOCKS5 UDP ASSOCIATE ===
|
||||
// The request's DST is the client's intended source (typically
|
||||
// 0.0.0.0:0) and is ignored — the browser's relay source is
|
||||
// learned from its first datagram. Bind a loopback relay socket
|
||||
// the browser sends to, plus a smoltcp UDP socket that egresses
|
||||
// through the WireGuard tunnel on the interface IP.
|
||||
let relay = match UdpSocket::bind("127.0.0.1:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = relay.set_nonblocking(true);
|
||||
let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||
|
||||
// Reply with the relay endpoint (127.0.0.1:relay_port).
|
||||
if conn
|
||||
.tcp_stream
|
||||
.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(relay_port >> 8) as u8,
|
||||
(relay_port & 0xff) as u8,
|
||||
])
|
||||
.is_err()
|
||||
{
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
let udp_rx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let udp_tx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let mut udp_socket = udp::Socket::new(udp_rx, udp_tx);
|
||||
let local_port = 20000 + (rand::random::<u16>() % 40000);
|
||||
if udp_socket.bind(local_port).is_err() {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swap this connection's unused TCP socket for the UDP socket;
|
||||
// `smol_handle` now keys the UDP socket, so teardown is unchanged.
|
||||
sockets.remove(conn.smol_handle);
|
||||
conn.smol_handle = sockets.add(udp_socket);
|
||||
conn.udp = Some(UdpAssoc {
|
||||
relay,
|
||||
client_addr: None,
|
||||
});
|
||||
conn.socks_done = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
conn.dest_addr = Some(addr);
|
||||
|
||||
// Open smoltcp TCP socket to the destination
|
||||
@@ -641,6 +783,82 @@ impl WireGuardSocks5Server {
|
||||
|
||||
conn.connecting = true;
|
||||
}
|
||||
} else if conn.udp.is_some() {
|
||||
// === UDP ASSOCIATE relay ===
|
||||
// The association lives only while the TCP control connection is
|
||||
// open (RFC 1928 §6); tear down when the browser closes it.
|
||||
let mut probe = [0u8; 1];
|
||||
match conn.tcp_stream.try_read(&mut probe) {
|
||||
Ok(0) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let handle = conn.smol_handle;
|
||||
let Some(udp) = conn.udp.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Browser → tunnel: strip the §7 header and forward the payload.
|
||||
let mut dbuf = [0u8; SMOLTCP_UDP_BUF];
|
||||
loop {
|
||||
match udp.relay.recv_from(&mut dbuf) {
|
||||
Ok((n, src)) => {
|
||||
udp.client_addr = Some(src);
|
||||
if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
let cs = socket.can_send();
|
||||
let r = if cs {
|
||||
socket.send_slice(&dbuf[off..n], dst)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
log::info!(
|
||||
"[wg-udp] browser->tunnel {}B dst={} client={} can_send={} send={:?}",
|
||||
n - off,
|
||||
dst,
|
||||
src,
|
||||
cs,
|
||||
r
|
||||
);
|
||||
} else {
|
||||
log::info!("[wg-udp] browser->tunnel parse FAIL ({}B)", n);
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel → browser: wrap each datagram in a §7 header and relay back.
|
||||
loop {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
if !socket.can_recv() {
|
||||
break;
|
||||
}
|
||||
let (payload, src) = match socket.recv() {
|
||||
Ok((data, meta)) => (data.to_vec(), meta.endpoint),
|
||||
Err(_) => break,
|
||||
};
|
||||
if let Some(client) = udp.client_addr {
|
||||
let resp = build_udp_datagram(src, &payload);
|
||||
let r = udp.relay.send_to(&resp, client);
|
||||
log::info!(
|
||||
"[wg-udp] tunnel->browser {}B src={} -> client={} send={:?}",
|
||||
payload.len(),
|
||||
src,
|
||||
client,
|
||||
r
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Data relay between SOCKS5 client and smoltcp socket
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
|
||||
@@ -728,9 +728,21 @@ impl WayfernManager {
|
||||
}
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
||||
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
||||
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
|
||||
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
|
||||
("SOCKS5", rest)
|
||||
} else {
|
||||
(
|
||||
"PROXY",
|
||||
proxy
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://"),
|
||||
)
|
||||
};
|
||||
let pac_data = format!(
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
|
||||
proxy.trim_start_matches("http://").trim_start_matches("https://")
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
|
||||
);
|
||||
args.push(format!("--proxy-pac-url={pac_data}"));
|
||||
args.push("--dns-prefetch-disable".to_string());
|
||||
|
||||
Reference in New Issue
Block a user