use secrecy::SecretString;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::prompt::{self, PromptResult, Prompter};
use crate::cli::{ProxyCommands, ProxyDiscoverArgs, ProxySetArgs, ProxyTestArgs};
use crate::egress::{
self, mask_userinfo, CandidateAttempt, Context, EgressConfig, ProxyAuth, ProxyMode, ProxySource,
};
use crate::error::{OlError, ERR_EGRESS_UNREACHABLE, ERR_INVALID_CONFIG, ERR_PROXY_CONFIG_INVALID};
pub fn run(cmd: &ProxyCommands, output: &OutputConfig) -> Result<(), OlError> {
match cmd {
ProxyCommands::Status => status(output),
ProxyCommands::Discover(args) => discover(args, output),
ProxyCommands::Set(args) => set(args, output),
ProxyCommands::Clear => clear(output),
ProxyCommands::Test(args) => test(args, output),
}
}
#[derive(Debug, Default, Clone)]
pub struct ProxyOverrides {
pub url: Option<String>,
pub no_proxy: Option<String>,
pub ca_bundle: Option<String>,
pub mode: Option<String>,
pub auth: Option<String>,
pub spn: Option<String>,
}
impl ProxyOverrides {
pub fn from_init(args: &crate::cli::InitArgs) -> Self {
Self {
url: args.proxy.clone(),
no_proxy: args.no_proxy.clone(),
ca_bundle: args.ca_bundle.clone(),
mode: args.proxy_mode.clone(),
auth: args.proxy_auth.clone(),
spn: args.proxy_spn.clone(),
}
}
pub fn validate(&self) -> Result<(), OlError> {
if let Some(url) = &self.url {
if authority_of(url).is_some_and(|a| a.contains('@')) {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
"--proxy must not contain a username or password: command-line arguments \
are readable by every process on this host",
)
.with_suggestion(
"Pass the credential through OPENLATCH_PROXY instead, or omit it and let \
`openlatch init` prompt on the 407 — both store it in the OS credential \
store and never write it to config.toml.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1226"));
}
}
if let Some(m) = &self.mode {
if !matches!(m.as_str(), "auto" | "manual" | "direct") {
return Err(invalid_flag("--proxy-mode", m, "auto, manual or direct"));
}
}
if let Some(a) = &self.auth {
if !matches!(a.as_str(), "auto" | "none" | "basic" | "negotiate") {
return Err(invalid_flag(
"--proxy-auth",
a,
"auto, none, basic or negotiate (NTLM is not supported)",
));
}
}
Ok(())
}
pub fn apply(&self, cfg: &mut EgressConfig) -> Result<(), OlError> {
if let Some(m) = &self.mode {
cfg.mode = match m.as_str() {
"manual" => ProxyMode::Manual,
"direct" => ProxyMode::Direct,
_ => ProxyMode::Auto,
};
}
if let Some(a) = &self.auth {
cfg.auth = match a.as_str() {
"none" => ProxyAuth::None,
"basic" => ProxyAuth::Basic,
"negotiate" => ProxyAuth::Negotiate,
_ => ProxyAuth::Auto,
};
}
if let Some(u) = &self.url {
let parsed = parse_proxy_url(u)?;
cfg.url = Some(parsed);
cfg.mode = match &self.mode {
Some(m) if m == "direct" => ProxyMode::Direct,
_ => ProxyMode::Manual,
};
cfg.source = Some(ProxySource::Manual);
}
if let Some(list) = &self.no_proxy {
let (matcher, unsupported) = egress::NoProxyMatcher::new(list);
for entry in unsupported {
cfg.warnings
.push(egress::EgressWarning::UnsupportedNoProxyEntry(entry));
}
cfg.no_proxy = matcher;
}
if let Some(p) = &self.ca_bundle {
let path = std::path::PathBuf::from(p);
if !path.is_file() {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("--ca-bundle '{p}' is not a readable file"),
)
.with_suggestion("Point it at the intercepting proxy's root certificate, in PEM.")
.with_docs("https://docs.openlatch.ai/errors/OL-1226"));
}
cfg.ca_bundle = Some(path);
}
if let Some(s) = &self.spn {
cfg.spn = Some(s.clone());
}
Ok(())
}
fn persistable(&self) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
if let Some(v) = &self.no_proxy {
out.push(("no_proxy", quoted(v)));
}
if let Some(v) = &self.ca_bundle {
out.push(("ca_bundle", quoted(v)));
}
if let Some(v) = &self.auth {
out.push(("auth", quoted(v)));
}
if let Some(v) = &self.spn {
out.push(("spn", quoted(v)));
}
out
}
}
fn invalid_flag(flag: &str, got: &str, expected: &str) -> OlError {
OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("{flag} = \"{got}\" is not a valid value"),
)
.with_suggestion(format!("Expected one of: {expected}."))
.with_docs("https://docs.openlatch.ai/errors/OL-1226")
}
fn authority_of(url: &str) -> Option<&str> {
let (_, rest) = url.split_once("://")?;
Some(rest.split('/').next().unwrap_or(rest))
}
pub fn parse_proxy_url(raw: &str) -> Result<String, OlError> {
let bad = || {
OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("'{raw}' is not a usable proxy URL"),
)
.with_suggestion(
"Use http://host:port, https://host:port, socks5://host:port or \
socks5h://host:port.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1226")
};
let parsed = reqwest::Url::parse(raw.trim()).map_err(|_| bad())?;
if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h") {
return Err(bad());
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err(bad());
}
Ok(egress::discovery::authority_form(&parsed))
}
fn quoted(v: &str) -> String {
format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\""))
}
pub fn refuse_linux_pac(cfg: &EgressConfig) -> Result<(), OlError> {
if !cfg!(target_os = "linux") {
return Ok(());
}
match cfg.pac_url.as_deref().filter(|u| !u.is_empty()) {
Some(url) => Err(egress::discovery::linux::pac_refusal(url)),
None => Ok(()),
}
}
pub struct GateOutcome {
pub config: EgressConfig,
pub sets: Vec<(&'static str, String)>,
pub removes: Vec<&'static str>,
pub attempts: Vec<CandidateAttempt>,
pub prompted: bool,
pub captured: Option<(String, String, SecretString)>,
}
impl GateOutcome {
fn unchanged(config: EgressConfig, attempts: Vec<CandidateAttempt>) -> Self {
Self {
config,
sets: Vec::new(),
removes: Vec::new(),
attempts,
prompted: false,
captured: None,
}
}
pub fn source_str(&self) -> Option<&'static str> {
self.config.source.map(source_str)
}
}
#[derive(Debug, Default, Clone)]
pub struct PersistedProxy {
pub url: Option<String>,
pub source: Option<String>,
}
impl PersistedProxy {
pub fn read(config_path: &std::path::Path) -> Self {
#[derive(serde::Deserialize)]
struct Wrapper {
proxy: Option<egress::ProxyToml>,
}
let Ok(raw) = std::fs::read_to_string(config_path) else {
return Self::default();
};
let Ok(wrapper) = toml::from_str::<Wrapper>(&raw) else {
return Self::default();
};
let Some(toml) = wrapper.proxy else {
return Self::default();
};
Self {
url: toml.url.filter(|u| !u.is_empty()),
source: toml.source.filter(|s| !s.is_empty()),
}
}
fn already_records(&self, url: Option<&str>) -> bool {
self.source.is_some() && self.url.as_deref() == url
}
}
pub struct GateFailure {
pub error: OlError,
pub attempts: Vec<CandidateAttempt>,
}
impl GateFailure {
fn new(error: OlError, attempts: Vec<CandidateAttempt>) -> Self {
Self { error, attempts }
}
}
impl From<GateFailure> for OlError {
fn from(f: GateFailure) -> Self {
f.error
}
}
pub fn source_str(s: ProxySource) -> &'static str {
match s {
ProxySource::Manual => "manual",
ProxySource::Env => "env",
ProxySource::Windows => "windows",
ProxySource::Macos => "macos",
ProxySource::Gnome => "gnome",
ProxySource::Pac => "pac",
ProxySource::Wpad => "wpad",
}
}
pub fn run_gate(
api_url: &str,
base: EgressConfig,
overrides: &ProxyOverrides,
persisted: &PersistedProxy,
prompter: Option<&mut dyn Prompter>,
output: &OutputConfig,
) -> Result<GateOutcome, GateFailure> {
let mut cfg = base;
let bare = |e: OlError| GateFailure::new(e, Vec::new());
overrides.apply(&mut cfg).map_err(bare)?;
refuse_linux_pac(&cfg).map_err(bare)?;
let target = reqwest::Url::parse(api_url)
.map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("[cloud] api_url = '{api_url}' is not a URL"),
)
.with_suggestion("Set it with `openlatch init --api-url <url>`.")
})
.map_err(bare)?;
let probe = egress::HealthProbe::strict(api_url, cfg.clone()).map_err(bare)?;
let ranking_probe = egress::HealthProbe::new(api_url, cfg.clone()).map_err(bare)?;
let explicit = match cfg.mode {
ProxyMode::Direct => probe_route(&probe, &cfg, None),
_ => match cfg.url.as_deref() {
Some(u) => {
let parsed = reqwest::Url::parse(u).ok();
probe_route(&probe, &cfg, parsed.as_ref())
}
None => probe_route(&probe, &cfg, None),
},
};
let mut attempts = vec![explicit.clone()];
if explicit.probe.is_ok() {
let mut outcome = GateOutcome::unchanged(cfg, attempts);
outcome.sets = overrides.persistable();
if overrides.url.is_some() {
outcome
.sets
.push(("url", quoted(outcome.config.url.as_deref().unwrap_or(""))));
outcome.sets.push(("mode", quoted("manual")));
outcome.sets.push(("source", quoted("manual")));
} else if !persisted.already_records(outcome.config.url.as_deref()) {
if let Some(url) = outcome.config.url.clone() {
outcome.config.source = Some(ProxySource::Env);
outcome.sets.push(("url", quoted(&url)));
outcome.sets.push(("source", quoted("env")));
if let Some(user) = outcome.config.username.clone() {
outcome.sets.push(("username", quoted(&user)));
if let Some(pass) = outcome.config.env_password.clone() {
outcome.captured = Some((
egress::credential_authority(&url),
user,
SecretString::from(pass),
));
}
}
}
}
return Ok(outcome);
}
if cfg.source == Some(ProxySource::Manual) || cfg.mode == ProxyMode::Manual {
let e = manual_route_failed(&cfg);
return Err(GateFailure::new(e, attempts));
}
output.print_substep("Cloud unreachable — running proxy discovery");
let (winner, trace) = egress::discover(Context::UserSession, &cfg, &target, &ranking_probe);
attempts.extend(trace);
if let Some(found) = winner {
let (sets, removes) = route_writes(&found);
let mut config = cfg.clone();
apply_discovered(&mut config, &found);
let mut all = overrides.persistable();
all.extend(sets);
return Ok(GateOutcome {
config,
sets: all,
removes,
attempts,
prompted: false,
captured: None,
});
}
let Some(prompter) = prompter else {
let e = nothing_reached(api_url, &attempts);
return Err(GateFailure::new(e, attempts));
};
prompt_loop(api_url, cfg, overrides, prompter, attempts)
}
fn probe_route(
probe: &egress::HealthProbe,
cfg: &EgressConfig,
via: Option<&reqwest::Url>,
) -> CandidateAttempt {
use egress::{CandidateOutcome, CandidateProbe};
let started = std::time::Instant::now();
let (outcome, latency_ms) = match probe.probe(via) {
Ok(ms) => (CandidateOutcome::Ok, ms),
Err(e) => (
CandidateOutcome::Failed(e.code),
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
),
};
CandidateAttempt {
source: cfg.source.unwrap_or(ProxySource::Env),
url_masked: via.map(mask_url).unwrap_or_default(),
probe: outcome,
latency_ms,
rung: "configured",
detail: None,
}
}
fn mask_url(u: &reqwest::Url) -> String {
mask_userinfo(&egress::discovery::authority_form(u))
}
fn apply_discovered(cfg: &mut EgressConfig, found: &egress::Discovered) {
cfg.source = Some(found.source);
match &found.route {
egress::Route::Static(u) => {
cfg.mode = ProxyMode::Auto;
cfg.url = Some(egress::discovery::authority_form(u));
cfg.pac_url = None;
}
egress::Route::PacSource { pac_url } => {
cfg.mode = ProxyMode::Auto;
cfg.url = None;
cfg.pac_url = pac_url.as_ref().map(ToString::to_string);
}
}
}
fn route_writes(found: &egress::Discovered) -> (Vec<(&'static str, String)>, Vec<&'static str>) {
let source = quoted(source_str(found.source));
match &found.route {
egress::Route::Static(u) => (
vec![
("url", quoted(&egress::discovery::authority_form(u))),
("source", source),
],
vec!["pac_url"],
),
egress::Route::PacSource { pac_url } => {
let mut sets = vec![("source", source)];
if let Some(p) = pac_url {
sets.push(("pac_url", quoted(p.as_str())));
}
let removes = if pac_url.is_some() {
vec!["url"]
} else {
vec!["url", "pac_url"]
};
(sets, removes)
}
}
}
fn prompt_loop(
api_url: &str,
base: EgressConfig,
overrides: &ProxyOverrides,
prompter: &mut dyn Prompter,
mut attempts: Vec<CandidateAttempt>,
) -> Result<GateOutcome, GateFailure> {
for attempt in 1..=prompt::MAX_URL_ATTEMPTS {
let answer = match prompter.ask_url(attempt) {
PromptResult::Answered(a) => a,
PromptResult::Aborted => return Err(GateFailure::new(aborted(api_url), attempts)),
PromptResult::NotATty => {
let e = nothing_reached(api_url, &attempts);
return Err(GateFailure::new(e, attempts));
}
};
let (clean, user, pass) = split_userinfo(&answer);
let url = match parse_proxy_url(&clean) {
Ok(u) => u,
Err(e) => {
eprintln!(" {}", e.message);
continue;
}
};
let parsed = match reqwest::Url::parse(&url) {
Ok(p) => p,
Err(_) => continue,
};
let mut cfg = base.clone();
cfg.mode = ProxyMode::Manual;
cfg.source = Some(ProxySource::Manual);
cfg.url = Some(url.clone());
cfg.username = user.clone().or(cfg.username);
cfg.env_password = pass.clone();
if cfg.env_password.is_some() && cfg.auth == ProxyAuth::None {
cfg.auth = ProxyAuth::Basic;
}
let mut record = match egress::HealthProbe::strict(api_url, cfg.clone()) {
Ok(p) => probe_route(&p, &cfg, Some(&parsed)),
Err(e) => return Err(GateFailure::new(e, attempts)),
};
record.rung = "prompt";
record.source = ProxySource::Manual;
attempts.push(record.clone());
let needs_credential = matches!(
record.probe,
egress::CandidateOutcome::Failed(code) if code == crate::error::ERR_PROXY_AUTH_FAILED
);
if needs_credential && cfg.env_password.is_none() {
let username = match &cfg.username {
Some(u) => u.clone(),
None => match prompter.ask_username(&url) {
PromptResult::Answered(u) => u,
_ => return Err(GateFailure::new(aborted(api_url), attempts)),
},
};
let secret = match prompter.ask_password(&url, &username) {
PromptResult::Answered(s) => s,
_ => return Err(GateFailure::new(aborted(api_url), attempts)),
};
cfg.username = Some(username.clone());
cfg.auth = match cfg.auth {
ProxyAuth::None => ProxyAuth::Basic,
other => other,
};
cfg.env_password = Some(secrecy::ExposeSecret::expose_secret(&secret).to_string());
let mut retry = match egress::HealthProbe::strict(api_url, cfg.clone()) {
Ok(p) => probe_route(&p, &cfg, Some(&parsed)),
Err(e) => return Err(GateFailure::new(e, attempts)),
};
retry.rung = "prompt-auth";
retry.source = ProxySource::Manual;
attempts.push(retry.clone());
if retry.probe.is_ok() {
return Ok(prompt_outcome(
cfg,
overrides,
attempts,
Some((url, username, secret)),
));
}
continue;
}
if record.probe.is_ok() {
let captured = match (user, pass) {
(Some(u), Some(p)) => Some((url.clone(), u, SecretString::from(p))),
_ => None,
};
return Ok(prompt_outcome(cfg, overrides, attempts, captured));
}
}
Err(GateFailure::new(exhausted(api_url), attempts))
}
fn prompt_outcome(
cfg: EgressConfig,
overrides: &ProxyOverrides,
attempts: Vec<CandidateAttempt>,
captured: Option<(String, String, SecretString)>,
) -> GateOutcome {
let mut sets = overrides.persistable();
sets.push(("url", quoted(cfg.url.as_deref().unwrap_or(""))));
sets.push(("mode", quoted("manual")));
sets.push(("source", quoted("manual")));
if let Some(user) = &cfg.username {
sets.push(("username", quoted(user)));
}
GateOutcome {
config: cfg,
sets,
removes: vec!["pac_url"],
attempts,
prompted: true,
captured,
}
}
fn split_userinfo(raw: &str) -> (String, Option<String>, Option<String>) {
let Some((scheme, rest)) = raw.trim().split_once("://") else {
return (raw.trim().to_string(), None, None);
};
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (rest, None),
};
let Some((userinfo, host)) = authority.rsplit_once('@') else {
return (raw.trim().to_string(), None, None);
};
let (user, pass) = match userinfo.split_once(':') {
Some((u, p)) => (u.to_string(), Some(p.to_string())),
None => (userinfo.to_string(), None),
};
let clean = match path {
Some(p) => format!("{scheme}://{host}/{p}"),
None => format!("{scheme}://{host}"),
};
(clean, Some(user), pass)
}
fn nothing_reached(api_url: &str, attempts: &[CandidateAttempt]) -> OlError {
OlError::new(
ERR_EGRESS_UNREACHABLE,
format!(
"cannot reach {api_url}: {} candidate route(s) tried, none worked",
attempts.iter().filter(|a| a.was_probed()).count()
),
)
.with_suggestion(
"Set the proxy explicitly with `openlatch proxy set <url>`, or re-run with a \
terminal so `openlatch init` can prompt for one. If this host has no proxy, ask \
IT for an egress rule for app.openlatch.ai. `--yes` suppresses the prompt.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1220")
}
fn aborted(api_url: &str) -> OlError {
OlError::new(
ERR_EGRESS_UNREACHABLE,
format!("cancelled: {api_url} was not reachable and no proxy was given"),
)
.with_suggestion(
"Re-run `openlatch init` when you have the proxy URL, or set it directly with \
`openlatch proxy set <url>`.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1220")
}
fn exhausted(api_url: &str) -> OlError {
OlError::new(
ERR_EGRESS_UNREACHABLE,
format!(
"cannot reach {api_url} after {} proxy attempts",
prompt::MAX_URL_ATTEMPTS
),
)
.with_suggestion(
"Check the proxy URL and port with whoever runs egress on this network, then \
`openlatch proxy set <url>`.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1220")
}
fn manual_route_failed(cfg: &EgressConfig) -> OlError {
let route = cfg
.url
.as_deref()
.map(mask_userinfo)
.unwrap_or_else(|| "direct".to_string());
OlError::new(
crate::error::ERR_PROXY_UNREACHABLE,
format!("the proxy set by hand ({route}) did not reach the platform"),
)
.with_suggestion(
"`[proxy] source = \"manual\"` means discovery will not replace it. Fix the URL \
with `openlatch proxy set <url>`, or hand the route back to discovery with \
`openlatch proxy discover --force`.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1221")
}
pub fn candidates_json(attempts: &[CandidateAttempt]) -> serde_json::Value {
serde_json::Value::Array(
attempts
.iter()
.filter(|a| a.was_probed())
.map(|a| {
serde_json::json!({
"source": source_str(a.source),
"url_masked": a.url_masked,
"probe": a.probe.as_str(),
"latency_ms": a.latency_ms,
})
})
.collect(),
)
}
pub fn persist_outcome(
config_path: &std::path::Path,
outcome: &GateOutcome,
output: &OutputConfig,
) -> Result<(), OlError> {
if outcome.sets.is_empty() && outcome.removes.is_empty() {
return Ok(());
}
crate::config::persist_proxy_config(config_path, &outcome.sets, &outcome.removes)?;
if let Some((authority, _username, secret)) = &outcome.captured {
let store = credential_store();
if let Err(e) = store.store(authority, secret) {
output.print_substep(&format!(
"Proxy credential could not be stored ({}); re-enter it with `openlatch proxy set`",
e.code
));
}
}
Ok(())
}
pub fn credential_store() -> egress::credentials::ProxyCredentialStore {
let dir = crate::config::openlatch_dir();
let agent_id = crate::config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_default();
egress::credentials::ProxyCredentialStore::new(&dir, agent_id)
}
pub fn proxy_type(cfg: &EgressConfig) -> &'static str {
if matches!(cfg.source, Some(ProxySource::Pac) | Some(ProxySource::Wpad)) {
return "pac";
}
match cfg.url.as_deref().and_then(|u| u.split_once("://")) {
Some(("https", _)) => "https",
Some(("socks5" | "socks5h", _)) => "socks5",
Some(("http", _)) => "http",
_ => "direct",
}
}
fn auth_scheme(cfg: &EgressConfig) -> &'static str {
match cfg.auth {
ProxyAuth::None => "none",
ProxyAuth::Negotiate => "negotiate",
ProxyAuth::Basic => "basic",
ProxyAuth::Auto if cfg.username.is_some() => "basic",
ProxyAuth::Auto => "none",
}
}
pub fn emit_proxy_configured(
cfg: &EgressConfig,
discovery_attempts: usize,
tls_intercepted: Option<bool>,
) {
let in_use = cfg.mode != ProxyMode::Direct
&& (cfg.url.is_some()
|| matches!(cfg.source, Some(ProxySource::Pac) | Some(ProxySource::Wpad)));
crate::telemetry::capture_global(crate::telemetry::Event::proxy_configured(
in_use,
proxy_type(cfg),
cfg.source.map(source_str),
auth_scheme(cfg),
egress::tls::ca_source(cfg).as_str(),
tls_intercepted,
u32::try_from(discovery_attempts).unwrap_or(u32::MAX),
crate::telemetry::telemetry_os(),
));
}
fn current() -> Result<(crate::config::Config, EgressConfig), OlError> {
let cfg = crate::config::Config::load(None, None, false)?;
let egress_cfg = cfg.egress.clone();
Ok((cfg, egress_cfg))
}
fn config_path() -> std::path::PathBuf {
crate::config::openlatch_dir().join("config.toml")
}
fn status(output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["proxy", "status"]);
let (cfg, egress_cfg) = current()?;
let cli_source = egress_cfg.source.map(source_str).unwrap_or("unset");
let cli_route = egress_cfg
.url
.as_deref()
.map(mask_userinfo)
.unwrap_or_else(|| "direct".to_string());
let daemon_route = "unknown";
if output.format == OutputFormat::Json {
let body = serde_json::json!({
"status": "unknown",
"proxy_in_use": egress_cfg.has_proxy(),
"proxy_url": egress_cfg.url.as_deref().map(mask_userinfo),
"source": egress_cfg.source.map(source_str),
"auth_scheme": auth_scheme(&egress_cfg),
"ca_source": egress::tls::ca_source(&egress_cfg).as_str(),
"tls_intercepted": serde_json::Value::Null,
"last_ok_at": serde_json::Value::Null,
"last_error": serde_json::Value::Null,
"consecutive_failures": 0,
"probing": false,
"cli_resolution": {
"source": cli_source,
"url_masked": cli_route,
},
"daemon_resolution": daemon_route,
});
let doc = crate::cli::commands::doctor::with_section_verdict(
body,
crate::cli::report::Section::Connection,
output,
)?;
output.print_json(&doc);
return Ok(());
}
crate::cli::commands::doctor::append_section_verdict(
crate::cli::report::Section::Connection,
output,
)?;
if output.quiet {
return Ok(());
}
eprintln!();
eprintln!(" {:<21}{}", "OpenLatch platform", cfg.cloud.api_url);
eprintln!(
" {:<21}{}",
"Route (your shell)",
crate::cli::commands::doctor::cli_route_line(&cfg)
);
for warning in &egress_cfg.warnings {
match warning {
egress::EgressWarning::EnvCaseMismatch { lower, upper } => eprintln!(
" {:<21}{lower} and {upper} disagree — {lower} wins (OL-1226, warning form)",
"Setting conflict"
),
egress::EgressWarning::UnsupportedNoProxyEntry(e) => eprintln!(
" {:<21}no_proxy entry '{e}' is not a form this client matches",
"Setting ignored"
),
}
}
Ok(())
}
fn discover(args: &ProxyDiscoverArgs, output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["proxy", "discover"]);
let (cfg, egress_cfg) = current()?;
if egress_cfg.source == Some(ProxySource::Manual) && !args.force {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
"[proxy] source = \"manual\" — discovery will not overwrite a route you set",
)
.with_suggestion(
"Re-run with `--force` to hand the route back to discovery, or change it \
directly with `openlatch proxy set <url>`.",
)
.with_docs("https://docs.openlatch.ai/errors/OL-1226"));
}
refuse_linux_pac(&egress_cfg)?;
let target = reqwest::Url::parse(&cfg.cloud.api_url).map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("[cloud] api_url = '{}' is not a URL", cfg.cloud.api_url),
)
})?;
let probe = egress::HealthProbe::new(&cfg.cloud.api_url, egress_cfg.clone())?;
let (winner, trace) = egress::discover(Context::UserSession, &egress_cfg, &target, &probe);
for attempt in &trace {
output.print_substep(&attempt.trace_line());
}
let mut daemon_trace = Vec::new();
if is_elevated() {
let (_, service_trace) =
egress::discover(Context::DaemonService, &egress_cfg, &target, &probe);
if !service_trace.is_empty() {
output.print_substep("daemon context:");
for attempt in &service_trace {
output.print_substep(&format!(" {}", attempt.trace_line()));
}
}
daemon_trace = service_trace;
}
let Some(found) = winner else {
let mut trace = trace;
trace.push(probe_route(&probe, &egress_cfg, None));
let direct_works = trace.last().is_some_and(|a| a.probe.is_ok());
if !direct_works {
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"status": "failed",
"error": { "code": ERR_EGRESS_UNREACHABLE },
"candidates": candidates_json(&trace),
"daemon_candidates": candidates_json(&daemon_trace),
}));
}
return Err(nothing_reached(&cfg.cloud.api_url, &trace));
}
output.print_step("no proxy needed — this host reaches the platform directly");
if let Some(stale) = egress_cfg.url.as_deref() {
output.print_substep(&format!(
"note: [proxy] url is still set to {} — clear it with `openlatch proxy clear`",
mask_userinfo(stale)
));
}
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"status": "ok",
"source": "direct",
"url_masked": serde_json::Value::Null,
"candidates": candidates_json(&trace),
"daemon_candidates": candidates_json(&daemon_trace),
}));
}
return Ok(());
};
let (sets, removes) = route_writes(&found);
crate::config::persist_proxy_config(&config_path(), &sets, &removes)?;
let mut resolved = egress_cfg.clone();
apply_discovered(&mut resolved, &found);
emit_proxy_configured(
&resolved,
trace.iter().filter(|a| a.was_probed()).count(),
None,
);
let source = source_str(found.source);
let route = resolved
.url
.as_deref()
.map(mask_userinfo)
.unwrap_or_else(|| "pac".to_string());
output.print_step(&format!("proxy via {route} ({source})"));
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"status": "ok",
"source": source,
"url_masked": resolved.url.as_deref().map(mask_userinfo),
"candidates": candidates_json(&trace),
"daemon_candidates": candidates_json(&daemon_trace),
}));
}
Ok(())
}
fn is_elevated() -> bool {
#[cfg(windows)]
{
true
}
#[cfg(not(windows))]
{
false
}
}
fn set(args: &ProxySetArgs, output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["proxy", "set"]);
let overrides = ProxyOverrides {
url: Some(args.url.clone()),
no_proxy: args.no_proxy.clone(),
ca_bundle: args.ca_bundle.clone(),
spn: args.spn.clone(),
..Default::default()
};
overrides.validate()?;
let url = parse_proxy_url(&args.url)?;
let path = config_path();
if !path.exists() {
crate::config::ensure_config(crate::config::Config::defaults().port)?;
}
let (cfg, base) = current()?;
let mut resolved = base.clone();
overrides.apply(&mut resolved)?;
resolved.mode = ProxyMode::Manual;
resolved.source = Some(ProxySource::Manual);
resolved.url = Some(url.clone());
let mut captured = None;
let probe = egress::HealthProbe::strict(&cfg.cloud.api_url, resolved.clone())?;
let parsed = reqwest::Url::parse(&url)
.map_err(|_| OlError::new(ERR_PROXY_CONFIG_INVALID, format!("'{url}' is not a URL")))?;
let attempt = probe_route(&probe, &resolved, Some(&parsed));
let needs_credential = matches!(
attempt.probe,
egress::CandidateOutcome::Failed(code) if code == crate::error::ERR_PROXY_AUTH_FAILED
);
if needs_credential && prompt::interactive(output, false) {
let mut prompter = prompt::TerminalPrompter::new(cfg.cloud.api_url.clone());
if let PromptResult::Answered(user) = prompter.ask_username(&url) {
if let PromptResult::Answered(secret) = prompter.ask_password(&url, &user) {
resolved.username = Some(user.clone());
resolved.env_password =
Some(secrecy::ExposeSecret::expose_secret(&secret).to_string());
if resolved.auth == ProxyAuth::None {
resolved.auth = ProxyAuth::Basic;
}
match egress::HealthProbe::strict(&cfg.cloud.api_url, resolved.clone())
.map(|p| probe_route(&p, &resolved, Some(&parsed)))
{
Ok(retry) if retry.probe.is_ok() => {
captured = Some((url.clone(), user, secret));
}
_ => {
output.print_substep(
"That credential did not get through the proxy — the route is \
saved, the credential is not. Re-run `openlatch proxy set` to \
try again.",
);
resolved.username = None;
resolved.env_password = None;
}
}
}
}
}
let mut sets = overrides.persistable();
sets.push(("url", quoted(&url)));
sets.push(("mode", quoted("manual")));
sets.push(("source", quoted("manual")));
if let Some(user) = &resolved.username {
sets.push(("username", quoted(user)));
}
let outcome = GateOutcome {
config: resolved,
sets,
removes: vec!["pac_url"],
attempts: vec![attempt],
prompted: captured.is_some(),
captured,
};
persist_outcome(&path, &outcome, output)?;
emit_proxy_configured(&outcome.config, 1, None);
output.print_step(&format!("proxy set to {} (manual)", mask_userinfo(&url)));
output.print_substep("The daemon picks this up at its next start — `openlatch restart`.");
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"status": "ok",
"source": "manual",
"mode": "manual",
"url_masked": mask_userinfo(&url),
}));
}
Ok(())
}
fn clear(output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["proxy", "clear"]);
let path = config_path();
if !path.exists() {
crate::config::ensure_config(crate::config::Config::defaults().port)?;
}
let authority = crate::config::Config::load(None, None, false)
.ok()
.and_then(|c| c.egress.url)
.and_then(|url| egress::authority_key(&url));
crate::config::persist_proxy_config(
&path,
&[("mode", quoted("direct"))],
&["url", "source", "pac_url", "username"],
)?;
if let Some(authority) = authority.as_deref() {
credential_store().clear(authority);
}
let mut direct = EgressConfig::direct();
direct.mode = ProxyMode::Direct;
emit_proxy_configured(&direct, 0, None);
output.print_step("proxy cleared — outbound requests go direct");
output.print_substep("The daemon picks this up at its next start — `openlatch restart`.");
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"status": "ok",
"mode": "direct",
}));
}
Ok(())
}
fn test(args: &ProxyTestArgs, output: &OutputConfig) -> Result<(), OlError> {
crate::cli::header::print(output, &["proxy", "test"]);
let (cfg, egress_cfg) = current()?;
let target = args
.url
.clone()
.unwrap_or_else(|| cfg.cloud.api_url.clone());
let source = egress_cfg.source.map(source_str).unwrap_or("none");
let route = egress_cfg.url.as_deref().map(mask_userinfo);
let probe = egress::HealthProbe::new(&target, egress_cfg.clone())?;
let parsed = egress_cfg
.url
.as_deref()
.and_then(|u| reqwest::Url::parse(u).ok());
let attempt = probe_route(&probe, &egress_cfg, parsed.as_ref());
let stream = egress::StreamVerdict::Skipped;
let failed = !attempt.probe.is_ok();
if output.format == OutputFormat::Json {
let mut doc = serde_json::json!({
"status": if failed { "failed" } else { "ok" },
"source": source,
"proxy": {
"url_masked": route,
"connect_ms": attempt.latency_ms,
"auth_scheme": auth_scheme(&egress_cfg),
},
"tls": {
"issuer": serde_json::Value::Null,
"intercepted": serde_json::Value::Null,
},
"http": {
"code": if failed { serde_json::Value::Null } else { serde_json::json!(200) },
"ms": attempt.latency_ms,
},
"stream": { "verdict": stream.as_str() },
});
if failed {
doc["error"] = serde_json::json!({
"code": attempt.probe.as_str(),
"message": format!("{target} was not reachable through this route"),
});
}
output.print_json(&doc);
} else {
let via = route.clone().unwrap_or_else(|| "direct".to_string());
let ms = attempt.latency_ms;
let rows = [
("source", source.to_string(), String::new()),
(
"proxy",
via,
format!("{} · {ms} ms", if failed { "failed" } else { "CONNECT ok" }),
),
(
"http",
"GET /api/v1/health".to_string(),
format!("{} · {ms} ms", attempt.probe.as_str()),
),
("stream", target.clone(), stream.as_str().to_string()),
];
let width = rows
.iter()
.filter(|(_, _, verdict)| !verdict.is_empty())
.map(|(_, detail, _)| detail.chars().count())
.max()
.unwrap_or(0);
let label_width = LABEL_WIDTH;
for (label, detail, verdict) in &rows {
if verdict.is_empty() {
output.print_substep(&format!("{label:<label_width$}{detail}"));
} else {
output.print_substep(&format!(
"{label:<label_width$}{detail:<width$} [{verdict}]"
));
}
}
}
if failed {
crate::cli::report::record_exit_code(1);
} else if stream_degrades(stream) {
crate::cli::report::record_exit_code(crate::cli::report::EXIT_DEGRADED);
}
Ok(())
}
const LABEL_WIDTH: usize = 10;
fn stream_degrades(verdict: egress::StreamVerdict) -> bool {
use egress::StreamVerdict as V;
match verdict {
V::Buffered => true,
V::Streaming | V::Skipped => false,
V::Unclassified => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn userinfo_in_the_proxy_flag_is_rejected_naming_the_alternatives() {
let o = ProxyOverrides {
url: Some("http://alice:s3cr3t@proxy.corp:8080".into()),
..Default::default()
};
let err = o.validate().expect_err("userinfo in argv must be refused");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
let suggestion = err.suggestion.unwrap_or_default();
assert!(
suggestion.contains("OPENLATCH_PROXY"),
"the remedy must name the env var: {suggestion}"
);
assert!(
suggestion.contains("prompt"),
"the remedy must name the prompt: {suggestion}"
);
assert!(
!err.message.contains("s3cr3t") && !suggestion.contains("s3cr3t"),
"the refusal must not repeat the password back"
);
}
#[test]
fn a_clean_proxy_flag_passes() {
let o = ProxyOverrides {
url: Some("http://proxy.corp:8080".into()),
..Default::default()
};
assert!(o.validate().is_ok());
}
#[test]
fn a_proxy_flag_win_persists_as_manual() {
let o = ProxyOverrides {
url: Some("http://proxy.corp:8080".into()),
..Default::default()
};
let mut cfg = EgressConfig::direct();
o.apply(&mut cfg).expect("apply");
assert_eq!(cfg.mode, ProxyMode::Manual);
assert_eq!(cfg.source, Some(ProxySource::Manual));
assert_eq!(cfg.url.as_deref(), Some("http://proxy.corp:8080"));
}
#[test]
fn overrides_apply_per_key() {
let o = ProxyOverrides {
auth: Some("basic".into()),
..Default::default()
};
let mut cfg = EgressConfig::direct();
cfg.url = Some("http://from-config:8080".into());
o.apply(&mut cfg).expect("apply");
assert_eq!(cfg.auth, ProxyAuth::Basic);
assert_eq!(
cfg.url.as_deref(),
Some("http://from-config:8080"),
"a flag that says nothing about the url must leave it alone"
);
}
#[test]
fn bad_enum_values_are_refused_by_name() {
for (o, flag) in [
(
ProxyOverrides {
mode: Some("sometimes".into()),
..Default::default()
},
"--proxy-mode",
),
(
ProxyOverrides {
auth: Some("ntlm".into()),
..Default::default()
},
"--proxy-auth",
),
] {
let err = o.validate().expect_err("an off-enum value must be refused");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
assert!(err.message.contains(flag), "{}", err.message);
}
}
#[test]
fn userinfo_is_split_off_a_prompt_answer() {
let (clean, user, pass) = split_userinfo("http://alice:s3cr3t@proxy.corp:8080");
assert_eq!(clean, "http://proxy.corp:8080");
assert_eq!(user.as_deref(), Some("alice"));
assert_eq!(pass.as_deref(), Some("s3cr3t"));
let (clean, _, pass) = split_userinfo("http://alice:p@ss@proxy.corp:8080");
assert_eq!(clean, "http://proxy.corp:8080");
assert_eq!(pass.as_deref(), Some("p@ss"));
let (clean, user, pass) = split_userinfo("http://proxy.corp:8080");
assert_eq!(clean, "http://proxy.corp:8080");
assert!(user.is_none() && pass.is_none());
}
#[test]
fn a_pac_win_removes_the_stale_url() {
let found = egress::Discovered {
source: ProxySource::Wpad,
route: egress::Route::PacSource { pac_url: None },
};
let (sets, removes) = route_writes(&found);
assert!(
sets.iter().all(|(k, _)| *k != "url"),
"a PAC win must never write a concrete url: {sets:?}"
);
assert!(removes.contains(&"url"), "{removes:?}");
}
#[test]
fn a_static_win_writes_url_and_source() {
let found = egress::Discovered {
source: ProxySource::Gnome,
route: egress::Route::Static(
reqwest::Url::parse("http://proxy.corp:8080").expect("url"),
),
};
let (sets, _) = route_writes(&found);
assert!(
sets.contains(&("url", "\"http://proxy.corp:8080\"".to_string())),
"{sets:?}"
);
assert!(
sets.contains(&("source", "\"gnome\"".to_string())),
"{sets:?}"
);
}
#[test]
fn proxy_type_reports_pac_for_a_pac_source() {
let mut cfg = EgressConfig::direct();
cfg.mode = ProxyMode::Auto;
cfg.source = Some(ProxySource::Pac);
cfg.url = Some("http://whatever-the-script-said:8080".into());
assert_eq!(proxy_type(&cfg), "pac");
}
#[test]
fn proxy_type_reports_the_scheme_otherwise() {
let mut cfg = EgressConfig::direct();
cfg.mode = ProxyMode::Manual;
cfg.source = Some(ProxySource::Manual);
for (url, expected) in [
("http://p:8080", "http"),
("https://p:8443", "https"),
("socks5h://p:1080", "socks5"),
] {
cfg.url = Some(url.into());
assert_eq!(proxy_type(&cfg), expected, "{url}");
}
cfg.url = None;
assert_eq!(proxy_type(&cfg), "direct");
}
#[test]
fn the_candidate_array_holds_only_probed_rungs() {
let attempts = vec![
CandidateAttempt {
source: ProxySource::Env,
url_masked: "http://a:1".into(),
probe: egress::CandidateOutcome::Failed("OL-1221"),
latency_ms: 4,
rung: "env",
detail: None,
},
CandidateAttempt {
source: ProxySource::Wpad,
url_masked: String::new(),
probe: egress::CandidateOutcome::Skipped("OL-1225"),
latency_ms: 0,
rung: "wpad",
detail: None,
},
];
let json = candidates_json(&attempts);
let arr = json.as_array().expect("array");
assert_eq!(arr.len(), 1, "{json}");
assert_eq!(arr[0]["probe"], "OL-1221");
}
#[test]
fn a_ca_bundle_that_is_not_there_is_refused_at_parse_time() {
let o = ProxyOverrides {
ca_bundle: Some("definitely-not-a-file.pem".into()),
..Default::default()
};
let mut cfg = EgressConfig::direct();
let err = o.apply(&mut cfg).expect_err("a missing bundle must fail");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
#[test]
fn an_explicit_pac_url_is_refused_on_linux_only() {
let mut cfg = EgressConfig::direct();
cfg.pac_url = Some("http://wpad.corp/proxy.pac".into());
let result = refuse_linux_pac(&cfg);
if cfg!(target_os = "linux") {
let err = result.expect_err("Linux has no PAC evaluator");
assert_eq!(err.code, crate::error::ERR_PAC_UNAVAILABLE);
assert!(
err.suggestion.unwrap_or_default().contains("[proxy] url"),
"the D-20 remedy must name the key that replaces it"
);
} else {
assert!(
result.is_ok(),
"Windows and macOS have audited PAC evaluators"
);
}
}
#[test]
fn only_a_stream_that_actually_misbehaved_costs_the_exit_code() {
use egress::StreamVerdict as V;
assert!(
!stream_degrades(V::Skipped),
"the production verdict: nothing ran, so nothing is wrong here"
);
assert!(!stream_degrades(V::Streaming), "the leg worked");
assert!(
stream_degrades(V::Buffered),
"a hop reassembling the stream is OL-1228 — a warning, and warnings are 7"
);
assert!(
stream_degrades(V::Unclassified),
"the leg ran and could not be read: something in the path answered oddly"
);
}
}