mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Proxy: one copy of the rules, none of it per request
Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
This commit is contained in:
1 parent
a9453b7c93
commit
da54f988d4
6 files changed
+312
-217
No files matched your search
@@ -2,65 +2,142 @@
|
||||
//! places as everything else, so they take the same proxy — and leave the same hosts alone.
|
||||
//!
|
||||
//! The launcher hands its own setting over (`set_proxy`, from `proxy.rs`); a run of the
|
||||
//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead. Either
|
||||
//! way a host in `NO_PROXY`, or on this computer, is reached directly: a host that only answers
|
||||
//! without the proxy would otherwise fail here while the launcher's own downloads worked.
|
||||
//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead (`env_for`),
|
||||
//! taking the one that matches the address's scheme the way the launcher does. Either way a host in
|
||||
//! `NO_PROXY`, or on this computer, is reached directly: a host that only answers without the proxy
|
||||
//! would otherwise fail here while the launcher's own downloads worked.
|
||||
//!
|
||||
//! The rules are `launcher/src/proxy.rs`'s, which the launcher applies to its own traffic. The two
|
||||
//! crates share no code (the launcher depends on this one, not the other way round), so they are
|
||||
//! kept in step by hand.
|
||||
use std::sync::Mutex;
|
||||
//! 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
|
||||
//! (`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;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// What the launcher set, when it did.
|
||||
static PROXY: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Agents kept for reuse, named by the part that asked and the proxy they use: a fresh `Agent`
|
||||
/// starts a new connection pool, so building one per request costs a handshake per file.
|
||||
static KEPT: Mutex<Option<HashMap<String, ureq::Agent>>> = Mutex::new(None);
|
||||
|
||||
/// The proxy to use from now on: the launcher's setting, or None to fall back to the environment.
|
||||
/// The agents built so far are dropped when it changes, since they hold the address it replaces.
|
||||
pub fn set_proxy(spec: Option<String>) {
|
||||
let spec = spec.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||
if let Ok(mut proxy) = PROXY.lock() {
|
||||
*proxy = spec;
|
||||
let mut proxy = lock(&PROXY);
|
||||
if *proxy == spec {
|
||||
return;
|
||||
}
|
||||
*proxy = spec;
|
||||
drop(proxy);
|
||||
*lock(&KEPT) = None;
|
||||
}
|
||||
|
||||
/// The proxy address to use: the launcher's, else the environment's.
|
||||
fn spec() -> Option<String> {
|
||||
let set = PROXY.lock().ok().and_then(|proxy| proxy.clone());
|
||||
set.or_else(|| {
|
||||
[
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_owned())
|
||||
.filter(|v| !v.is_empty())
|
||||
})
|
||||
})
|
||||
/// The proxy address `url` is fetched through: what the launcher set, else the environment's for
|
||||
/// its scheme, unless `url` is a host that must be reached directly.
|
||||
fn spec_for(url: &str) -> Option<String> {
|
||||
if direct(url) {
|
||||
return None;
|
||||
}
|
||||
lock(&PROXY)
|
||||
.clone()
|
||||
.or_else(|| env_for(scheme_of(url).unwrap_or("https")))
|
||||
}
|
||||
|
||||
/// An agent for downloading `url`: through the proxy when one is set, unless `url` is a host that
|
||||
/// must be reached directly.
|
||||
pub fn agent(url: &str) -> ureq::Agent {
|
||||
let builder = ureq::AgentBuilder::new();
|
||||
if direct(url, &no_proxy()) {
|
||||
return builder.build();
|
||||
keep("port-pack", spec_for(url).as_deref(), |proxy| {
|
||||
let builder = ureq::AgentBuilder::new();
|
||||
match proxy {
|
||||
Some(proxy) => builder.proxy(proxy).build(),
|
||||
None => builder.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The agent `url` is fetched with, built by `build` and then kept for the next request that names
|
||||
/// the same `slot` and proxy: a fresh `Agent` starts a new connection pool, so building one per
|
||||
/// request is a handshake per file, and a modpack's jars are fetched one after another.
|
||||
pub fn keep(
|
||||
slot: &str,
|
||||
spec: Option<&str>,
|
||||
build: impl FnOnce(Option<ureq::Proxy>) -> ureq::Agent,
|
||||
) -> ureq::Agent {
|
||||
let proxy = spec.and_then(parse);
|
||||
// An address that isn't a proxy is used as none, and remembered as none.
|
||||
let key = format!(
|
||||
"{slot} {}",
|
||||
if proxy.is_some() {
|
||||
spec.unwrap_or_default()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
if let Some(agent) = lock(&KEPT).as_ref().and_then(|kept| kept.get(&key)) {
|
||||
return agent.clone();
|
||||
}
|
||||
match spec().and_then(|s| ureq::Proxy::new(s).ok()) {
|
||||
Some(proxy) => builder.proxy(proxy).build(),
|
||||
None => builder.build(),
|
||||
let agent = build(proxy);
|
||||
lock(&KEPT)
|
||||
.get_or_insert_with(HashMap::new)
|
||||
.insert(key, agent.clone());
|
||||
agent
|
||||
}
|
||||
|
||||
/// `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!(
|
||||
"{spec} is a SOCKS proxy; downloads go without one. Write the http:// address your \
|
||||
proxy app listens on as well."
|
||||
));
|
||||
return None;
|
||||
}
|
||||
match ureq::Proxy::new(spec) {
|
||||
Ok(proxy) => Some(proxy),
|
||||
Err(e) => {
|
||||
say(&format!(
|
||||
"The proxy address {spec} can't be used ({e}); downloads go without one."
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `url` is reached without the proxy, given a `NO_PROXY` list: a local address, or a host
|
||||
/// the list names.
|
||||
fn direct(url: &str, no_proxy: &str) -> bool {
|
||||
host_of(url).is_some_and(|host| local(host) || excluded(host, no_proxy))
|
||||
/// 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 {
|
||||
host_of(url).is_some_and(|host| local(host) || excluded(host, &no_proxy()))
|
||||
}
|
||||
|
||||
/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling
|
||||
/// first and the lower-case one after, as the tools that write them do.
|
||||
pub fn env_for(scheme: &str) -> Option<String> {
|
||||
let names: &[&str] = if scheme.eq_ignore_ascii_case("http") {
|
||||
&["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
|
||||
} else {
|
||||
&["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
|
||||
};
|
||||
names.iter().find_map(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_owned())
|
||||
.filter(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is.
|
||||
@@ -72,7 +149,7 @@ fn no_proxy() -> String {
|
||||
}
|
||||
|
||||
/// The host part of a URL (`https://user@host:port/path` is `host`).
|
||||
fn host_of(url: &str) -> Option<&str> {
|
||||
pub fn host_of(url: &str) -> Option<&str> {
|
||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let rest = rest.split(['/', '?', '#']).next()?;
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
|
||||
@@ -83,7 +160,12 @@ fn host_of(url: &str) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `host` is this computer.
|
||||
/// `https` in `https://…`, or None for anything that isn't an absolute address.
|
||||
pub fn scheme_of(url: &str) -> Option<&str> {
|
||||
url.split_once("://").map(|(scheme, _)| scheme)
|
||||
}
|
||||
|
||||
/// Whether `host` is this computer: the game's own API and a server on the same machine.
|
||||
fn local(host: &str) -> bool {
|
||||
let host = bare(host);
|
||||
host == "localhost"
|
||||
@@ -119,12 +201,35 @@ fn bare(host: &str) -> String {
|
||||
host.trim().trim_matches(['[', ']']).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Said once, however many downloads follow: a wrong address is one thing to report, not one
|
||||
/// report per file.
|
||||
fn say(what: &str) {
|
||||
static SAID: AtomicBool = AtomicBool::new(false);
|
||||
if !SAID.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock, even after a thread panicked while holding it: an address lost to a panic is worse
|
||||
/// than an old one kept.
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What the tests below share, since the address and the agents are one of each for the whole
|
||||
/// process: they take it in turn.
|
||||
fn alone() -> MutexGuard<'static, ()> {
|
||||
static TURN: Mutex<()> = Mutex::new(());
|
||||
lock(&TURN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_the_launcher_sets_is_trimmed_and_kept() {
|
||||
let _turn = alone();
|
||||
set_proxy(Some(" http://127.0.0.1:7890 ".into()));
|
||||
assert_eq!(
|
||||
PROXY.lock().unwrap().as_deref(),
|
||||
@@ -134,16 +239,13 @@ mod tests {
|
||||
assert!(PROXY.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A host that must be reached directly takes no proxy, however one is set: what an import
|
||||
/// with `NO_PROXY=modrinth.com` needs, since the launcher's own requests to it go direct.
|
||||
/// An address on this computer is reached directly whatever `NO_PROXY` says: what an import
|
||||
/// needs when the launcher's own requests to it go direct.
|
||||
#[test]
|
||||
fn a_listed_host_is_reached_directly() {
|
||||
let list = "modrinth.com";
|
||||
assert!(direct("https://cdn.modrinth.com/data/a.jar", list));
|
||||
assert!(direct("https://api.modrinth.com/v2/version", list));
|
||||
assert!(!direct("https://edge.forgecdn.net/files/1.jar", list));
|
||||
assert!(!direct("https://example.com/a.jar", list));
|
||||
assert!(direct("http://127.0.0.1:8130/api/v1/status", ""));
|
||||
fn a_local_address_is_reached_directly() {
|
||||
assert!(direct("http://127.0.0.1:8130/api/v1/status"));
|
||||
assert!(direct("http://localhost/api/v1/status"));
|
||||
assert!(direct("https://[::1]:25565/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -153,32 +255,98 @@ mod tests {
|
||||
Some("cdn.modrinth.com")
|
||||
);
|
||||
assert_eq!(
|
||||
host_of("https://user:pass@host.example:8443/a"),
|
||||
Some("host.example")
|
||||
host_of("https://user:pass@piston-meta.mojang.com:8443/a"),
|
||||
Some("piston-meta.mojang.com")
|
||||
);
|
||||
assert_eq!(
|
||||
host_of("http://127.0.0.1:8130/api/v1/status"),
|
||||
Some("127.0.0.1")
|
||||
);
|
||||
assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]"));
|
||||
assert_eq!(host_of("https://example.com?x=1"), Some("example.com"));
|
||||
assert_eq!(host_of("https://example.com#top"), Some("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_absolute_addresses_have_a_scheme() {
|
||||
assert_eq!(scheme_of("https://a.b"), Some("https"));
|
||||
assert_eq!(scheme_of("http://a.b"), Some("http"));
|
||||
assert_eq!(scheme_of("a.b/c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_computer_is_never_proxied() {
|
||||
for host in ["localhost", "[::1]", "127.0.0.1", "127.1.2.3"] {
|
||||
for host in [
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"[::1]",
|
||||
"::1",
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
] {
|
||||
assert!(local(host), "{host}");
|
||||
}
|
||||
for host in ["cdn.modrinth.com", "10.0.0.1"] {
|
||||
for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] {
|
||||
assert!(!local(host), "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What an import with `NO_PROXY=modrinth.com` needs: the launcher's own requests to it go
|
||||
/// direct, so the jars fetched for it must too.
|
||||
#[test]
|
||||
fn no_proxy_lists_hosts_and_their_subdomains() {
|
||||
assert!(excluded("anything.at.all", "*"));
|
||||
assert!(excluded("cdn.modrinth.com", "modrinth.com"));
|
||||
assert!(excluded("api.modrinth.com", "modrinth.com"));
|
||||
assert!(excluded("piston-meta.mojang.com", "mojang.com"));
|
||||
assert!(excluded(
|
||||
"wiki.corp.example",
|
||||
" .corp.example , other.host "
|
||||
));
|
||||
assert!(excluded("github.com", "github.com:443"));
|
||||
assert!(!excluded("notmodrinth.com", "modrinth.com"));
|
||||
assert!(!excluded("modrinth.com", " .corp.example , mojang.com "));
|
||||
assert!(!excluded("edge.forgecdn.net", "modrinth.com"));
|
||||
assert!(!excluded("modrinth.com", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
/// The agent a second request gets is the one the first built: one connection pool, not one
|
||||
/// handshake per jar.
|
||||
#[test]
|
||||
fn an_agent_is_kept_for_the_next_request() {
|
||||
let _turn = alone();
|
||||
set_proxy(None);
|
||||
let mut built = 0;
|
||||
for _ in 0..3 {
|
||||
keep("kept", None, |_| {
|
||||
built += 1;
|
||||
ureq::AgentBuilder::new().build()
|
||||
});
|
||||
}
|
||||
assert_eq!(built, 1, "three requests, one agent");
|
||||
// Another address is another agent, and a new setting drops the ones before it.
|
||||
keep("kept", Some("http://127.0.0.1:7890"), |proxy| {
|
||||
assert!(proxy.is_some());
|
||||
built += 1;
|
||||
ureq::AgentBuilder::new().build()
|
||||
});
|
||||
assert_eq!(built, 2, "a second address needs a second agent");
|
||||
set_proxy(Some("http://127.0.0.1:7891".into()));
|
||||
keep("kept", Some("http://127.0.0.1:7891"), |_| {
|
||||
built += 1;
|
||||
ureq::AgentBuilder::new().build()
|
||||
});
|
||||
assert_eq!(built, 3, "a new setting drops the agents kept before it");
|
||||
set_proxy(None);
|
||||
}
|
||||
}
|
||||
@@ -132,11 +132,11 @@ MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher settings`
|
||||
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off.
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it).
|
||||
|
||||
horizon-launcher settings [KEY] [VALUE]
|
||||
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation)
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy)
|
||||
- `VALUE`: The new value (with KEY)
|
||||
|
||||
MCP: `settings`.
|
||||
|
||||
@@ -19,18 +19,24 @@ const MAX: u64 = 1 << 30;
|
||||
|
||||
/// The HTTP agent the sources share, reaching `url` through the player's proxy when one is set
|
||||
/// (`proxy`). Modrinth asks for a user agent that names the project.
|
||||
///
|
||||
/// It is kept between downloads: a fresh agent starts a new connection pool, so one per file would
|
||||
/// be a handshake per file, and a modpack brings hundreds.
|
||||
pub(super) fn agent(url: &str) -> ureq::Agent {
|
||||
crate::proxy::agent(
|
||||
ureq::AgentBuilder::new()
|
||||
let spec = crate::proxy::spec_for(url);
|
||||
horizon_port_pack::net::keep("browser", spec.as_deref(), |proxy| {
|
||||
let builder = ureq::AgentBuilder::new()
|
||||
.user_agent(&format!(
|
||||
"ludovic111/horizon horizon-launcher/{} (github.com/ludovic111/horizon)",
|
||||
crate::backend::VERSION
|
||||
))
|
||||
.timeout_connect(std::time::Duration::from_secs(15))
|
||||
.timeout_read(std::time::Duration::from_secs(60)),
|
||||
url,
|
||||
)
|
||||
.build()
|
||||
.timeout_read(std::time::Duration::from_secs(60));
|
||||
match proxy {
|
||||
Some(proxy) => builder.proxy(proxy).build(),
|
||||
None => builder.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a file can't be downloaded at all, before any byte is read.
|
||||
|
||||
+1
-1
@@ -731,7 +731,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
let value = value.ok_or("settings KEY VALUE: the value is missing")?;
|
||||
set_setting(&mut settings, &key, &value)?;
|
||||
settings.save();
|
||||
horizon_launcher::proxy::announce();
|
||||
horizon_launcher::proxy::announce(settings.proxy.clone());
|
||||
}
|
||||
let all = settings_json(&settings);
|
||||
if as_json {
|
||||
|
||||
@@ -2721,7 +2721,9 @@ impl Launcher {
|
||||
}
|
||||
_ if paste => {
|
||||
if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
|
||||
this.proxy_input.push_str(text.trim());
|
||||
// A copied line, not a copied page: the field takes one address.
|
||||
let line = text.lines().next().unwrap_or(&text);
|
||||
this.proxy_input.push_str(line.trim());
|
||||
}
|
||||
}
|
||||
_ if !k.modifiers.control && !k.modifiers.platform => {
|
||||
@@ -2731,7 +2733,10 @@ impl Launcher {
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
this.proxy_input.truncate(256);
|
||||
// `truncate` counts bytes, and a cut inside a character panics: count characters.
|
||||
if this.proxy_input.chars().count() > 256 {
|
||||
this.proxy_input = this.proxy_input.chars().take(256).collect();
|
||||
}
|
||||
this.save_proxy();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
@@ -2756,7 +2761,7 @@ impl Launcher {
|
||||
let value = self.proxy_input.trim().to_owned();
|
||||
self.settings.proxy = (!value.is_empty()).then_some(value);
|
||||
self.settings.save();
|
||||
horizon_launcher::proxy::announce();
|
||||
horizon_launcher::proxy::announce(self.settings.proxy.clone());
|
||||
}
|
||||
|
||||
/// Settings' Microsoft accounts: who is signed in and plays, the sign-in with its code. The
|
||||
@@ -3672,7 +3677,7 @@ fn main() {
|
||||
backend::exe_path();
|
||||
// What the launcher downloads goes through the player's proxy, if they set one: the parts
|
||||
// that build their own agents (`horizon-port-pack`) are told before anything starts.
|
||||
horizon_launcher::proxy::announce();
|
||||
horizon_launcher::proxy::announce(horizon_launcher::backend::Settings::load().proxy);
|
||||
if let Some(code) = cli::run() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
+66
-150
@@ -9,23 +9,35 @@
|
||||
//! Servers page's calls to a game server's API (`servers.rs`) build their agent without it: they
|
||||
//! are on loopback and asked every second.
|
||||
//!
|
||||
//! The jars an import downloads are fetched by `horizon-port-pack`, which builds its own agents and
|
||||
//! keeps these same `NO_PROXY` rules (`crates/horizon-port-pack/src/net.rs`). The two crates share
|
||||
//! no code, so a change to them here belongs there too.
|
||||
//! Which hosts are reached directly, and how an address is read, are `horizon-port-pack`'s rules
|
||||
//! (`crates/horizon-port-pack/src/net.rs`): the jars an import fetches are downloaded there, and
|
||||
//! the same hosts have to be left alone on both sides. This module adds what is the launcher's
|
||||
//! own — the setting in `settings.json`, kept in memory so a request doesn't read the file — and
|
||||
//! hands the address to the parts that build their agents.
|
||||
use crate::backend::Settings;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// The proxy to reach `url` through: the one set for the launcher or in the environment, unless
|
||||
/// The launcher's setting, as `announce` last read it, or None before it has: a request shouldn't
|
||||
/// read `settings.json` every time, and an import fetches hundreds of jars.
|
||||
static SET: Mutex<Option<Option<String>>> = Mutex::new(None);
|
||||
|
||||
/// The proxy address to reach `url` through: the launcher's setting, else the environment's, unless
|
||||
/// `url` is a local address or `NO_PROXY` names its host.
|
||||
pub fn for_url(url: &str) -> Option<ureq::Proxy> {
|
||||
let scheme = scheme_of(url)?;
|
||||
let host = host_of(url)?;
|
||||
if local(host) || excluded(host, &no_proxy()) {
|
||||
pub fn spec_for(url: &str) -> Option<String> {
|
||||
let scheme = horizon_port_pack::net::scheme_of(url)?;
|
||||
if horizon_port_pack::net::direct(url) {
|
||||
return None;
|
||||
}
|
||||
ureq::Proxy::new(written(scheme)?).ok()
|
||||
setting().or_else(|| horizon_port_pack::net::env_for(scheme))
|
||||
}
|
||||
|
||||
/// `builder`, reaching `url` through the proxy when one is set.
|
||||
/// The proxy to reach `url` through: `spec_for`'s address, read as one.
|
||||
pub fn for_url(url: &str) -> Option<ureq::Proxy> {
|
||||
spec_for(url).and_then(|spec| horizon_port_pack::net::parse(&spec))
|
||||
}
|
||||
|
||||
/// `builder`, reaching `url` through the proxy when one is set. For the parts that ask for a few
|
||||
/// addresses; the ones that fetch file after file keep their agent instead (`keep`).
|
||||
pub fn agent(builder: ureq::AgentBuilder, url: &str) -> ureq::AgentBuilder {
|
||||
match for_url(url) {
|
||||
Some(proxy) => builder.proxy(proxy),
|
||||
@@ -39,19 +51,17 @@ pub fn refuse(spec: &str) -> Option<String> {
|
||||
if spec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let scheme = spec.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
|
||||
if matches!(
|
||||
scheme.as_deref(),
|
||||
Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h")
|
||||
) {
|
||||
if horizon_port_pack::net::socks(spec) {
|
||||
return Some(format!(
|
||||
"{spec} is a SOCKS proxy; Horizon downloads through an HTTP proxy, such as the \
|
||||
http:// address your proxy app also listens on."
|
||||
));
|
||||
}
|
||||
// `Proxy::new` takes `http://` without a host, which would leave a download failing with
|
||||
// nothing said about the proxy.
|
||||
if ureq::Proxy::new(spec).is_err() || host_of(spec).is_none_or(str::is_empty) {
|
||||
// `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!(
|
||||
"{spec} isn't a proxy address. Write it as http://host:port."
|
||||
));
|
||||
@@ -59,97 +69,46 @@ pub fn refuse(spec: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use. Called at
|
||||
/// start and whenever the player changes the setting; the hosts `NO_PROXY` names stay direct there
|
||||
/// too, since it reads the same list.
|
||||
pub fn announce() {
|
||||
horizon_port_pack::net::set_proxy(written("https"));
|
||||
/// Tells the parts that build their own agents (`horizon-port-pack`) which proxy to use, and keeps
|
||||
/// the setting for the launcher's own requests. Called with what `settings.json` holds: at start,
|
||||
/// and whenever the player changes the setting.
|
||||
///
|
||||
/// The environment's is left to them: they read it per address (`HTTPS_PROXY` for an https jar,
|
||||
/// `HTTP_PROXY` for an http one), the way the launcher does for its own.
|
||||
pub fn announce(setting: Option<String>) {
|
||||
let spec = setting
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty());
|
||||
*held() = Some(spec.clone());
|
||||
horizon_port_pack::net::set_proxy(spec);
|
||||
}
|
||||
|
||||
/// The proxy as it is written down: the launcher's, else the environment's for `scheme`.
|
||||
fn written(scheme: &str) -> Option<String> {
|
||||
/// The launcher's setting: read from `settings.json` the first time it is needed and kept after
|
||||
/// that, so a download doesn't read the file once per file.
|
||||
fn setting() -> Option<String> {
|
||||
let mut set = held();
|
||||
match set.clone() {
|
||||
Some(kept) => kept,
|
||||
None => {
|
||||
let read = written();
|
||||
*set = Some(read.clone());
|
||||
read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's setting as it is written down, or None when there is none.
|
||||
fn written() -> Option<String> {
|
||||
Settings::load()
|
||||
.proxy
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty())
|
||||
.or_else(|| from_env(scheme))
|
||||
}
|
||||
|
||||
/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling
|
||||
/// first and the lower-case one after, as the tools that write them do.
|
||||
fn from_env(scheme: &str) -> Option<String> {
|
||||
let names: &[&str] = if scheme.eq_ignore_ascii_case("http") {
|
||||
&["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
|
||||
} else {
|
||||
&["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
|
||||
};
|
||||
names.iter().find_map(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| v.trim().to_owned())
|
||||
.filter(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is.
|
||||
fn no_proxy() -> String {
|
||||
["NO_PROXY", "no_proxy"]
|
||||
.iter()
|
||||
.find_map(|name| std::env::var(name).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The host part of a URL (`https://user@host:port/path` is `host`).
|
||||
fn host_of(url: &str) -> Option<&str> {
|
||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let rest = rest.split(['/', '?', '#']).next()?;
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
|
||||
Some(match rest.rfind(':') {
|
||||
// A port follows the last colon; an IPv6 literal keeps the colons inside its brackets.
|
||||
Some(at) if !rest[at..].contains(']') => &rest[..at],
|
||||
_ => rest,
|
||||
})
|
||||
}
|
||||
|
||||
/// `https` in `https://…`, or None for anything that isn't an absolute address.
|
||||
fn scheme_of(url: &str) -> Option<&str> {
|
||||
url.split_once("://").map(|(scheme, _)| scheme)
|
||||
}
|
||||
|
||||
/// Whether `host` is this computer: the game's own API and a server on the same machine.
|
||||
fn local(host: &str) -> bool {
|
||||
let host = bare(host);
|
||||
host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host == "::1"
|
||||
|| host == "0.0.0.0"
|
||||
|| host.starts_with("127.")
|
||||
}
|
||||
|
||||
/// Whether `host` is in `NO_PROXY`: `*`, `host`, `.domain`, `*.domain` and `host:port` entries.
|
||||
fn excluded(host: &str, no_proxy: &str) -> bool {
|
||||
let host = bare(host);
|
||||
no_proxy
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|e| !e.is_empty())
|
||||
.any(|entry| {
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
let entry = entry.trim_start_matches('*').trim_start_matches('.');
|
||||
let entry = match entry.rfind(':') {
|
||||
Some(at) if !entry[at..].contains(']') => &entry[..at],
|
||||
_ => entry,
|
||||
};
|
||||
let entry = bare(entry);
|
||||
!entry.is_empty() && (host == entry || host.ends_with(&format!(".{entry}")))
|
||||
})
|
||||
}
|
||||
|
||||
/// A host without the brackets an IPv6 literal wears, lower case.
|
||||
fn bare(host: &str) -> String {
|
||||
host.trim().trim_matches(['[', ']']).to_ascii_lowercase()
|
||||
/// The setting's lock, even after a thread panicked while holding it: an address lost to a panic
|
||||
/// is worse than an old one kept.
|
||||
fn held() -> std::sync::MutexGuard<'static, Option<Option<String>>> {
|
||||
SET.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -157,58 +116,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_host_is_read_out_of_the_address() {
|
||||
assert_eq!(
|
||||
host_of("https://cdn.modrinth.com/v2/x.jar"),
|
||||
Some("cdn.modrinth.com")
|
||||
);
|
||||
assert_eq!(
|
||||
host_of("https://user:[email protected]:8443/a"),
|
||||
Some("piston-meta.mojang.com")
|
||||
);
|
||||
assert_eq!(
|
||||
host_of("http://127.0.0.1:8130/api/v1/status"),
|
||||
Some("127.0.0.1")
|
||||
);
|
||||
assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]"));
|
||||
assert_eq!(host_of("https://example.com?x=1"), Some("example.com"));
|
||||
assert_eq!(host_of("https://example.com#top"), Some("example.com"));
|
||||
fn a_local_address_takes_no_proxy() {
|
||||
assert_eq!(spec_for("http://127.0.0.1:8130/api/v1/status"), None);
|
||||
assert_eq!(spec_for("http://localhost/api/v1/status"), None);
|
||||
}
|
||||
|
||||
/// An address the launcher knows nothing about is one nothing is written for either.
|
||||
#[test]
|
||||
fn only_absolute_addresses_have_a_scheme() {
|
||||
assert_eq!(scheme_of("https://a.b"), Some("https"));
|
||||
assert_eq!(scheme_of("http://a.b"), Some("http"));
|
||||
assert_eq!(scheme_of("a.b/c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_computer_is_never_proxied() {
|
||||
for host in [
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"[::1]",
|
||||
"::1",
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
] {
|
||||
assert!(local(host), "{host}");
|
||||
}
|
||||
for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] {
|
||||
assert!(!local(host), "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_lists_hosts_and_their_subdomains() {
|
||||
let list = "*.internal, .corp.example, mojang.com, github.com:443, *";
|
||||
assert!(excluded("anything.at.all", list));
|
||||
assert!(excluded("wiki.corp.example", "corp.example"));
|
||||
assert!(excluded("piston-meta.mojang.com", "mojang.com"));
|
||||
assert!(excluded("github.com", "github.com:443"));
|
||||
assert!(!excluded("notmojang.com", "mojang.com"));
|
||||
assert!(!excluded("modrinth.com", " .corp.example , mojang.com "));
|
||||
assert!(!excluded("modrinth.com", ""));
|
||||
fn a_relative_address_has_no_scheme_to_read_a_proxy_for() {
|
||||
assert_eq!(spec_for("data/a.jar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user