use std::collections::HashMap;
use crate::bypass::{BypassRules, ImplicitBypass};
use crate::endpoint::{ProxyEndpoint, ProxyEntry, Scheme};
use crate::error::Error;
use crate::mode::ProxyMode;
use super::gio_dl::{self, DBusConnection, Variant};
use super::sandbox::Sandbox;
const BUS_NAME: &str = "org.freedesktop.portal.Desktop";
const OBJECT_PATH: &str = "/org/freedesktop/portal/desktop";
const INTERFACE: &str = "org.freedesktop.portal.ProxyResolver";
const METHOD: &str = "Lookup";
const REPLY_SIGNATURE: &str = "(as)";
const TIMEOUT_MS: i32 = 5_000;
const DIRECT: &str = "direct://";
macro_rules! probe_host {
() => {
"proxy-watch-probe.invalid"
};
}
const PROBES: [(Scheme, &str); 5] = [
(Scheme::Http, concat!("http://", probe_host!(), "/")),
(Scheme::Https, concat!("https://", probe_host!(), "/")),
(Scheme::Ftp, concat!("ftp://", probe_host!(), "/")),
(Scheme::Socks, concat!("socks://", probe_host!(), "/")),
(Scheme::All, concat!("none://", probe_host!(), "/")),
];
const DEFAULT_PORT: u16 = 80;
pub(crate) fn read_mode(sandbox: Sandbox) -> Result<ProxyMode, Error> {
let Some(g) = gio_dl::gio() else {
crate::trace::warning!(
sandbox = sandbox.name(),
"GLib is not loadable, so the portal cannot be probed"
);
return Err(sandboxed(
sandbox,
"GLib is not loadable",
"there is no D-Bus client to reach the portal with",
));
};
let connection = DBusConnection::session(g).map_err(|error| {
let message = safe_message(&error);
crate::trace::warning!(
sandbox = sandbox.name(),
error = %message,
"the session bus is not reachable, so the portal cannot be probed"
);
sandboxed(sandbox, "the session bus is not reachable", &message)
})?;
let mut per_scheme = HashMap::new();
for (scheme, uri) in PROBES {
let answers = lookup(&connection, uri).map_err(|error| {
let message = safe_message(&error);
crate::trace::warning!(
sandbox = sandbox.name(),
interface = INTERFACE,
uri = uri,
error = %message,
"the ProxyResolver portal call failed"
);
sandboxed(
sandbox,
&format!("the ProxyResolver portal call failed for {uri}"),
&message,
)
})?;
match first_choice(&answers) {
Some(proxy) => {
per_scheme.insert(
scheme,
ProxyEntry::Use(ProxyEndpoint::parse(as_spoken(proxy), DEFAULT_PORT)?),
);
}
None => {
per_scheme.insert(scheme, ProxyEntry::Disabled);
}
}
}
if per_scheme.values().all(ProxyEntry::is_disabled) {
warn_every_probe_answered_direct();
return Ok(ProxyMode::Direct);
}
let mode = ProxyMode::manual(per_scheme, rebuilt_bypass());
crate::trace::debug!(
mode = %crate::trace::ModeSummary(&mode),
"reconstructed a mode from the portal's answers"
);
Ok(mode)
}
fn rebuilt_bypass() -> BypassRules {
let mut bypass = BypassRules::new();
bypass.implicit = ImplicitBypass::Empty;
bypass.ipv4_mapped_as_ipv4 = false;
bypass.strip_trailing_dot = false;
bypass
}
fn lookup(connection: &DBusConnection, uri: &str) -> Result<Vec<String>, String> {
let g = gio_dl::gio().expect("a connection exists only once GLib is loaded");
let reply = connection.call_sync(
BUS_NAME,
OBJECT_PATH,
INTERFACE,
METHOD,
&Variant::string_tuple(g, uri),
REPLY_SIGNATURE,
TIMEOUT_MS,
)?;
Ok(reply.child(0).strv())
}
fn as_spoken(proxy: &str) -> &str {
match proxy.split_once("://") {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("ftp") => rest,
_ => proxy,
}
}
fn first_choice(answers: &[String]) -> Option<&str> {
answers
.iter()
.map(|answer| answer.trim())
.find(|answer| !answer.is_empty())
.filter(|answer| *answer != DIRECT)
}
fn warn_every_probe_answered_direct() {
crate::trace::warning!(
probe_host = probe_host!(),
"the portal answered direct:// for every probed scheme, so the reported mode is \
Direct. If the sandbox host resolves proxies with a PAC script that branches on \
the host name, this is what that looks like from inside the sandbox — the script \
saw the probe host, not the real destination — and the real answer for a real \
destination may well be a proxy"
);
}
fn safe_message(error: &str) -> String {
crate::util::redact_and_sanitize_untrusted(error)
}
fn sandboxed(sandbox: Sandbox, reason: &str, message: &str) -> Error {
Error::Sandboxed {
sandbox: sandbox.name().to_owned(),
reason: format!(
"{reason} ({message}); GSettings cannot be trusted here because GLib would \
answer from the keyfile backend with the schema default mode='none'"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_rebuilt_bypass_sends_nothing_direct() {
let rules = rebuilt_bypass();
assert!(rules.patterns.is_empty());
for destination in [
"localhost",
"localhost.",
"127.0.0.1",
"[::1]",
"[::ffff:127.0.0.1]",
"169.254.169.254",
"[fe80::1]",
] {
assert!(!rules.matches_authority(destination), "{destination}");
}
}
#[test]
fn an_ftp_answer_is_the_http_proxy_for_ftp_destinations() {
assert!(ProxyEndpoint::parse("ftp://px.corp:3128", DEFAULT_PORT).is_err());
let endpoint = ProxyEndpoint::parse(as_spoken("FTP://px.corp:3128"), DEFAULT_PORT).unwrap();
assert_eq!(endpoint.authority(), "px.corp:3128");
assert_eq!(endpoint.scheme_hint, None);
assert_eq!(as_spoken("socks://s:1080"), "socks://s:1080");
assert_eq!(as_spoken("http://h:1"), "http://h:1");
}
#[test]
fn direct_answers_are_recognised() {
assert_eq!(first_choice(&["direct://".to_owned()]), None);
assert_eq!(first_choice(&[]), None);
assert_eq!(first_choice(&[" ".to_owned()]), None);
}
#[test]
fn a_leading_direct_is_the_answer_and_not_something_to_reach_past() {
let answers = ["direct://".to_owned(), "http://proxy.corp:8080".to_owned()];
assert_eq!(first_choice(&answers), None);
}
#[test]
fn a_proxy_at_the_head_wins_over_everything_behind_it() {
let answers = [
"http://proxy.corp:8080".to_owned(),
"direct://".to_owned(),
"http://backup.corp:8080".to_owned(),
];
assert_eq!(first_choice(&answers), Some("http://proxy.corp:8080"));
}
#[test]
fn every_probe_asks_about_the_scheme_it_records_at_the_reserved_host() {
for (scheme, uri) in PROBES {
assert!(
uri.ends_with(concat!(probe_host!(), "/")),
"{uri} must not name a real host"
);
let asks_about = if scheme == Scheme::All {
"none"
} else {
scheme.as_str()
};
assert!(
uri.starts_with(&format!("{asks_about}://")),
"{uri} is filed under {scheme}, which is asked about with {asks_about}://"
);
}
for scheme in Scheme::ALL {
assert_eq!(
PROBES
.iter()
.filter(|(probed, _)| *probed == scheme)
.count(),
1,
"{scheme} is probed the wrong number of times"
);
}
}
}