use crate::cli::SandboxArgs;
use crate::launch_runtime::ProxyLaunchOptions;
use crate::network_policy;
use crate::sandbox_prepare::{PreparedSandbox, validate_external_proxy_bypass};
#[cfg(not(target_os = "macos"))]
use nono::AccessMode;
use nono::{CapabilitySet, NonoError, Result};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
pub(crate) struct ActiveProxyRuntime {
pub(crate) env_vars: Vec<(String, String)>,
pub(crate) handle: Option<nono_proxy::server::ProxyHandle>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct EffectiveProxySettings {
pub(crate) network_profile: Option<String>,
pub(crate) allow_domain: Vec<crate::profile::AllowDomainEntry>,
pub(crate) credentials: Vec<String>,
}
pub(crate) fn prepare_proxy_launch_options(
args: &SandboxArgs,
prepared: &PreparedSandbox,
silent: bool,
) -> Result<ProxyLaunchOptions> {
validate_external_proxy_bypass(args, prepared)?;
let effective_proxy = resolve_effective_proxy_settings(args, prepared);
let network_profile = effective_proxy.network_profile;
let allow_domain = effective_proxy.allow_domain;
let credentials = effective_proxy.credentials;
let allow_bind_ports = merge_dedup_ports(&prepared.listen_ports, &args.allow_bind);
let upstream_proxy = if args.allow_net {
None
} else {
args.external_proxy
.clone()
.or_else(|| prepared.upstream_proxy.clone())
};
let upstream_bypass = if args.allow_net {
Vec::new()
} else if args.external_proxy.is_some() {
args.external_proxy_bypass.clone()
} else {
let mut bypass = prepared.upstream_bypass.clone();
bypass.extend(args.external_proxy_bypass.clone());
bypass
};
let active = if matches!(prepared.caps.network_mode(), nono::NetworkMode::Blocked) {
if !credentials.is_empty()
|| network_profile.is_some()
|| !allow_domain.is_empty()
|| upstream_proxy.is_some()
{
warn!(
"--block-net is active; ignoring proxy configuration \
that would re-enable network access"
);
if !silent {
eprintln!(
" [nono] Warning: --block-net overrides proxy/credential settings. \
Network remains fully blocked."
);
}
}
false
} else {
matches!(
prepared.caps.network_mode(),
nono::NetworkMode::ProxyOnly { .. }
) || !credentials.is_empty()
|| network_profile.is_some()
|| !allow_domain.is_empty()
|| upstream_proxy.is_some()
};
Ok(ProxyLaunchOptions {
active,
network_profile,
allow_domain,
credentials,
custom_credentials: prepared.custom_credentials.clone(),
upstream_proxy,
upstream_bypass,
allow_bind_ports,
proxy_port: args.proxy_port,
open_url_origins: prepared.open_url_origins.clone(),
open_url_allow_localhost: prepared.open_url_allow_localhost,
allow_launch_services_active: prepared.allow_launch_services_active,
#[cfg(target_os = "macos")]
trust_proxy_ca: args.trust_proxy_ca,
proxy_ca_validity: args
.proxy_ca_validity
.map(|days| std::time::Duration::from_secs(u64::from(days) * 24 * 60 * 60)),
})
}
pub(crate) fn resolve_effective_proxy_settings(
args: &SandboxArgs,
prepared: &PreparedSandbox,
) -> EffectiveProxySettings {
if args.allow_net {
return EffectiveProxySettings {
network_profile: None,
allow_domain: Vec::new(),
credentials: Vec::new(),
};
}
let network_profile = args
.network_profile
.clone()
.or_else(|| prepared.network_profile.clone());
let mut allow_domain = prepared.allow_domain.clone();
allow_domain.extend(args.allow_proxy.iter().map(|s| parse_allow_domain_arg(s)));
let mut credentials = prepared.credentials.clone();
credentials.extend(args.proxy_credential.clone());
EffectiveProxySettings {
network_profile,
allow_domain,
credentials,
}
}
fn parse_allow_domain_arg(input: &str) -> crate::profile::AllowDomainEntry {
if let Ok(parsed) = url::Url::parse(input) {
let domain = parsed.host_str().unwrap_or(input).to_string();
let path = parsed.path();
if path.is_empty() || path == "/" {
crate::profile::AllowDomainEntry::Plain(domain)
} else {
crate::profile::AllowDomainEntry::WithEndpoints {
domain,
endpoints: vec![nono_proxy::config::EndpointRule {
method: "*".to_string(),
path: path.to_string(),
}],
}
}
} else {
crate::profile::AllowDomainEntry::Plain(input.to_string())
}
}
pub(crate) fn merge_dedup_ports(a: &[u16], b: &[u16]) -> Vec<u16> {
let mut ports = a.to_vec();
ports.extend_from_slice(b);
ports.sort_unstable();
ports.dedup();
ports
}
pub(crate) fn build_proxy_config_from_flags(
proxy: &ProxyLaunchOptions,
) -> Result<nono_proxy::config::ProxyConfig> {
let net_policy_json = crate::config::embedded::embedded_network_policy_json();
let net_policy = network_policy::load_network_policy(net_policy_json)?;
let mut resolved = if let Some(ref profile_name) = proxy.network_profile {
network_policy::resolve_network_profile(&net_policy, profile_name)?
} else {
network_policy::ResolvedNetworkPolicy {
hosts: Vec::new(),
suffixes: Vec::new(),
routes: Vec::new(),
profile_credentials: Vec::new(),
}
};
let mut all_credentials = resolved.profile_credentials.clone();
for cred in &proxy.credentials {
if !all_credentials.contains(cred) {
all_credentials.push(cred.clone());
}
}
let mut routes = network_policy::resolve_credentials(
&net_policy,
&all_credentials,
&proxy.custom_credentials,
)?;
let (mut plain_hosts, endpoint_routes) =
network_policy::partition_allow_domain(&net_policy, &proxy.allow_domain)?;
for route in &endpoint_routes {
if let Some(ref hp) = route.upstream.strip_prefix("https://") {
plain_hosts.push(hp.to_string());
} else if let Some(ref hp) = route.upstream.strip_prefix("http://") {
plain_hosts.push(hp.to_string());
}
}
routes.extend(endpoint_routes);
resolved.routes = routes;
let mut proxy_config = network_policy::build_proxy_config(&resolved, &plain_hosts);
if let Some(ref addr) = proxy.upstream_proxy {
proxy_config.external_proxy = Some(nono_proxy::config::ExternalProxyConfig {
address: addr.clone(),
auth: None,
bypass_hosts: proxy.upstream_bypass.clone(),
});
}
if let Some(port) = proxy.proxy_port {
proxy_config.bind_port = port;
}
proxy_config.ca_validity = proxy.proxy_ca_validity;
Ok(proxy_config)
}
pub(crate) fn start_proxy_runtime(
proxy: &ProxyLaunchOptions,
caps: &mut CapabilitySet,
) -> Result<ActiveProxyRuntime> {
if !proxy.active {
return Ok(ActiveProxyRuntime {
env_vars: Vec::new(),
handle: None,
});
}
let mut proxy_config = build_proxy_config_from_flags(proxy)?;
proxy_config.direct_connect_ports = caps.tcp_connect_ports().to_vec();
if let Some(dir) = prepare_intercept_ca_dir()? {
proxy_config.intercept_ca_dir = Some(dir);
proxy_config.intercept_parent_ca_pems = read_parent_ssl_cert_file();
}
#[cfg(target_os = "macos")]
if proxy.trust_proxy_ca {
if proxy_config.intercept_ca_dir.is_some() {
let validity = proxy
.proxy_ca_validity
.unwrap_or(nono_proxy::tls_intercept::ca::CA_VALIDITY_DEFAULT);
proxy_config.preloaded_ca = crate::macos_trust::load_or_generate_proxy_ca(validity);
} else {
tracing::warn!(
"--trust-proxy-ca has no effect without TLS-intercepting credential routes"
);
}
}
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| NonoError::SandboxInit(format!("Failed to start proxy runtime: {}", e)))?;
let handle = rt
.block_on(async { nono_proxy::server::start(proxy_config.clone()).await })
.map_err(|e| NonoError::SandboxInit(format!("Failed to start proxy: {}", e)))?;
let port = handle.port;
if proxy.allow_bind_ports.is_empty() {
info!("Network proxy started on localhost:{}", port);
} else {
info!(
"Network proxy started on localhost:{}, bind ports: {:?}",
port, proxy.allow_bind_ports
);
}
let route_rows = handle.route_diagnostics(&proxy_config);
if !route_rows.is_empty() {
info!("Proxy routes:");
for (prefix, summary) in &route_rows {
info!(" /{} {}", prefix, summary);
}
if handle.intercept_ca_path().is_some() {
info!(
"TLS interception trust bundle: {}",
handle
.intercept_ca_path()
.map(|p| p.display().to_string())
.unwrap_or_default()
);
}
}
caps.set_network_mode_mut(nono::NetworkMode::ProxyOnly {
port,
bind_ports: proxy.allow_bind_ports.clone(),
});
if let Some(ca_path) = handle.intercept_ca_path() {
#[cfg(target_os = "macos")]
{
let path_str = crate::policy::path_to_utf8(ca_path)?;
let escaped = crate::policy::escape_seatbelt_path(path_str)?;
caps.add_platform_rule(format!("(allow file-read-data (literal \"{}\"))", escaped))?;
caps.add_platform_rule(format!(
"(allow file-read-metadata (literal \"{}\"))",
escaped
))?;
}
#[cfg(not(target_os = "macos"))]
{
caps.allow_file_mut(ca_path, AccessMode::Read)
.map_err(|e| {
NonoError::SandboxInit(format!(
"Failed to grant read capability on TLS-intercept bundle '{}': {}",
ca_path.display(),
e
))
})?;
}
debug!(
"Granted sandboxed child read access to TLS-intercept trust bundle: {}",
ca_path.display()
);
}
let mut env_vars: Vec<(String, String)> = Vec::new();
for (key, value) in handle.env_vars() {
env_vars.push((key, value));
}
for (key, value) in handle.credential_env_vars(&proxy_config) {
env_vars.push((key, value));
}
std::mem::forget(rt);
Ok(ActiveProxyRuntime {
env_vars,
handle: Some(handle),
})
}
fn prepare_intercept_ca_dir() -> Result<Option<PathBuf>> {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
warn!(
"no $HOME found; skipping TLS-intercept setup (CONNECTs to L7-bearing routes \
will be denied with 403)"
);
return Ok(None);
}
};
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let suffix = format!("{}-{:09}", pid, nanos);
let dir = home
.join(".nono")
.join("sessions")
.join(format!("intercept-{}", suffix));
if let Err(e) = std::fs::create_dir_all(&dir) {
warn!(
"failed to create TLS-intercept dir '{}': {}; skipping interception",
dir.display(),
e
);
return Ok(None);
}
set_intercept_ca_dir_permissions(&dir)?;
Ok(Some(dir))
}
#[cfg(unix)]
fn set_intercept_ca_dir_permissions(dir: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
NonoError::SandboxInit(format!(
"failed to set owner-only permissions on TLS-intercept dir '{}': {e}",
dir.display()
))
})
}
#[cfg(not(unix))]
fn set_intercept_ca_dir_permissions(_dir: &Path) -> Result<()> {
Ok(())
}
fn read_parent_ssl_cert_file() -> Option<Vec<u8>> {
let path = std::env::var_os("SSL_CERT_FILE")?;
match std::fs::read(&path) {
Ok(bytes) => {
debug!(
"merging parent SSL_CERT_FILE '{}' ({} bytes) into TLS-intercept trust bundle",
std::path::Path::new(&path).display(),
bytes.len()
);
Some(bytes)
}
Err(e) => {
warn!(
"could not read parent SSL_CERT_FILE '{}': {} — corporate CAs configured on \
the host will not be trusted by the sandboxed child",
std::path::Path::new(&path).display(),
e
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn set_intercept_ca_dir_permissions_fails_closed() -> Result<()> {
let tmp = tempfile::tempdir().map_err(NonoError::Io)?;
let missing = tmp.path().join("missing");
let err = set_intercept_ca_dir_permissions(&missing)
.err()
.ok_or_else(|| {
NonoError::SandboxInit("expected missing intercept dir to fail".to_string())
})?;
assert!(matches!(err, NonoError::SandboxInit(_)));
assert!(err.to_string().contains("TLS-intercept dir"));
Ok(())
}
#[test]
fn test_parse_allow_domain_arg_plain_hostname() {
let entry = parse_allow_domain_arg("github.com");
assert_eq!(
entry,
crate::profile::AllowDomainEntry::Plain("github.com".to_string())
);
}
#[test]
fn test_parse_allow_domain_arg_url_with_path() {
let entry = parse_allow_domain_arg("https://github.com/atko-cic/**");
match entry {
crate::profile::AllowDomainEntry::WithEndpoints { domain, endpoints } => {
assert_eq!(domain, "github.com");
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].method, "*");
assert_eq!(endpoints[0].path, "/atko-cic/**");
}
_ => panic!("expected WithEndpoints, got: {:?}", entry),
}
}
#[test]
fn test_parse_allow_domain_arg_url_root_is_plain() {
let entry = parse_allow_domain_arg("https://api.example.com/");
assert_eq!(
entry,
crate::profile::AllowDomainEntry::Plain("api.example.com".to_string())
);
}
#[test]
fn test_parse_allow_domain_arg_url_no_path_is_plain() {
let entry = parse_allow_domain_arg("https://api.example.com");
assert_eq!(
entry,
crate::profile::AllowDomainEntry::Plain("api.example.com".to_string())
);
}
#[test]
fn test_parse_allow_domain_arg_deep_path() {
let entry = parse_allow_domain_arg("https://github.com/org/repo/tree/**");
match entry {
crate::profile::AllowDomainEntry::WithEndpoints { domain, endpoints } => {
assert_eq!(domain, "github.com");
assert_eq!(endpoints[0].path, "/org/repo/tree/**");
}
_ => panic!("expected WithEndpoints"),
}
}
}