mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
1 parent
5b4823c198
commit
114dbd0e21
10 files changed
+126
-44
No files matched your search
Generated
+40
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Generated
+12
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user