use std::net::IpAddr;
use std::path::Path;
use anyhow::{Context, Result};
#[must_use]
pub(crate) fn is_local_address(host: &str) -> bool {
let h = host.trim();
if h.is_empty() {
return false;
}
let lower = h.to_ascii_lowercase();
if lower == "localhost" || lower.ends_with(".localhost") {
return true;
}
let bare = h
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(h);
match bare.parse::<IpAddr>() {
Ok(ip) => ip_is_local(ip),
Err(_) => false,
}
}
fn ip_is_local(ip: IpAddr) -> bool {
astrid_core::net::ip_is_blocked(ip)
}
#[must_use]
pub(crate) fn endpoint_host_port(base_url: &str) -> Option<(String, u16)> {
let url = url::Url::parse(base_url.trim()).ok()?;
let host = url.host_str()?.to_string();
let port = url.port_or_known_default()?;
Some((host, port))
}
#[must_use]
pub(crate) fn local_egress_entry(base_url: &str) -> Option<String> {
let (host, port) = endpoint_host_port(base_url)?;
if is_local_address(&host) {
Some(format!("{host}:{port}"))
} else {
None
}
}
pub(crate) fn record_local_egress(config_path: &Path, capsule_id: &str, entry: &str) -> Result<()> {
let mut doc = if config_path.exists() {
let existing = std::fs::read_to_string(config_path)
.with_context(|| format!("read {}", config_path.display()))?;
existing
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("{} is not valid TOML", config_path.display()))?
} else {
toml_edit::DocumentMut::new()
};
let security = doc["security"].or_insert(toml_edit::table());
if let Some(t) = security.as_table_mut() {
t.set_implicit(true);
}
let egress = doc["security"]["capsule_local_egress"].or_insert(toml_edit::table());
if let Some(t) = egress.as_table_mut() {
t.set_implicit(true);
}
let list = doc["security"]["capsule_local_egress"][capsule_id].or_insert(
toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())),
);
let Some(arr) = list.as_array_mut() else {
anyhow::bail!("existing [security.capsule_local_egress].{capsule_id} is not an array");
};
let already = arr
.iter()
.any(|v| v.as_str().is_some_and(|s| s.eq_ignore_ascii_case(entry)));
if !already {
arr.push(entry);
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
write_atomic(config_path, doc.to_string().as_bytes())
.with_context(|| format!("write {}", config_path.display()))
}
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_path = path.with_extension(format!("toml.tmp.{}.{seq}", std::process::id()));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(data)?;
f.sync_all()?;
drop(f);
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, data)
}
}
pub(crate) fn maybe_prompt_local_egress(capsule_id: &str, value: &str, config_path: &Path) {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return;
}
prompt_and_record(capsule_id, value, config_path, || {
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok()?;
Some(input)
});
}
fn prompt_and_record(
capsule_id: &str,
value: &str,
config_path: &Path,
read_answer: impl FnOnce() -> Option<String>,
) {
let Some(entry) = local_egress_entry(value) else {
return;
};
eprintln!();
eprintln!(" '{value}' is a local/private address. Capsules cannot reach local");
eprintln!(" endpoints unless you add an SSRF-airlock exemption.");
eprint!(" Allow '{capsule_id}' to reach {entry}? [y/N]: ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let Some(input) = read_answer() else {
return;
};
let answer = input.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
eprintln!(" Skipped. The capsule will be blocked from {entry} until you add it.");
return;
}
match record_local_egress(config_path, capsule_id, &entry) {
Ok(()) => {
eprintln!(" Added {entry} to [security.capsule_local_egress].{capsule_id}.");
eprintln!(" Restart the daemon for the change to take effect.");
eprintln!(
" Note: removing this exemption later also requires a daemon restart \
— editing the config alone does not revoke an in-flight grant."
);
},
Err(e) => {
eprintln!(" Could not update operator config ({e}).");
eprintln!(
" Add this to {} manually:\n [security.capsule_local_egress]\n {capsule_id} = [\"{entry}\"]",
config_path.display()
);
},
}
}
#[cfg(test)]
#[path = "local_egress_tests.rs"]
mod tests;