Launcher: downloads can go through a SOCKS proxy

Turn on ureq's socks-proxy feature and stop refusing SOCKS addresses, in port-pack's parse and the launcher's settings. socks5h, curl's name for SOCKS5 with remote DNS, is read as socks5, which already works that way in ureq. A test runs a request through a stand-in SOCKS5 proxy.

Fixes #8143
This commit is contained in:
foudretbr committed 2026-11-05 20:14:10 +01:00
1 parent 5b4823c198
commit 114dbd0e21
10 files changed
+126 -44

No files matched your search

Generated
+40
View File
@@ -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"
+1 -1
View File
@@ -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]
+58 -25
View File
@@ -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<ureq::Proxy> {
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<String>) {
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
+1 -1
View File
@@ -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]
+12
View File
@@ -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",
]
+2 -1
View File
@@ -270,7 +270,8 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
game starts. `horizon-launcher game-args "<line>"` sets them too, and `play --arg <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
+2 -1
View File
@@ -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<String>,
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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<Self>) -> 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(),
+6 -11
View File
@@ -51,20 +51,13 @@ pub fn refuse(spec: &str) -> Option<String> {
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:[email protected]: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:[email protected]: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:[email protected]:7891").unwrap();
assert!(why.starts_with("socks5://***@127.0.0.1:7891 "), "{why}");
let why = refuse("socks3://user:[email protected]: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}");
}