diff --git a/Cargo.lock b/Cargo.lock index 9fc54e4..02788f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,6 +760,16 @@ dependencies = [ "windows", ] +[[package]] +name = "robius-proxy" +version = "0.2.0" +dependencies = [ + "cfg-if", + "log", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "rustc-demangle" version = "0.1.25" diff --git a/crates/proxy/Cargo.toml b/crates/proxy/Cargo.toml new file mode 100644 index 0000000..b4755cb --- /dev/null +++ b/crates/proxy/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "robius-proxy" +version.workspace = true +edition.workspace = true +authors = [ + "Tyrese Luo ", + "Project Robius Maintainers", +] +homepage.workspace = true +categories.workspace = true +license.workspace = true +readme = "README.md" +repository.workspace = true + +[dependencies] +cfg-if.workspace = true +log = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { workspace = true, features = ["NSWorkspace"] } +objc2-foundation = { workspace = true, features = ["NSString", "NSURL"] } diff --git a/crates/proxy/README.md b/crates/proxy/README.md new file mode 100644 index 0000000..0f24a6f --- /dev/null +++ b/crates/proxy/README.md @@ -0,0 +1,51 @@ +# `robius-proxy` + +This crate provides easy Rust interfaces to get and apply proxy across multiple platforms, including: + +- Modes: direct, manual (HTTP/HTTPS/SOCKS), PAC URL, WPAD. +- Platforms: + 1. **macOS** : via `networksetup`/`scutil`; others return + 2. Others(Planned): `Error::Unsupported` until added. + +## Usage + +```rust +use robius_proxy::{BypassList, ProxyEndpoint, ProxyManager, ProxyMode, ProxySettings, ProxyState}; + +let manager = ProxyManager::new()?; +let current = manager.current()?; + +println!("Current mode: {:?}", current.mode); +println!("Proxy enabled: {}", current.is_enabled()); + +let state = ProxyState::manual(ProxySettings { + http: Some(ProxyEndpoint::new("proxy.local", 8080)), + https: None, + socks: None, + bypass: BypassList::new(vec!["localhost".into(), "127.0.0.1".into()]), +}); + +manager.apply(state)?; +``` + +## Examples + +- Show current configuration: `cargo run -p robius-proxy --example show_current` +- Set manual proxies (macOS): `cargo run -p robius-proxy --example set_manual` + +Examples invoke `networksetup` under the hood, so they may prompt for system +permissions depending on your macOS configuration. + +## Platform support + +- macOS: implemented via `networksetup` and `scutil`. +- Linux (planned): reserve hooks for NetworkManager, GNOME proxy settings, and + system-wide environment exports. +- Windows (planned): reserve hooks for WinHTTP/WinINET and per-connection + proxy configuration. +- Android (planned): reserve hooks for global HTTP proxy settings available to + device owner apps. +- iOS (planned): reserve hooks for CFNetwork proxy dictionaries. +- OpenHarmony (planned): reserve hooks for system proxy APIs when exposed. + +Contributions adding new platforms or improving parsing/validation are welcome. diff --git a/crates/proxy/examples/show_current.rs b/crates/proxy/examples/show_current.rs new file mode 100644 index 0000000..7b45499 --- /dev/null +++ b/crates/proxy/examples/show_current.rs @@ -0,0 +1,65 @@ +use robius_proxy::{ProxyManager, ProxyMode, ProxyState}; + +fn main() -> Result<(), Box> { + let manager = ProxyManager::new()?; + let state = manager.current()?; + let is_enabled = state.is_enabled(); + + println!("Proxy enabled => {}", is_enabled); + + if is_enabled { + println!("========= Current proxy configuration ========="); + print_state(&state); + } + + Ok(()) +} + +fn print_state(state: &ProxyState) { + match &state.mode { + ProxyMode::Direct => println!(" mode: Direct (no proxy)"), + ProxyMode::Manual(settings) => { + println!(" mode: Manual"); + if let Some(http) = &settings.http { + println!(" http: {}:{}", http.host, http.port); + } + if let Some(https) = &settings.https { + println!(" https: {}:{}", https.host, https.port); + } + if let Some(socks) = &settings.socks { + println!(" socks: {}:{}", socks.host, socks.port); + } + if settings.bypass.entries.is_empty() { + println!(" bypass: (none)"); + } else { + println!(" bypass:"); + for entry in &settings.bypass.entries { + println!(" - {entry}"); + } + } + } + ProxyMode::AutoConfigUrl { url, bypass } => { + println!(" mode: PAC url"); + println!(" url: {url}"); + if bypass.entries.is_empty() { + println!(" bypass: (none)"); + } else { + println!(" bypass:"); + for entry in &bypass.entries { + println!(" - {entry}"); + } + } + } + ProxyMode::AutoDiscovery { bypass } => { + println!(" mode: Auto-discovery (WPAD)"); + if bypass.entries.is_empty() { + println!(" bypass: (none)"); + } else { + println!(" bypass:"); + for entry in &bypass.entries { + println!(" - {entry}"); + } + } + } + } +} diff --git a/crates/proxy/src/error.rs b/crates/proxy/src/error.rs new file mode 100644 index 0000000..c3c6ab1 --- /dev/null +++ b/crates/proxy/src/error.rs @@ -0,0 +1,44 @@ +use std::{fmt, io}; + +/// Result alias for the proxy crate. +pub type Result = std::result::Result; + +/// Errors that can arise while interacting with system proxy settings. +#[derive(Debug)] +pub enum Error { + /// The operation is not supported on this platform. + Unsupported, + /// Input data was invalid for the target platform. + InvalidInput(&'static str), + /// A command failed to execute successfully. + CommandFailed { + command: String, + stderr: String, + }, + /// Parsing system proxy data failed. + Parse(String), + /// I/O failure while invoking platform facilities. + Io(io::Error), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unsupported => write!(f, "operation not supported on this platform"), + Self::InvalidInput(reason) => write!(f, "invalid proxy input: {reason}"), + Self::CommandFailed { command, stderr } => { + write!(f, "command `{command}` failed: {stderr}") + } + Self::Parse(reason) => write!(f, "failed to parse proxy data: {reason}"), + Self::Io(e) => write!(f, "io error: {e}"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs new file mode 100644 index 0000000..3d10111 --- /dev/null +++ b/crates/proxy/src/lib.rs @@ -0,0 +1,168 @@ +//! Cross-platform access to system proxy settings. +//! +//! The API is intentionally small and capability-oriented: you describe the +//! desired [`ProxyState`] and the crate applies it using the native facilities +//! of the underlying platform. Only macOS is implemented right now; other +//! platforms fall back to [`Error::Unsupported`]. +//! +//! The abstractions are designed to be forward-compatible with additional +//! platforms such as Linux, Windows, Android, iOS, and OpenHarmony. + +mod error; +mod sys; + +pub use crate::error::{Error, Result}; + +/// Uniform representation of a proxy endpoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProxyEndpoint { + /// Hostname or IP of the proxy. + pub host: String, + /// TCP port on which the proxy listens. + pub port: u16, + /// Optional credentials when the proxy requires authentication. + pub credentials: Option, +} + +impl ProxyEndpoint { + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + credentials: None, + } + } + + pub fn with_credentials( + host: impl Into, + port: u16, + username: impl Into, + password: Option, + ) -> Self { + Self { + host: host.into(), + port, + credentials: Some(Credentials { + username: username.into(), + password, + }), + } + } +} + +/// Credentials for proxies that require authentication. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Credentials { + pub username: String, + pub password: Option, +} + +/// List of hosts, domains, or CIDRs that should bypass the proxy. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BypassList { + pub entries: Vec, +} + +impl BypassList { + pub fn new(entries: impl Into>) -> Self { + Self { + entries: entries.into(), + } + } +} + +/// Proxy configuration for manual modes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProxySettings { + pub http: Option, + pub https: Option, + pub socks: Option, + pub bypass: BypassList, +} + +/// Modes supported by the abstraction layer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProxyMode { + /// No proxy usage. + Direct, + /// User-specified endpoints. + Manual(ProxySettings), + /// Proxy auto-configuration (PAC) script URL. + AutoConfigUrl { + url: String, + bypass: BypassList, + }, + /// WPAD / automatic discovery. + AutoDiscovery { + bypass: BypassList, + }, +} + +/// High-level proxy state exposed to callers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProxyState { + pub mode: ProxyMode, +} + +impl ProxyState { + pub fn direct() -> Self { + Self { + mode: ProxyMode::Direct, + } + } + + pub fn manual(settings: ProxySettings) -> Self { + Self { + mode: ProxyMode::Manual(settings), + } + } + + pub fn pac(url: impl Into, bypass: BypassList) -> Self { + Self { + mode: ProxyMode::AutoConfigUrl { + url: url.into(), + bypass, + }, + } + } + + pub fn auto_discovery(bypass: BypassList) -> Self { + Self { + mode: ProxyMode::AutoDiscovery { bypass }, + } + } + + /// Returns true when any proxy mode is enabled (manual/PAC/WPAD). + pub fn is_enabled(&self) -> bool { + !matches!(self.mode, ProxyMode::Direct) + } +} + +/// Entry point for interacting with system proxy settings. +pub struct ProxyManager { + inner: sys::Manager, +} + +impl ProxyManager { + /// Constructs a manager bound to the current platform. + pub fn new() -> Result { + Ok(Self { + inner: sys::Manager::new()?, + }) + } + + /// Reads the current proxy state from the system. + pub fn current(&self) -> Result { + self.inner.current() + } + + /// Applies the given proxy state to the system. + pub fn apply(&self, state: ProxyState) -> Result<()> { + self.inner.apply(state) + } + + /// Convenience helper to disable all proxies. + pub fn disable(&self) -> Result<()> { + self.apply(ProxyState::direct()) + } +} diff --git a/crates/proxy/src/sys.rs b/crates/proxy/src/sys.rs new file mode 100644 index 0000000..6976723 --- /dev/null +++ b/crates/proxy/src/sys.rs @@ -0,0 +1,9 @@ +cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + mod macos; + pub(crate) use macos::Manager; + } else { + mod unsupported; + pub(crate) use unsupported::Manager; + } +} diff --git a/crates/proxy/src/sys/macos.rs b/crates/proxy/src/sys/macos.rs new file mode 100644 index 0000000..a4e8909 --- /dev/null +++ b/crates/proxy/src/sys/macos.rs @@ -0,0 +1,446 @@ +use std::{ + collections::HashMap, + ffi::OsStr, + process::{Command, Stdio}, +}; + +use crate::{ + BypassList, Error, ProxyEndpoint, ProxyMode, ProxySettings, ProxyState, Result, +}; + +pub(crate) struct Manager { + // Network service names returned by `networksetup -listallnetworkservices`. + services: Vec, +} + +impl Manager { + pub(crate) fn new() -> Result { + Ok(Self { + services: list_services()?, + }) + } + + pub(crate) fn current(&self) -> Result { + let output = Command::new("scutil") + .arg("--proxy") + .output() + .map_err(Error::Io)?; + + if !output.status.success() { + return Err(Error::CommandFailed { + command: "scutil --proxy".to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_scutil_proxy(&stdout) + } + + pub(crate) fn apply(&self, state: ProxyState) -> Result<()> { + match state.mode { + ProxyMode::Direct => self.disable_all(), + ProxyMode::Manual(settings) => self.apply_manual(settings), + ProxyMode::AutoConfigUrl { url, bypass } => self.apply_pac(url, bypass), + ProxyMode::AutoDiscovery { bypass } => self.apply_auto_discovery(bypass), + } + } + + fn apply_manual(&self, mut settings: ProxySettings) -> Result<()> { + // Drop accidental empty entries so we do not feed blank values to `networksetup`. + settings.bypass.entries.retain(|s| !s.is_empty()); + for service in &self.services { + self.disable_auto(service)?; + + configure_endpoint( + service, + "-setwebproxy", + "-setwebproxystate", + settings.http.as_ref(), + )?; + configure_endpoint( + service, + "-setsecurewebproxy", + "-setsecurewebproxystate", + settings.https.as_ref(), + )?; + configure_endpoint( + service, + "-setsocksfirewallproxy", + "-setsocksfirewallproxystate", + settings.socks.as_ref(), + )?; + + self.set_bypass(service, &settings.bypass)?; + } + Ok(()) + } + + fn apply_pac(&self, url: String, bypass: BypassList) -> Result<()> { + if url.is_empty() { + return Err(Error::InvalidInput("PAC url cannot be empty")); + } + + for service in &self.services { + // PAC mode and manual proxies are mutually exclusive on macOS. + self.disable_manual(service)?; + run_networksetup([ + "-setautoproxyurl", + service.as_str(), + url.as_str(), + ])?; + run_networksetup(["-setautoproxystate", service.as_str(), "on"])?; + self.set_bypass(service, &bypass)?; + } + Ok(()) + } + + fn apply_auto_discovery(&self, bypass: BypassList) -> Result<()> { + for service in &self.services { + self.disable_manual(service)?; + run_networksetup(["-setproxyautodiscovery", service.as_str(), "on"])?; + run_networksetup(["-setautoproxystate", service.as_str(), "off"])?; + self.set_bypass(service, &bypass)?; + } + Ok(()) + } + + fn disable_all(&self) -> Result<()> { + for service in &self.services { + self.disable_manual(service)?; + self.disable_auto(service)?; + run_networksetup(["-setproxybypassdomains", service.as_str(), "Empty"])?; + } + Ok(()) + } + + fn disable_manual(&self, service: &str) -> Result<()> { + run_networksetup(["-setwebproxystate", service, "off"])?; + run_networksetup(["-setsecurewebproxystate", service, "off"])?; + run_networksetup(["-setsocksfirewallproxystate", service, "off"])?; + Ok(()) + } + + fn disable_auto(&self, service: &str) -> Result<()> { + run_networksetup(["-setautoproxystate", service, "off"])?; + run_networksetup(["-setproxyautodiscovery", service, "off"])?; + Ok(()) + } + + fn set_bypass(&self, service: &str, bypass: &BypassList) -> Result<()> { + if bypass.entries.is_empty() { + // "Empty" is a sentinel understood by `networksetup` to clear bypass entries. + return run_networksetup(["-setproxybypassdomains", service, "Empty"]); + } + + let mut args = Vec::with_capacity(2 + bypass.entries.len()); + args.push("-setproxybypassdomains".to_string()); + args.push(service.to_string()); + args.extend(bypass.entries.iter().cloned()); + run_networksetup(args) + } +} + +fn list_services() -> Result> { + let output = Command::new("networksetup") + .arg("-listallnetworkservices") + .stderr(Stdio::null()) + .output() + .map_err(Error::Io)?; + + if !output.status.success() { + return Err(Error::CommandFailed { + command: "networksetup -listallnetworkservices".to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let services: Vec = stdout + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with("An asterisk") || line.starts_with('*') { + None + } else { + Some(line.to_string()) + } + }) + .collect(); + + if services.is_empty() { + return Err(Error::Parse( + "networksetup returned no network services".to_string(), + )); + } + + Ok(services) +} + +fn parse_scutil_proxy(text: &str) -> Result { + let mut entries = HashMap::new(); + let mut bypass = Vec::new(); + let mut in_bypass = false; + + // The `scutil --proxy` output is a loose dictionary; walk it line by line. + for line in text.lines() { + let trimmed = line.trim(); + + if in_bypass { + if trimmed.starts_with('}') { + in_bypass = false; + continue; + } + if let Some((_, value)) = trimmed.split_once(':') { + let val = value.trim().trim_matches('"').to_string(); + if !val.is_empty() { + bypass.push(val); + } + } + continue; + } + + if trimmed.starts_with("ExceptionsList") { + in_bypass = true; + continue; + } + + if let Some((key, value)) = trimmed.split_once(" : ") { + entries.insert(key.trim().to_string(), value.trim().to_string()); + } + } + + let bypass = BypassList { entries: bypass }; + + if entries.get("ProxyAutoConfigEnable").map(|v| v == "1").unwrap_or(false) { + let url = entries + .get("ProxyAutoConfigURLString") + .map(|s| s.to_string()) + .unwrap_or_default(); + if url.is_empty() { + return Err(Error::Parse( + "ProxyAutoConfigEnable set but URL missing".to_string(), + )); + } + return Ok(ProxyState::pac(url, bypass)); + } + + if entries + .get("ProxyAutoDiscoveryEnable") + .map(|v| v == "1") + .unwrap_or(false) + { + return Ok(ProxyState::auto_discovery(bypass)); + } + + let mut settings = ProxySettings::default(); + + if entries.get("HTTPEnable").map(|v| v == "1").unwrap_or(false) { + if let Some(endpoint) = build_endpoint(entries.get("HTTPProxy"), entries.get("HTTPPort")) { + settings.http = Some(endpoint); + } + } + + if entries.get("HTTPSEnable").map(|v| v == "1").unwrap_or(false) { + if let Some(endpoint) = + build_endpoint(entries.get("HTTPSProxy"), entries.get("HTTPSPort")) + { + settings.https = Some(endpoint); + } + } + + if entries.get("SOCKSEnable").map(|v| v == "1").unwrap_or(false) { + if let Some(endpoint) = build_endpoint(entries.get("SOCKSProxy"), entries.get("SOCKSPort")) + { + settings.socks = Some(endpoint); + } + } + + if settings.http.is_some() || settings.https.is_some() || settings.socks.is_some() { + settings.bypass = bypass; + return Ok(ProxyState::manual(settings)); + } + + Ok(ProxyState::direct()) +} + +fn build_endpoint(host: Option<&String>, port: Option<&String>) -> Option { + let host = host?.trim(); + let port = port?.trim(); + + if host.is_empty() { + return None; + } + let port: u16 = port.parse().ok()?; + + Some(ProxyEndpoint { + host: host.to_string(), + port, + credentials: None, + }) +} + +fn configure_endpoint( + service: &str, + set_cmd: &str, + toggle_cmd: &str, + endpoint: Option<&ProxyEndpoint>, +) -> Result<()> { + match endpoint { + Some(ep) => { + let mut args = Vec::new(); + args.push(set_cmd.to_string()); + args.push(service.to_string()); + args.push(ep.host.clone()); + args.push(ep.port.to_string()); + + if let Some(credentials) = &ep.credentials { + args.push("authenticated".to_string()); + args.push(credentials.username.clone()); + if let Some(password) = &credentials.password { + args.push(password.clone()); + } + } + + run_networksetup(args)?; + run_networksetup([toggle_cmd, service, "on"]) + } + None => run_networksetup([toggle_cmd, service, "off"]), + } +} + +fn run_networksetup(args: I) -> Result<()> +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new("networksetup") + .args(args) + .output() + .map_err(Error::Io)?; + + if output.status.success() { + return Ok(()); + } + + Err(Error::CommandFailed { + command: "networksetup".to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_manual_settings() { + let text = r#" + { + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : proxy.local + HTTPSEnable : 1 + HTTPSPort : 7890 + HTTPSProxy : secure.local + SOCKSEnable : 1 + SOCKSPort : 7890 + SOCKSProxy : socks.local + ExceptionsList : { + 0 : localhost + 1 : 127.0.0.1 + } + } + "#; + + let state = parse_scutil_proxy(text).expect("parse"); + let mode = match state.mode { + ProxyMode::Manual(settings) => settings, + other => panic!("expected manual, got {other:?}"), + }; + + assert_eq!( + mode.http, + Some(ProxyEndpoint::new("proxy.local", 7890)) + ); + assert_eq!( + mode.https, + Some(ProxyEndpoint::new("secure.local", 7890)) + ); + assert_eq!( + mode.socks, + Some(ProxyEndpoint::new("socks.local", 7890)) + ); + assert_eq!( + mode.bypass.entries, + vec!["localhost".to_string(), "127.0.0.1".to_string()] + ); + } + + #[test] + fn parse_pac_mode() { + let text = r#" + { + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://pac.example.com/proxy.pac + ExceptionsList : { + 0 : intranet.local + } + } + "#; + + let state = parse_scutil_proxy(text).expect("parse"); + let (url, bypass) = match state.mode { + ProxyMode::AutoConfigUrl { url, bypass } => (url, bypass), + other => panic!("expected PAC, got {other:?}"), + }; + + assert_eq!(url, "http://pac.example.com/proxy.pac"); + assert_eq!(bypass.entries, vec!["intranet.local".to_string()]); + } + + #[test] + fn parse_auto_discovery() { + let text = r#" + { + ProxyAutoDiscoveryEnable : 1 + ExceptionsList : { + 0 : *.local + } + } + "#; + + let state = parse_scutil_proxy(text).expect("parse"); + let bypass = match state.mode { + ProxyMode::AutoDiscovery { bypass } => bypass, + other => panic!("expected auto discovery, got {other:?}"), + }; + + assert_eq!(bypass.entries, vec!["*.local".to_string()]); + } + + #[test] + fn parse_direct_when_empty() { + let text = r#" + { + HTTPEnable : 0 + HTTPSEnable : 0 + SOCKSEnable : 0 + }"#; + + let state = parse_scutil_proxy(text).expect("parse"); + assert!(matches!(state.mode, ProxyMode::Direct)); + } + + #[test] + fn pac_requires_url() { + let text = r#" + { + ProxyAutoConfigEnable : 1 + } + "#; + + let err = parse_scutil_proxy(text).expect_err("should fail"); + assert!(matches!(err, Error::Parse(_))); + } +} diff --git a/crates/proxy/src/sys/unsupported.rs b/crates/proxy/src/sys/unsupported.rs new file mode 100644 index 0000000..a6452dd --- /dev/null +++ b/crates/proxy/src/sys/unsupported.rs @@ -0,0 +1,17 @@ +use crate::{Error, ProxyState, Result}; + +pub(crate) struct Manager; + +impl Manager { + pub(crate) fn new() -> Result { + Err(Error::Unsupported) + } + + pub(crate) fn current(&self) -> Result { + Err(Error::Unsupported) + } + + pub(crate) fn apply(&self, _state: ProxyState) -> Result<()> { + Err(Error::Unsupported) + } +}