diff --git a/Cargo.lock b/Cargo.lock index 2d14492824..e8d00632a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,6 +329,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -3715,6 +3721,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spake2" version = "0.4.0" @@ -4168,6 +4185,7 @@ dependencies = [ "once_cell", "rustls", "rustls-pki-types", + "socks", "url", "webpki-roots 0.26.11", ] @@ -4630,6 +4648,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4639,6 +4673,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.54.0" diff --git a/crates/horizon-port-pack/Cargo.toml b/crates/horizon-port-pack/Cargo.toml index 53758fc949..35c50ce32a 100644 --- a/crates/horizon-port-pack/Cargo.toml +++ b/crates/horizon-port-pack/Cargo.toml @@ -15,7 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } sha1 = "0.10" toml = "1.1.6" -ureq = { version = "2", default-features = false, features = ["tls"] } +ureq = { version = "2", default-features = false, features = ["tls", "socks-proxy"] } zip = { version = "9.1.3", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/horizon-port-pack/src/net.rs b/crates/horizon-port-pack/src/net.rs index dba49ee5cc..d7e8732654 100644 --- a/crates/horizon-port-pack/src/net.rs +++ b/crates/horizon-port-pack/src/net.rs @@ -8,7 +8,7 @@ //! would otherwise fail here while the launcher's own downloads worked. //! //! Which hosts those are is decided here and nowhere else. The launcher asks `direct`, `env_for`, -//! `host_of`, `scheme_of`, `socks` and `parse` for its own traffic, and keeps its agents here too +//! `host_of`, `scheme_of` and `parse` for its own traffic, and keeps its agents here too //! (`keep`), so the two agree whatever the player set — and there is one copy of the rules to //! change when they need it. use std::collections::HashMap; @@ -89,15 +89,13 @@ pub fn keep( /// `spec` as a proxy, when it is one. An address that can't be used is said once: a download left /// to go direct would otherwise fail with nothing pointing at the proxy. pub fn parse(spec: &str) -> Option { - if socks(spec) { - say(&format!( - "{} is a SOCKS proxy; downloads go without one. Write the http:// address your \ - proxy app listens on as well.", - redacted(spec) - )); - return None; - } - match ureq::Proxy::new(spec) { + // `socks5h` is curl's name for SOCKS5 with the proxy resolving host names. `ureq` has only the + // name `socks5`, which already sends it host names to resolve. + let address = match spec.strip_prefix("socks5h://") { + Some(rest) => format!("socks5://{rest}"), + None => spec.to_owned(), + }; + match ureq::Proxy::new(&address) { Ok(proxy) => Some(proxy), Err(e) => { say(&format!( @@ -124,17 +122,6 @@ pub fn redacted(spec: &str) -> String { } } -/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a -/// SOCKS address happily and only fails when it connects through it. -pub fn socks(spec: &str) -> bool { - matches!( - spec.split_once("://") - .map(|(s, _)| s.to_ascii_lowercase()) - .as_deref(), - Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h") - ) -} - /// Whether `url` is reached without the proxy: a local address, or a host `NO_PROXY` names. The /// launcher asks this of its own requests, so both reach the same hosts directly. pub fn direct(url: &str) -> bool { @@ -382,10 +369,56 @@ mod tests { fn an_address_that_cant_be_used_becomes_no_proxy() { assert!(parse("http://127.0.0.1:7890").is_some()); assert!(parse("127.0.0.1:7890").is_some()); - // `ureq` reads a SOCKS address and only fails once it connects through it. - assert!(socks("socks5://127.0.0.1:7891")); - assert!(parse("socks5://127.0.0.1:7891").is_none()); - assert!(!socks("http://127.0.0.1:7890")); + assert!(parse("socks5://127.0.0.1:7891").is_some()); + assert!(parse("socks3://127.0.0.1:7890").is_none()); + } + + /// A stand-in SOCKS5 proxy: answers one request itself and returns the host it was asked for. + fn socks5_proxy() -> (u16, std::thread::JoinHandle) { + use std::io::{BufRead, BufReader, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut greeting = [0; 2]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting[0], 5); + let mut methods = vec![0; greeting[1].into()]; + stream.read_exact(&mut methods).unwrap(); + stream.write_all(&[5, 0]).unwrap(); + let mut request = [0; 5]; + stream.read_exact(&mut request).unwrap(); + assert_eq!(request[..4], [5, 1, 0, 3], "a CONNECT to a host name"); + let mut host = vec![0; request[4].into()]; + stream.read_exact(&mut host).unwrap(); + let mut target_port = [0; 2]; + stream.read_exact(&mut target_port).unwrap(); + stream.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap(); + let mut http = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + while http.read_line(&mut line).unwrap() > 2 { + line.clear(); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .unwrap(); + String::from_utf8(host).unwrap() + }); + (port, server) + } + + /// `.invalid` never resolves here (RFC 2606), so the answer can only have come through the + /// proxy, which is handed the name to resolve. + #[test] + fn a_request_goes_through_a_socks_proxy() { + for scheme in ["socks5", "socks5h"] { + let (port, server) = socks5_proxy(); + let proxy = parse(&format!("{scheme}://127.0.0.1:{port}")).unwrap(); + let agent = ureq::AgentBuilder::new().proxy(proxy).build(); + let answer = agent.get("http://horizon.invalid/a.jar").call().unwrap(); + assert_eq!(answer.into_string().unwrap(), "ok", "{scheme}"); + assert_eq!(server.join().unwrap(), "horizon.invalid", "{scheme}"); + } } /// The agent a second request gets is the one the first built: one connection pool, not one diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 0ab14d3abb..d69815ff3c 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -233,7 +233,7 @@ MCP: `account_skin`. ## `horizon-launcher settings` -Show the launcher's settings, or set one (the Settings page): selected-version TAG, game-architecture auto|x64|arm64, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, prune-old-versions on|off (delete old versions after an install, keeping the selected one, the ones instances use and the newest few), keep-versions N (how many of the newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any hue), background PATH (a picture behind Home; "default" shows Horizon's landscape), native-cache-gib N (combined native compilation cache limit in GiB, default 30; 0 disables automatic cleanup), native-profile performance|dev|release (the profile native mods build with: performance is incremental, dev rebuilds a mod quickest for a slow game, release is slowest to build and slightly faster to play). +Show the launcher's settings, or set one (the Settings page): selected-version TAG, game-architecture auto|x64|arm64, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, prune-old-versions on|off (delete old versions after an install, keeping the selected one, the ones instances use and the newest few), keep-versions N (how many of the newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port|socks5://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any hue), background PATH (a picture behind Home; "default" shows Horizon's landscape), native-cache-gib N (combined native compilation cache limit in GiB, default 30; 0 disables automatic cleanup), native-profile performance|dev|release (the profile native mods build with: performance is incremental, dev rebuilds a mod quickest for a slow game, release is slowest to build and slightly faster to play). horizon-launcher settings [KEY] [VALUE] diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 68f9acd232..7b2b7c8a30 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -5483,6 +5483,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spake2" version = "0.4.0" @@ -6134,6 +6145,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "socks", "url", "webpki-roots 0.26.11", ] diff --git a/launcher/README.md b/launcher/README.md index 912382e557..ded94ce77a 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -270,7 +270,8 @@ page `servers.json` (the list), `servers//` (the servers created there) an game starts. `horizon-launcher game-args ""` sets them too, and `play --arg ` passes one for a single run. - **Proxy**: when the internet here needs one (a school's, an office's, a country where a host is - unreachable), Settings' Proxy takes the `http://host:port` address your proxy app listens on, and + unreachable), Settings' Proxy takes the `http://host:port` or `socks5://host:port` address your + proxy app listens on, and every download goes through it: Horizon's releases and the launcher's own update, Minecraft's files and Java from Mojang, Modrinth, CurseForge, Horizon's mod registry, pictures, Microsoft sign-in, and the jars a modpack import fetches. It is kept as `proxy` in `settings.json`, and diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 2970c7b6de..59a4bceb9f 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -4254,7 +4254,8 @@ pub struct Settings { /// default. Appended after the launcher's own arguments, so they can add to or override them. #[serde(default, skip_serializing_if = "String::is_empty")] pub game_args: String, - /// The HTTP proxy every download goes through (`proxy`), such as `http://127.0.0.1:7890`. + /// The proxy every download goes through (`proxy`), such as `http://127.0.0.1:7890` or + /// `socks5://127.0.0.1:1080`. /// Unset means none: the environment's `HTTPS_PROXY` and `HTTP_PROXY` apply instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub proxy: Option, diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index dc6cf9a348..0cae3cdee1 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -848,7 +848,7 @@ enum Cmd { /// minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions /// on|off, prune-old-versions on|off (delete old versions after an install, keeping the /// selected one, the ones instances use and the newest few), keep-versions N (how many of the - /// newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port + /// newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port|socks5://host:port /// ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language /// system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent /// violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 6a4a3870c1..9f69790f49 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -4284,7 +4284,7 @@ impl Launcher { ) } - /// Settings' Proxy row: the HTTP proxy every download and sign-in goes through, kept in + /// Settings' Proxy row: the proxy every download and sign-in goes through, kept in /// `settings.json` and read back by `proxy.rs` on each request. fn proxy_row(&self, n: usize, cx: &mut Context) -> Div { let t = self.theme; @@ -4335,8 +4335,8 @@ impl Launcher { (false, Some(why)) => div().text_color(t.danger).child(why).into_any_element(), _ => div() .child(tr( - "Downloads, sign-ins and updates go through this HTTP proxy \ - (http://host:port). Empty means none: HTTPS_PROXY is used instead when the \ + "Downloads, sign-ins and updates go through this proxy (http://host:port \ + or socks5://host:port). Empty means none: HTTPS_PROXY is used instead when the \ launcher was started with one.", )) .into_any_element(), diff --git a/launcher/src/proxy.rs b/launcher/src/proxy.rs index d08b932b9c..9a53f5cac8 100644 --- a/launcher/src/proxy.rs +++ b/launcher/src/proxy.rs @@ -51,20 +51,13 @@ pub fn refuse(spec: &str) -> Option { if spec.is_empty() { return None; } - if horizon_port_pack::net::socks(spec) { - return Some(format!( - "{} is a SOCKS proxy; Horizon downloads through an HTTP proxy, such as the \ - http:// address your proxy app also listens on.", - horizon_port_pack::net::redacted(spec) - )); - } // `ureq` takes `http://` without a host, which would leave a download failing with nothing // said about the proxy. if horizon_port_pack::net::parse(spec).is_none() || horizon_port_pack::net::host_of(spec).is_none_or(str::is_empty) { return Some(format!( - "{} isn't a proxy address. Write it as http://host:port.", + "{} isn't a proxy address. Write it as http://host:port or socks5://host:port.", horizon_port_pack::net::redacted(spec) )); } @@ -137,14 +130,16 @@ mod tests { assert_eq!(refuse("127.0.0.1:7890"), None); assert_eq!(refuse("http://user:pass@proxy.local:3128"), None); assert!(refuse("http://").is_some()); - assert!(refuse("socks5://127.0.0.1:7891").is_some()); + assert_eq!(refuse("socks5://127.0.0.1:7891"), None); + assert_eq!(refuse("socks5h://user:pass@127.0.0.1:7891"), None); + assert!(refuse("socks5://").is_some()); } /// The reason is shown under the field and printed by `horizon-launcher settings set`. #[test] fn a_refused_address_keeps_its_credentials_to_itself() { - let why = refuse("socks5://user:secret@127.0.0.1:7891").unwrap(); - assert!(why.starts_with("socks5://***@127.0.0.1:7891 "), "{why}"); + let why = refuse("socks3://user:secret@127.0.0.1:7891").unwrap(); + assert!(why.starts_with("socks3://***@127.0.0.1:7891 "), "{why}"); let why = refuse("http://user:secret@").unwrap(); assert!(!why.contains("secret"), "{why}"); }