use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::egress::{EgressDenied, EgressPolicy};
type Result<T> = std::result::Result<T, String>;
const MAX_BODY: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub enum Credential {
Bearer(String),
Header { name: String, value: String },
}
impl Credential {
pub fn bearer_from_env(var: &str) -> Result<Credential> {
let v = std::env::var(var)
.map_err(|_| format!("credential env var {var} is not set"))?;
if v.trim().is_empty() {
return Err(format!("credential env var {var} is empty"));
}
Ok(Credential::Bearer(v))
}
}
#[derive(Debug, Clone, Default)]
pub struct CallerGrant {
pub credentials: std::collections::BTreeSet<String>,
pub methods: std::collections::BTreeSet<String>,
}
impl CallerGrant {
pub fn new() -> Self {
Self::default()
}
pub fn credential(mut self, name: &str) -> Self {
self.credentials.insert(name.to_string());
self
}
pub fn method(mut self, m: &str) -> Self {
self.methods.insert(m.trim().to_ascii_uppercase());
self
}
fn permits_method(&self, method: &str) -> bool {
if self.methods.is_empty() {
return matches!(method, "GET" | "HEAD");
}
self.methods.contains(method)
}
}
#[derive(Debug, Clone, Default)]
pub struct EgressGrants {
by_caller: BTreeMap<String, CallerGrant>,
default_grant: Option<CallerGrant>,
}
impl EgressGrants {
pub fn new() -> Self {
Self::default()
}
pub fn grant(mut self, caller: &str, g: CallerGrant) -> Self {
self.by_caller.insert(caller.to_string(), g);
self
}
pub fn default_for_all(mut self, g: CallerGrant) -> Self {
self.default_grant = Some(g);
self
}
fn for_caller(&self, caller: &str) -> Option<&CallerGrant> {
self.by_caller.get(caller).or(self.default_grant.as_ref())
}
fn callers(&self) -> Vec<String> {
self.by_caller.keys().cloned().collect()
}
}
fn mint_token() -> String {
let mut b = [0u8; 24];
getrandom::getrandom(&mut b).expect("OS randomness for the egress token");
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EgressRefusal {
pub caller: String,
pub destination: String,
pub reason: String,
}
pub struct Broker {
url: String,
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
refusals: Arc<std::sync::Mutex<Vec<EgressRefusal>>>,
tokens: BTreeMap<String, String>,
default_token: Option<String>,
}
impl Broker {
pub fn start(
policy: EgressPolicy,
credentials: BTreeMap<String, Credential>,
grants: EgressGrants,
refusal_code: &'static str,
) -> Result<Broker> {
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("egress broker cannot bind loopback: {e}"))?;
let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
listener.set_nonblocking(true).ok();
let stop = Arc::new(AtomicBool::new(false));
let refusals = Arc::new(std::sync::Mutex::new(Vec::new()));
let (stop_t, refusals_t) = (Arc::clone(&stop), Arc::clone(&refusals));
let mut tokens = BTreeMap::new();
for c in grants.callers() {
tokens.insert(c, mint_token());
}
let default_token = grants.default_grant.as_ref().map(|_| mint_token());
let by_token: BTreeMap<String, String> = tokens
.iter()
.map(|(c, t)| (t.clone(), c.clone()))
.chain(default_token.iter().map(|t| (t.clone(), String::new())))
.collect();
let handle = std::thread::spawn(move || {
while !stop_t.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
if stream.set_nonblocking(false).is_err() {
continue;
}
let _ = serve_one(
stream,
&policy,
&credentials,
&refusals_t,
&grants,
&by_token,
refusal_code,
);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(_) => break,
}
}
});
Ok(Broker {
url: format!("http://127.0.0.1:{port}"),
stop,
handle: Some(handle),
refusals,
tokens,
default_token,
})
}
pub fn token_for(&self, caller: &str) -> Option<&str> {
self.tokens
.get(caller)
.or(self.default_token.as_ref())
.map(String::as_str)
}
pub fn url(&self) -> &str {
&self.url
}
pub fn refusals(&self) -> Vec<EgressRefusal> {
self.refusals.lock().map(|r| r.clone()).unwrap_or_default()
}
}
impl Drop for Broker {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
drop(self.handle.take());
}
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct EgressRequest {
url: String,
method: String,
credential: Option<String>,
body: Option<String>,
}
#[allow(clippy::too_many_arguments)]
fn note_refusal(refusals: &Arc<std::sync::Mutex<Vec<EgressRefusal>>>, r: EgressRefusal) {
if let Ok(mut list) = refusals.lock() {
if !list.contains(&r) {
list.push(r);
}
}
}
fn serve_one(
mut stream: TcpStream,
policy: &EgressPolicy,
credentials: &BTreeMap<String, Credential>,
refusals: &Arc<std::sync::Mutex<Vec<EgressRefusal>>>,
grants: &EgressGrants,
by_token: &BTreeMap<String, String>,
refusal_code: &'static str,
) -> std::io::Result<()> {
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut content_length = 0usize;
let mut presented: Option<String> = None;
loop {
let mut h = String::new();
if reader.read_line(&mut h)? == 0 || h.trim().is_empty() {
break;
}
let lower = h.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
content_length = v.trim().parse().unwrap_or(0);
}
if lower.starts_with("x-areev-egress-token:") {
presented = h.split_once(':').map(|(_, v)| v.trim().to_string());
}
}
let caller = match presented.and_then(|t| by_token.get(&t).cloned()) {
Some(c) => c,
None => {
return respond(
&mut stream,
401,
&serde_json::json!({
"error": "missing or unknown X-Areev-Egress-Token",
"code": refusal_code
})
.to_string(),
)
}
};
let Some(grant) = grants.for_caller(&caller) else {
return respond(
&mut stream,
403,
&serde_json::json!({
"error": format!("caller '{caller}' has no egress grant"),
"code": refusal_code
})
.to_string(),
);
};
if content_length > MAX_BODY {
return respond(&mut stream, 413, r#"{"error":"body too large"}"#);
}
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
let req: EgressRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(e) => {
return respond(
&mut stream,
400,
&serde_json::json!({ "error": format!("bad egress request: {e}") }).to_string(),
)
}
};
if let Err(e) = policy.permits(&req.url) {
note_refusal(
refusals,
EgressRefusal {
caller: caller.clone(),
destination: req.url.clone(),
reason: "destination outside the declared allowlist".into(),
},
);
return respond(
&mut stream,
403,
&serde_json::json!({
"error": format!("caller '{caller}' {e}"),
"code": refusal_code
})
.to_string(),
);
}
let method = if req.method.trim().is_empty() { "GET" } else { req.method.trim() };
let method = method.to_ascii_uppercase();
if !grant.permits_method(&method) {
let denied = EgressDenied::Method { method: method.clone() };
note_refusal(
refusals,
EgressRefusal {
caller: caller.clone(),
destination: req.url.clone(),
reason: format!("method {method} is not permitted for this caller"),
},
);
return respond(
&mut stream,
403,
&serde_json::json!({
"error": format!("caller '{caller}' {denied}"),
"code": refusal_code
})
.to_string(),
);
}
let mut headers: Vec<(String, String)> = Vec::new();
if let Some(name) = &req.credential {
if !grant.credentials.contains(name) {
note_refusal(
refusals,
EgressRefusal {
caller: caller.clone(),
destination: req.url.clone(),
reason: format!("credential '{name}' is not granted to this caller"),
},
);
return respond(
&mut stream,
403,
&serde_json::json!({
"error": format!("caller '{caller}' may not use credential '{name}'"),
"code": refusal_code
})
.to_string(),
);
}
match credentials.get(name) {
Some(Credential::Bearer(v)) => {
headers.push(("Authorization".into(), format!("Bearer {v}")))
}
Some(Credential::Header { name, value }) => {
headers.push((name.clone(), value.clone()))
}
None => {
return respond(
&mut stream,
400,
&serde_json::json!({
"error": format!("no credential named '{name}' is configured for this run")
})
.to_string(),
)
}
}
}
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(15)))
.timeout_recv_response(Some(std::time::Duration::from_secs(60)))
.timeout_recv_body(Some(std::time::Duration::from_secs(60)))
.build()
.into();
let result = match method.as_str() {
"GET" | "DELETE" => {
let mut b = if method == "GET" { agent.get(&req.url) } else { agent.delete(&req.url) };
for (k, v) in &headers {
b = b.header(k, v);
}
b.call()
}
"POST" | "PUT" | "PATCH" => {
let mut b = match method.as_str() {
"POST" => agent.post(&req.url),
"PUT" => agent.put(&req.url),
_ => agent.patch(&req.url),
};
for (k, v) in &headers {
b = b.header(k, v);
}
b.send(req.body.as_deref().unwrap_or(""))
}
other => {
return respond(
&mut stream,
400,
&serde_json::json!({ "error": format!("unsupported method '{other}'") }).to_string(),
)
}
};
match result {
Ok(mut resp) => {
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().unwrap_or_default();
respond(
&mut stream,
200,
&serde_json::json!({ "status": status, "body": text }).to_string(),
)
}
Err(e) => respond(
&mut stream,
502,
&serde_json::json!({ "error": format!("upstream: {e}") }).to_string(),
),
}
}
fn respond(stream: &mut TcpStream, status: u16, body: &str) -> std::io::Result<()> {
let head = format!(
"HTTP/1.1 {status} \r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes())?;
stream.write_all(body.as_bytes())?;
stream.flush()
}
#[cfg(test)]
mod tests {
use super::*;
fn call(broker: &Broker, req: serde_json::Value) -> (u16, serde_json::Value) {
call_as(broker, "connector", req)
}
fn call_as(broker: &Broker, caller: &str, req: serde_json::Value) -> (u16, serde_json::Value) {
let token = broker.token_for(caller).unwrap_or("not-a-real-token").to_string();
call_with_token(broker, &token, req)
}
fn call_with_token(
broker: &Broker,
token: &str,
req: serde_json::Value,
) -> (u16, serde_json::Value) {
let addr = broker.url().trim_start_matches("http://").to_string();
let mut s = TcpStream::connect(addr).unwrap();
let body = req.to_string();
let head = format!(
"POST / HTTP/1.1\r\nHost: localhost\r\nX-Areev-Egress-Token: {token}\r\n\
Content-Length: {}\r\n\r\n",
body.len()
);
s.write_all(head.as_bytes()).unwrap();
s.write_all(body.as_bytes()).unwrap();
s.flush().unwrap();
let mut raw = String::new();
s.read_to_string(&mut raw).unwrap();
let status: u16 = raw
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let body = raw.split("\r\n\r\n").nth(1).unwrap_or("{}");
(status, serde_json::from_str(body).unwrap_or(serde_json::Value::Null))
}
fn grants(creds: &[&str]) -> EgressGrants {
let g = creds.iter().fold(
CallerGrant::new().method("GET").method("POST").method("DELETE"),
|g, c| g.credential(c),
);
EgressGrants::new().default_for_all(g)
}
fn policy(entries: &[&str]) -> EgressPolicy {
EgressPolicy::from_config(Some(&serde_json::json!({
"int:allowed_outbound_hosts": entries
})))
.unwrap()
}
#[test]
fn a_disallowed_destination_is_refused_before_any_request_is_made() {
let b = Broker::start(policy(&["https://api.github.com"]), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
let (status, body) = call(&b, serde_json::json!({ "url": "https://evil.com/steal" }));
assert_eq!(status, 403);
assert_eq!(body["code"], "TRG-E009");
let refusals = b.refusals();
assert_eq!(refusals.len(), 1);
assert_eq!(refusals[0].destination, "https://evil.com/steal");
assert!(refusals[0].reason.contains("allowlist"), "{:?}", refusals[0]);
}
#[test]
fn a_caller_cannot_name_a_credential_its_grant_does_not_cover() {
let b = Broker::start(
policy(&["https://api.github.com"]),
BTreeMap::new(),
grants(&[]),
"TRG-E009",
)
.unwrap();
let (status, body) = call(
&b,
serde_json::json!({ "url": "https://api.github.com/x", "credential": "gmail" }),
);
assert_eq!(status, 403);
assert!(body["error"].as_str().unwrap().contains("may not use credential"), "{body}");
}
#[test]
fn a_granted_credential_that_the_host_never_configured_is_a_client_error() {
let b = Broker::start(
policy(&["https://api.github.com"]),
BTreeMap::new(),
grants(&["gmail"]),
"TRG-E009",
)
.unwrap();
let (status, body) = call(
&b,
serde_json::json!({ "url": "https://api.github.com/x", "credential": "gmail" }),
);
assert_eq!(status, 400);
assert!(body["error"].as_str().unwrap().contains("no credential named"), "{body}");
}
#[test]
fn a_malformed_request_is_a_client_error_not_a_panic() {
let b = Broker::start(policy(&[]), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
let (status, _) = call(&b, serde_json::json!("not an egress request"));
assert_eq!(status, 400);
}
#[test]
fn a_caller_with_no_token_is_refused_before_its_body_is_parsed() {
let b = Broker::start(policy(&[]), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
let addr = b.url().trim_start_matches("http://").to_string();
let mut s = TcpStream::connect(addr).unwrap();
let body = "not json";
s.write_all(
format!("POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n{body}", body.len())
.as_bytes(),
)
.unwrap();
s.flush().unwrap();
let mut raw = String::new();
s.read_to_string(&mut raw).unwrap();
assert!(raw.contains("401"), "{raw}");
}
#[test]
fn a_forged_token_is_refused() {
let b = Broker::start(policy(&[]), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
let (status, _) =
call_with_token(&b, "0".repeat(48).as_str(), serde_json::json!({ "url": "https://x/" }));
assert_eq!(status, 401);
}
#[test]
fn a_grant_naming_no_method_may_only_read() {
let b = Broker::start(
EgressPolicy::unrestricted(),
BTreeMap::new(),
EgressGrants::new().grant("reader", CallerGrant::new()),
"TRG-E009",
)
.unwrap();
let (status, body) = call_as(
&b,
"reader",
serde_json::json!({ "url": "https://example.com/x", "method": "POST" }),
);
assert_eq!(status, 403);
assert!(body["error"].as_str().unwrap().contains("not permitted"), "{body}");
}
#[test]
fn one_callers_token_does_not_buy_anothers_scope() {
let b = Broker::start(
EgressPolicy::unrestricted(),
BTreeMap::new(),
EgressGrants::new()
.grant("writer", CallerGrant::new().method("POST").credential("zoho"))
.grant("reader", CallerGrant::new()),
"TRG-E009",
)
.unwrap();
assert_ne!(b.token_for("writer").unwrap(), b.token_for("reader").unwrap());
let (status, _) = call_as(
&b,
"reader",
serde_json::json!({ "url": "https://example.com/x", "method": "POST" }),
);
assert_eq!(status, 403);
let (status, _) = call_as(
&b,
"reader",
serde_json::json!({ "url": "https://example.com/x", "credential": "zoho" }),
);
assert_eq!(status, 403);
}
#[test]
fn a_caller_with_no_grant_at_all_gets_no_token() {
let b = Broker::start(
EgressPolicy::unrestricted(),
BTreeMap::new(),
EgressGrants::new().grant("writer", CallerGrant::new()),
"TRG-E009",
)
.unwrap();
assert!(b.token_for("nobody").is_none(), "an ungranted caller must not see the broker");
}
#[test]
fn the_broker_binds_loopback_only() {
let b = Broker::start(policy(&[]), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
assert!(b.url().starts_with("http://127.0.0.1:"), "{}", b.url());
}
#[test]
fn credentials_come_from_a_named_variable_never_a_literal() {
std::env::set_var("AREEV_TEST_BROKER_TOKEN", "s3cret");
let c = Credential::bearer_from_env("AREEV_TEST_BROKER_TOKEN").unwrap();
std::env::remove_var("AREEV_TEST_BROKER_TOKEN");
assert!(matches!(c, Credential::Bearer(v) if v == "s3cret"));
assert!(Credential::bearer_from_env("AREEV_TEST_BROKER_ABSENT").is_err());
std::env::set_var("AREEV_TEST_BROKER_EMPTY", " ");
assert!(Credential::bearer_from_env("AREEV_TEST_BROKER_EMPTY").is_err());
std::env::remove_var("AREEV_TEST_BROKER_EMPTY");
}
#[test]
fn an_unrestricted_policy_still_brokers_rather_than_handing_over_the_token() {
let b = Broker::start(EgressPolicy::unrestricted(), BTreeMap::new(), grants(&[]), "TRG-E009").unwrap();
let (status, _) = call(&b, serde_json::json!({ "url": "https://anywhere.example/" }));
assert_ne!(status, 403, "an absent allowlist does not refuse");
}
}