mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-15 14:15:31 +02:00
432 lines
14 KiB
Rust
432 lines
14 KiB
Rust
//! Whether a proxy can carry UDP, asked the way the browser would ask.
|
|
//!
|
|
//! This decides more than it looks like it does: WebRTC is UDP, so a profile
|
|
//! on a proxy without `UDP ASSOCIATE` either leaks WebRTC around the proxy or
|
|
//! loses it entirely. The answer therefore has to be a fact, not a guess —
|
|
//! hence three verdicts, with "unknown" reserved for everything the probe
|
|
//! could not establish.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
use crate::browser::ProxySettings;
|
|
|
|
/// How long the whole handshake gets. A proxy that cannot answer a three-byte
|
|
/// greeting and one request inside this is not going to carry a media stream.
|
|
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
const SOCKS5: u8 = 0x05;
|
|
const AUTH_NONE: u8 = 0x00;
|
|
const AUTH_USERPASS: u8 = 0x02;
|
|
const AUTH_UNACCEPTABLE: u8 = 0xFF;
|
|
const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
|
const ATYP_IPV4: u8 = 0x01;
|
|
const ATYP_DOMAIN: u8 = 0x03;
|
|
const ATYP_IPV6: u8 = 0x04;
|
|
const REP_SUCCEEDED: u8 = 0x00;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum UdpSupport {
|
|
/// The proxy accepted `UDP ASSOCIATE`.
|
|
Yes,
|
|
/// The proxy cannot carry UDP: it refused the command, or its protocol has
|
|
/// no way to carry a datagram at all.
|
|
No,
|
|
/// Not established. Never reported as `Yes`, and never as `No` either: an
|
|
/// unreachable proxy has not proved anything.
|
|
#[default]
|
|
Unknown,
|
|
}
|
|
|
|
/// The verdict that follows from the protocol alone, before anything is
|
|
/// dialled. `None` means the protocol can carry UDP in principle and the
|
|
/// proxy itself has to be asked.
|
|
///
|
|
/// HTTP proxies answer `No` here rather than being probed: CONNECT builds a
|
|
/// TCP tunnel and the protocol has no datagram command to send.
|
|
pub fn udp_verdict_for_type(proxy_type: &str) -> Option<UdpSupport> {
|
|
match proxy_type.trim().to_ascii_lowercase().as_str() {
|
|
"socks5" | "socks5h" => None,
|
|
// SOCKS4 and SOCKS4a define CONNECT and BIND only.
|
|
"http" | "https" | "httpstls" | "socks4" | "socks4a" => Some(UdpSupport::No),
|
|
// Shadowsocks and VLESS can carry UDP, but whether this endpoint does is
|
|
// not something a SOCKS handshake can answer.
|
|
_ => Some(UdpSupport::Unknown),
|
|
}
|
|
}
|
|
|
|
/// Map a SOCKS5 reply code onto a verdict.
|
|
///
|
|
/// Only an accepted association counts as `Yes`. Every other reply is a
|
|
/// refusal the proxy stated in answer to the exact request, which is a real
|
|
/// `No`. That deliberately includes codes RFC 1928 never defined: a residential
|
|
/// gateway tested here answers `UDP ASSOCIATE` with `0xFF`, and calling that
|
|
/// "unknown" would leave the column blank for the proxies it matters most for.
|
|
///
|
|
/// `Unknown` belongs to the cases where the proxy was never actually asked —
|
|
/// unreachable, timed out, refused the authentication, or answered something
|
|
/// that is not a SOCKS5 reply — and those are decided by the caller before a
|
|
/// reply code ever gets here.
|
|
pub fn udp_verdict_from_socks_reply(reply: u8) -> UdpSupport {
|
|
if reply == REP_SUCCEEDED {
|
|
UdpSupport::Yes
|
|
} else {
|
|
UdpSupport::No
|
|
}
|
|
}
|
|
|
|
/// Ask a proxy whether it carries UDP.
|
|
///
|
|
/// The probe dials the proxy exactly as a launch would — the same host, the
|
|
/// same port, the same credentials — and asks for an association it never
|
|
/// uses. No datagram is sent and no third-party host is named, so nothing
|
|
/// about this check reaches anywhere the browser would not already go.
|
|
pub async fn probe_udp_support(settings: &ProxySettings) -> UdpSupport {
|
|
if let Some(verdict) = udp_verdict_for_type(&settings.proxy_type) {
|
|
return verdict;
|
|
}
|
|
|
|
match tokio::time::timeout(PROBE_TIMEOUT, socks5_udp_associate(settings)).await {
|
|
Ok(Ok(verdict)) => verdict,
|
|
Ok(Err(e)) => {
|
|
log::debug!(
|
|
"UDP probe of {}:{} could not complete: {e}",
|
|
settings.host,
|
|
settings.port
|
|
);
|
|
UdpSupport::Unknown
|
|
}
|
|
Err(_) => {
|
|
log::debug!("UDP probe of {}:{} timed out", settings.host, settings.port);
|
|
UdpSupport::Unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result<UdpSupport> {
|
|
let mut stream = tokio::net::TcpStream::connect((settings.host.as_str(), settings.port)).await?;
|
|
|
|
let credentials = settings
|
|
.username
|
|
.as_deref()
|
|
.filter(|user| !user.is_empty())
|
|
.map(|user| (user, settings.password.as_deref().unwrap_or("")));
|
|
|
|
let greeting: Vec<u8> = match credentials {
|
|
Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS],
|
|
None => vec![SOCKS5, 1, AUTH_NONE],
|
|
};
|
|
stream.write_all(&greeting).await?;
|
|
|
|
let mut selection = [0u8; 2];
|
|
stream.read_exact(&mut selection).await?;
|
|
if selection[0] != SOCKS5 {
|
|
return Ok(UdpSupport::Unknown);
|
|
}
|
|
match selection[1] {
|
|
AUTH_NONE => {}
|
|
AUTH_USERPASS => {
|
|
let Some((user, password)) = credentials else {
|
|
return Ok(UdpSupport::Unknown);
|
|
};
|
|
if !authenticate(&mut stream, user, password).await? {
|
|
return Ok(UdpSupport::Unknown);
|
|
}
|
|
}
|
|
AUTH_UNACCEPTABLE => return Ok(UdpSupport::Unknown),
|
|
_ => return Ok(UdpSupport::Unknown),
|
|
}
|
|
|
|
// An all-zero address is what a client sends when it does not yet know the
|
|
// address it will send datagrams from, which is exactly this case: the
|
|
// association is requested and then dropped.
|
|
stream
|
|
.write_all(&[SOCKS5, CMD_UDP_ASSOCIATE, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
|
|
.await?;
|
|
|
|
// Only the version and the reply code are read up front. RFC 1928 says a
|
|
// reply carries a bound address as well, but a refusing server does not
|
|
// always send one: the residential gateway this was tested against answers
|
|
// `05 FF` and closes. Demanding the full four-byte header there turns a
|
|
// stated refusal into a read error, and the verdict into "unknown".
|
|
let mut head = [0u8; 2];
|
|
stream.read_exact(&mut head).await?;
|
|
if head[0] != SOCKS5 {
|
|
return Ok(UdpSupport::Unknown);
|
|
}
|
|
let verdict = udp_verdict_from_socks_reply(head[1]);
|
|
|
|
if verdict == UdpSupport::Yes {
|
|
// An accepted association does carry the address to send datagrams to.
|
|
// Nothing here uses it, but reading it leaves the socket drained rather
|
|
// than closing under a server that is still writing.
|
|
let mut tail = [0u8; 2];
|
|
if stream.read_exact(&mut tail).await.is_ok() {
|
|
let _ = drain_bound_address(&mut stream, tail[1]).await;
|
|
}
|
|
}
|
|
Ok(verdict)
|
|
}
|
|
|
|
async fn authenticate(
|
|
stream: &mut tokio::net::TcpStream,
|
|
user: &str,
|
|
password: &str,
|
|
) -> std::io::Result<bool> {
|
|
if user.len() > 255 || password.len() > 255 {
|
|
return Ok(false);
|
|
}
|
|
let mut request = Vec::with_capacity(3 + user.len() + password.len());
|
|
request.push(0x01);
|
|
request.push(user.len() as u8);
|
|
request.extend_from_slice(user.as_bytes());
|
|
request.push(password.len() as u8);
|
|
request.extend_from_slice(password.as_bytes());
|
|
stream.write_all(&request).await?;
|
|
|
|
let mut reply = [0u8; 2];
|
|
stream.read_exact(&mut reply).await?;
|
|
Ok(reply[1] == 0x00)
|
|
}
|
|
|
|
async fn drain_bound_address(
|
|
stream: &mut tokio::net::TcpStream,
|
|
address_type: u8,
|
|
) -> std::io::Result<()> {
|
|
let length = match address_type {
|
|
ATYP_IPV4 => 4,
|
|
ATYP_IPV6 => 16,
|
|
ATYP_DOMAIN => {
|
|
let mut len = [0u8; 1];
|
|
stream.read_exact(&mut len).await?;
|
|
len[0] as usize
|
|
}
|
|
_ => return Ok(()),
|
|
};
|
|
let mut scratch = vec![0u8; length + 2];
|
|
stream.read_exact(&mut scratch).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// RFC 1928's "command not supported". Production no longer needs to name
|
|
/// it — every non-zero reply is a refusal — but a test server has to send
|
|
/// something a real proxy would send.
|
|
const REP_CMD_NOT_SUPPORTED: u8 = 0x07;
|
|
|
|
#[test]
|
|
fn an_http_proxy_is_answered_without_ever_being_dialled() {
|
|
for proxy_type in ["http", "HTTP", "https", "httpstls", "socks4", "socks4a"] {
|
|
assert_eq!(
|
|
udp_verdict_for_type(proxy_type),
|
|
Some(UdpSupport::No),
|
|
"{proxy_type}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn socks5_is_the_only_type_that_gets_probed() {
|
|
assert_eq!(udp_verdict_for_type("socks5"), None);
|
|
assert_eq!(udp_verdict_for_type("SOCKS5"), None);
|
|
assert_eq!(udp_verdict_for_type("socks5h"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_protocol_no_socks_handshake_can_answer_stays_unknown() {
|
|
for proxy_type in ["ss", "vless", "", "something-new"] {
|
|
assert_eq!(
|
|
udp_verdict_for_type(proxy_type),
|
|
Some(UdpSupport::Unknown),
|
|
"{proxy_type}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn only_an_accepted_association_counts_as_yes() {
|
|
assert_eq!(udp_verdict_from_socks_reply(0x00), UdpSupport::Yes);
|
|
// Every stated refusal is a refusal, including 0xFF, which is not in
|
|
// RFC 1928 but is what a real residential gateway answers.
|
|
for refused in [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0xFF] {
|
|
assert_eq!(
|
|
udp_verdict_from_socks_reply(refused),
|
|
UdpSupport::No,
|
|
"reply {refused:#04x}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_verdict_is_unknown_so_an_old_receipt_never_claims_support() {
|
|
assert_eq!(UdpSupport::default(), UdpSupport::Unknown);
|
|
assert_eq!(serde_json::to_string(&UdpSupport::Yes).unwrap(), "\"yes\"");
|
|
assert_eq!(
|
|
serde_json::from_str::<UdpSupport>("\"unknown\"").unwrap(),
|
|
UdpSupport::Unknown
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_proxy_that_cannot_be_reached_reports_unknown_not_no() {
|
|
let settings = ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
// Discard port on loopback: nothing is listening, so the dial fails.
|
|
host: "127.0.0.1".to_string(),
|
|
port: 9,
|
|
username: None,
|
|
password: None,
|
|
vless_uri: None,
|
|
};
|
|
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_socks5_server_that_accepts_the_association_reports_yes() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut greeting = [0u8; 3];
|
|
stream.read_exact(&mut greeting).await.unwrap();
|
|
stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap();
|
|
let mut request = [0u8; 10];
|
|
stream.read_exact(&mut request).await.unwrap();
|
|
assert_eq!(request[1], CMD_UDP_ASSOCIATE);
|
|
stream
|
|
.write_all(&[
|
|
SOCKS5,
|
|
REP_SUCCEEDED,
|
|
0x00,
|
|
ATYP_IPV4,
|
|
127,
|
|
0,
|
|
0,
|
|
1,
|
|
0x11,
|
|
0x11,
|
|
])
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
let settings = ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
username: None,
|
|
password: None,
|
|
vless_uri: None,
|
|
};
|
|
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Yes);
|
|
}
|
|
|
|
/// A refusal that arrives as two bytes and a closed socket, which is what a
|
|
/// real residential gateway sends. Reading a full reply header here would
|
|
/// hit end-of-file and report "unknown" for a proxy that plainly said no.
|
|
#[tokio::test]
|
|
async fn a_truncated_refusal_is_still_a_refusal() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut greeting = [0u8; 3];
|
|
stream.read_exact(&mut greeting).await.unwrap();
|
|
stream.write_all(&[SOCKS5, AUTH_NONE]).await.unwrap();
|
|
let mut request = [0u8; 10];
|
|
stream.read_exact(&mut request).await.unwrap();
|
|
stream.write_all(&[SOCKS5, 0xFF]).await.unwrap();
|
|
});
|
|
|
|
let settings = ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
username: None,
|
|
password: None,
|
|
vless_uri: None,
|
|
};
|
|
assert_eq!(probe_udp_support(&settings).await, UdpSupport::No);
|
|
}
|
|
|
|
/// A server that will not accept the offered authentication never got asked
|
|
/// about UDP, so the answer is "unknown", not "no".
|
|
#[tokio::test]
|
|
async fn a_refused_handshake_reports_unknown() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut greeting = [0u8; 3];
|
|
stream.read_exact(&mut greeting).await.unwrap();
|
|
stream
|
|
.write_all(&[SOCKS5, AUTH_UNACCEPTABLE])
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
let settings = ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
username: None,
|
|
password: None,
|
|
vless_uri: None,
|
|
};
|
|
assert_eq!(probe_udp_support(&settings).await, UdpSupport::Unknown);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_socks5_server_that_refuses_the_command_reports_no() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut greeting = [0u8; 4];
|
|
stream.read_exact(&mut greeting).await.unwrap();
|
|
stream.write_all(&[SOCKS5, AUTH_USERPASS]).await.unwrap();
|
|
let mut header = [0u8; 2];
|
|
stream.read_exact(&mut header).await.unwrap();
|
|
let mut user = vec![0u8; header[1] as usize];
|
|
stream.read_exact(&mut user).await.unwrap();
|
|
let mut password_len = [0u8; 1];
|
|
stream.read_exact(&mut password_len).await.unwrap();
|
|
let mut password = vec![0u8; password_len[0] as usize];
|
|
stream.read_exact(&mut password).await.unwrap();
|
|
assert_eq!(user, b"probe-user");
|
|
stream.write_all(&[0x01, 0x00]).await.unwrap();
|
|
let mut request = [0u8; 10];
|
|
stream.read_exact(&mut request).await.unwrap();
|
|
stream
|
|
.write_all(&[
|
|
SOCKS5,
|
|
REP_CMD_NOT_SUPPORTED,
|
|
0x00,
|
|
ATYP_IPV4,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
])
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
let settings = ProxySettings {
|
|
proxy_type: "socks5".to_string(),
|
|
host: "127.0.0.1".to_string(),
|
|
port,
|
|
username: Some("probe-user".to_string()),
|
|
password: Some("probe-pass".to_string()),
|
|
vless_uri: None,
|
|
};
|
|
assert_eq!(probe_udp_support(&settings).await, UdpSupport::No);
|
|
}
|
|
}
|