| 1 | //! The proxy a relay connection, and only a relay connection, goes through: `HTTPS_PROXY` |
| 2 | //! (`HTTP_PROXY` for `ws://`), then `ALL_PROXY`, each in lower case too, short of `NO_PROXY`; |
| 3 | //! else the system's: macOS's network settings, Windows's Internet settings, GNOME's. Only |
| 4 | //! HTTP proxies, reached by `CONNECT`, with a name and password where the URL has them; a |
| 5 | //! proxy auto-configuration script is not read. |
| 6 | |
| 7 | use base64::Engine; |
| 8 | use std::sync::Mutex; |
| 9 | |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | pub struct Proxy { |
| 12 | pub host: String, |
| 13 | pub port: u16, |
| 14 | /// The name and password `Proxy-Authorization` sends. |
| 15 | pub credentials: Option<(String, String)>, |
| 16 | } |
| 17 | |
| 18 | impl Proxy { |
| 19 | /// `http://[name:password@]host[:port][/]`, or `host:port`; none for another scheme. |
| 20 | pub fn parse(url: &str) -> Option<Self> { |
| 21 | let url = url.trim(); |
| 22 | let rest = match url.split_once("://") { |
| 23 | Some(("http" | "https", rest)) => rest, |
| 24 | Some(_) => return None, |
| 25 | None => url, |
| 26 | }; |
| 27 | let rest = rest.split('/').next()?; |
| 28 | let (credentials, authority) = match rest.rsplit_once('@') { |
| 29 | Some((credentials, authority)) => { |
| 30 | let (name, password) = credentials.split_once(':').unwrap_or((credentials, "")); |
| 31 | ( |
| 32 | Some((crate::live::decode(name), crate::live::decode(password))), |
| 33 | authority, |
| 34 | ) |
| 35 | } |
| 36 | None => (None, rest), |
| 37 | }; |
| 38 | let (host, port) = match authority.rsplit_once(':') { |
| 39 | Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?), |
| 40 | _ => (authority, 80), |
| 41 | }; |
| 42 | let host = host.trim_start_matches('[').trim_end_matches(']'); |
| 43 | (!host.is_empty()).then(|| Self { |
| 44 | host: host.to_owned(), |
| 45 | port, |
| 46 | credentials, |
| 47 | }) |
| 48 | } |
| 49 | |
| 50 | /// The `Proxy-Authorization` header's value, where it has credentials. |
| 51 | pub fn authorization(&self) -> Option<String> { |
| 52 | let (name, password) = self.credentials.as_ref()?; |
| 53 | let token = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}")); |
| 54 | Some(format!("Basic {token}")) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | impl std::fmt::Display for Proxy { |
| 59 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { |
| 60 | write!(f, "{}:{}", self.host, self.port) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// A proxy `use_proxy` set in place of what the environment and the system say. |
| 65 | static CHOSEN: Mutex<Option<Option<Proxy>>> = Mutex::new(None); |
| 66 | |
| 67 | /// Uses `proxy`, or no proxy with `Some(None)`, for every relay connection from now on; `None` |
| 68 | /// goes back to the environment's and the system's. |
| 69 | pub fn use_proxy(proxy: Option<Option<Proxy>>) { |
| 70 | *CHOSEN |
| 71 | .lock() |
| 72 | .unwrap_or_else(|poisoned| poisoned.into_inner()) = proxy; |
| 73 | } |
| 74 | |
| 75 | /// The proxy a connection to `host` goes through, over TLS where `tls`. |
| 76 | pub fn for_host(host: &str, tls: bool) -> Option<Proxy> { |
| 77 | if let Some(chosen) = CHOSEN.lock().unwrap_or_else(|p| p.into_inner()).clone() { |
| 78 | return chosen; |
| 79 | } |
| 80 | let variable = |name: &str| { |
| 81 | [name.to_owned(), name.to_lowercase()] |
| 82 | .into_iter() |
| 83 | .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) |
| 84 | }; |
| 85 | let named = [if tls { "HTTPS_PROXY" } else { "HTTP_PROXY" }, "ALL_PROXY"] |
| 86 | .into_iter() |
| 87 | .find_map(variable); |
| 88 | match named { |
| 89 | Some(url) => { |
| 90 | let bypass = variable("NO_PROXY").unwrap_or_default(); |
| 91 | (!bypassed(host, bypass.split(','))).then(|| Proxy::parse(&url))? |
| 92 | } |
| 93 | None => system(host, tls), |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// Whether `host` is one of `list`'s: a name, a suffix after a dot, or `*` for every host. |
| 98 | fn bypassed<'a>(host: &str, list: impl Iterator<Item = &'a str>) -> bool { |
| 99 | let host = host.to_ascii_lowercase(); |
| 100 | list.map(|entry| { |
| 101 | entry |
| 102 | .trim() |
| 103 | .trim_start_matches("*.") |
| 104 | .trim_start_matches('.') |
| 105 | }) |
| 106 | .filter(|entry| !entry.is_empty()) |
| 107 | .any(|entry| { |
| 108 | let entry = entry.to_ascii_lowercase(); |
| 109 | entry == "*" || host == entry || host.ends_with(&format!(".{entry}")) |
| 110 | }) |
| 111 | } |
| 112 | |
| 113 | /// What `scutil --proxy` says of the network settings in use. |
| 114 | #[cfg(target_os = "macos")] |
| 115 | fn system(host: &str, tls: bool) -> Option<Proxy> { |
| 116 | let output = std::process::Command::new("/usr/sbin/scutil") |
| 117 | .arg("--proxy") |
| 118 | .output() |
| 119 | .ok()?; |
| 120 | let text = String::from_utf8(output.stdout).ok()?; |
| 121 | let value = |key: &str| { |
| 122 | text.lines().find_map(|line| { |
| 123 | let (name, value) = line.split_once(" : ")?; |
| 124 | (name.trim() == key).then(|| value.trim().to_owned()) |
| 125 | }) |
| 126 | }; |
| 127 | let kind = if tls { "HTTPS" } else { "HTTP" }; |
| 128 | if value(&format!("{kind}Enable")).as_deref() != Some("1") { |
| 129 | return None; |
| 130 | } |
| 131 | let exceptions: Vec<String> = text |
| 132 | .lines() |
| 133 | .skip_while(|line| !line.contains("ExceptionsList")) |
| 134 | .skip(1) |
| 135 | .take_while(|line| !line.contains('}')) |
| 136 | .filter_map(|line| Some(line.split_once(" : ")?.1.trim().to_owned())) |
| 137 | .collect(); |
| 138 | if bypassed(host, exceptions.iter().map(String::as_str)) { |
| 139 | return None; |
| 140 | } |
| 141 | Some(Proxy { |
| 142 | host: value(&format!("{kind}Proxy"))?, |
| 143 | port: value(&format!("{kind}Port"))?.parse().ok()?, |
| 144 | credentials: None, |
| 145 | }) |
| 146 | } |
| 147 | |
| 148 | /// What Windows's Internet settings say, as WinHTTP and Internet Explorer read them for this |
| 149 | /// user: `ProxyServer`, as `host:port` or `https=host:port;http=...`, where `ProxyEnable`. |
| 150 | #[cfg(windows)] |
| 151 | fn system(host: &str, tls: bool) -> Option<Proxy> { |
| 152 | let key = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; |
| 153 | if registry::dword(key, "ProxyEnable")? == 0 { |
| 154 | return None; |
| 155 | } |
| 156 | let server = registry::string(key, "ProxyServer")?; |
| 157 | let overrides = registry::string(key, "ProxyOverride").unwrap_or_default(); |
| 158 | if bypassed( |
| 159 | host, |
| 160 | overrides.split(';').filter(|entry| *entry != "<local>"), |
| 161 | ) { |
| 162 | return None; |
| 163 | } |
| 164 | let scheme = if tls { "https=" } else { "http=" }; |
| 165 | let server = match server.contains('=') { |
| 166 | true => server |
| 167 | .split(';') |
| 168 | .find_map(|entry| entry.trim().strip_prefix(scheme))?, |
| 169 | false => server.as_str(), |
| 170 | }; |
| 171 | Proxy::parse(server) |
| 172 | } |
| 173 | |
| 174 | #[cfg(windows)] |
| 175 | #[allow(unsafe_code)] |
| 176 | mod registry { |
| 177 | use windows_sys::Win32::System::Registry::{ |
| 178 | HKEY_CURRENT_USER, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegGetValueW, |
| 179 | }; |
| 180 | |
| 181 | fn wide(text: &str) -> Vec<u16> { |
| 182 | text.encode_utf16().chain([0]).collect() |
| 183 | } |
| 184 | |
| 185 | pub fn dword(key: &str, name: &str) -> Option<u32> { |
| 186 | let (key, name) = (wide(key), wide(name)); |
| 187 | let mut value = 0u32; |
| 188 | let mut size = 4u32; |
| 189 | // SAFETY: the key and name are NUL-terminated and outlive the call; the value is four |
| 190 | // bytes, as `size` says. |
| 191 | let result = unsafe { |
| 192 | RegGetValueW( |
| 193 | HKEY_CURRENT_USER, |
| 194 | key.as_ptr(), |
| 195 | name.as_ptr(), |
| 196 | RRF_RT_REG_DWORD, |
| 197 | std::ptr::null_mut(), |
| 198 | (&mut value as *mut u32).cast(), |
| 199 | &mut size, |
| 200 | ) |
| 201 | }; |
| 202 | (result == 0).then_some(value) |
| 203 | } |
| 204 | |
| 205 | pub fn string(key: &str, name: &str) -> Option<String> { |
| 206 | let (key, name) = (wide(key), wide(name)); |
| 207 | let mut buffer = vec![0u16; 2048]; |
| 208 | let mut size = (buffer.len() * 2) as u32; |
| 209 | // SAFETY: as in `dword`, with `size` the buffer's length in bytes. |
| 210 | let result = unsafe { |
| 211 | RegGetValueW( |
| 212 | HKEY_CURRENT_USER, |
| 213 | key.as_ptr(), |
| 214 | name.as_ptr(), |
| 215 | RRF_RT_REG_SZ, |
| 216 | std::ptr::null_mut(), |
| 217 | buffer.as_mut_ptr().cast(), |
| 218 | &mut size, |
| 219 | ) |
| 220 | }; |
| 221 | if result != 0 { |
| 222 | return None; |
| 223 | } |
| 224 | let length = buffer |
| 225 | .iter() |
| 226 | .position(|unit| *unit == 0) |
| 227 | .unwrap_or(buffer.len()); |
| 228 | Some(String::from_utf16_lossy(&buffer[..length])) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /// What GNOME's proxy settings say, where they are manual. |
| 233 | #[cfg(not(any(target_os = "macos", windows)))] |
| 234 | fn system(host: &str, tls: bool) -> Option<Proxy> { |
| 235 | let get = |schema: &str, key: &str| { |
| 236 | let output = std::process::Command::new("gsettings") |
| 237 | .args(["get", schema, key]) |
| 238 | .output() |
| 239 | .ok()?; |
| 240 | let text = String::from_utf8(output.stdout).ok()?; |
| 241 | Some(text.trim().trim_matches('\'').to_owned()) |
| 242 | }; |
| 243 | if get("org.gnome.system.proxy", "mode")? != "manual" { |
| 244 | return None; |
| 245 | } |
| 246 | let ignored = get("org.gnome.system.proxy", "ignore-hosts").unwrap_or_default(); |
| 247 | let ignored = ignored.trim_matches(['[', ']']).replace('\'', ""); |
| 248 | if bypassed(host, ignored.split(',')) { |
| 249 | return None; |
| 250 | } |
| 251 | let schema = if tls { |
| 252 | "org.gnome.system.proxy.https" |
| 253 | } else { |
| 254 | "org.gnome.system.proxy.http" |
| 255 | }; |
| 256 | Some(Proxy { |
| 257 | host: get(schema, "host").filter(|host| !host.is_empty())?, |
| 258 | port: get(schema, "port")? |
| 259 | .parse() |
| 260 | .ok() |
| 261 | .filter(|port| *port != 0)?, |
| 262 | credentials: None, |
| 263 | }) |
| 264 | } |
| 265 | |
| 266 | #[cfg(test)] |
| 267 | mod tests { |
| 268 | use super::*; |
| 269 | |
| 270 | #[test] |
| 271 | fn proxies_read_as_written() { |
| 272 | assert_eq!( |
| 273 | Proxy::parse("http://ada:p%40ss@proxy.example:3128/"), |
| 274 | Some(Proxy { |
| 275 | host: "proxy.example".into(), |
| 276 | port: 3128, |
| 277 | credentials: Some(("ada".into(), "p@ss".into())), |
| 278 | }) |
| 279 | ); |
| 280 | assert_eq!(Proxy::parse("proxy:8080").unwrap().port, 8080); |
| 281 | assert_eq!(Proxy::parse("http://[::1]:8080").unwrap().host, "::1"); |
| 282 | assert!(Proxy::parse("socks5://proxy:1080").is_none()); |
| 283 | let proxy = Proxy::parse("http://ada:secret@proxy:1").unwrap(); |
| 284 | assert_eq!(proxy.authorization().unwrap(), "Basic YWRhOnNlY3JldA=="); |
| 285 | let list = || ["localhost", ".internal", "*.corp.example"].into_iter(); |
| 286 | assert!(bypassed("localhost", list()) && bypassed("files.internal", list())); |
| 287 | assert!(bypassed("a.corp.example", list()) && !bypassed("relay.example", list())); |
| 288 | assert!(bypassed("anything", ["*"].into_iter())); |
| 289 | } |
| 290 | } |