use serde_json::{Value, json};
use std::time::Duration;
use crate::a2a::tasks::PushTarget;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(10);
pub fn check_url(url: &str, allow_private: bool) -> Result<(), String> {
let u = crate::net::http::Url::parse(url).map_err(|e| format!("bad url: {e}"))?;
if !u.is_tls() && !crate::net::http::is_loopback_host(&u.host) {
return Err("a push endpoint must be https (or loopback for development)".into());
}
crate::net::ssrf::guard_host(&u.host, allow_private).map_err(|e| e.to_string())
}
pub fn deliver(target: &PushTarget, event: &Value, allow_private: bool) -> Result<(), String> {
check_url(&target.url, allow_private)?;
let u = crate::net::http::Url::parse(&target.url).map_err(|e| e.to_string())?;
let body = serde_json::to_vec(event).map_err(|e| e.to_string())?;
let mut headers: Vec<(String, String)> = vec![
("content-type".into(), "application/json".into()),
("user-agent".into(), format!("agentd/{}", crate::VERSION)),
];
if !target.token.is_empty() {
headers.push(("x-a2a-notification-token".into(), target.token.clone()));
}
if let Some(b) = &target.bearer {
headers.push(("authorization".into(), format!("Bearer {b}")));
}
let refs: Vec<(&str, &str)> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let tcp = crate::net::ssrf::connect_vetted(&u.host, u.port, DELIVERY_TIMEOUT, allow_private)
.map_err(|e| e.to_string())?;
let resp = if u.is_tls() {
#[cfg(feature = "tls")]
{
let mut s = crate::net::tls::connect(tcp, &u.host, None).map_err(|e| e.to_string())?;
crate::net::http::send(&mut s, &u.host_header(), "POST", &u.path, &refs, &body)
.map_err(|e| e.to_string())?
}
#[cfg(not(feature = "tls"))]
{
return Err("an https push endpoint needs the 'tls' build feature".into());
}
} else {
let mut s = tcp;
crate::net::http::send(&mut s, &u.host_header(), "POST", &u.path, &refs, &body)
.map_err(|e| e.to_string())?
};
if resp.is_success() {
Ok(())
} else {
Err(format!("push endpoint answered {}", resp.status))
}
}
pub fn to_wire(task_id: &str, t: &PushTarget) -> Value {
let mut v = json!({"id": t.id, "taskId": task_id, "url": t.url});
if !t.token.is_empty() {
v["token"] = json!(t.token);
}
v
}
pub fn from_wire(v: &Value, id: String) -> Result<PushTarget, String> {
let url = v
.get("url")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or("push config needs a url")?
.to_string();
let token = v
.get("token")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let auth = v.get("authentication");
let schemes = auth
.and_then(|a| a.get("schemes"))
.and_then(Value::as_array)
.map(|s| {
s.iter()
.filter_map(Value::as_str)
.any(|x| x.eq_ignore_ascii_case("bearer"))
})
.unwrap_or(false);
let bearer = if schemes {
auth.and_then(|a| a.get("credentials"))
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
};
Ok(PushTarget {
id,
url,
token,
bearer,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_caller_supplied_url_is_refused_before_it_is_ever_dialled() {
assert!(check_url("http://169.254.169.254/latest/meta-data/", false).is_err());
assert!(check_url("https://10.0.0.5/internal", false).is_err());
assert!(check_url("https://[::1]/x", false).is_err());
assert!(check_url("http://example.com/hook", false).is_err());
assert!(check_url("http://127.0.0.1:9000/hook", false).is_err());
assert!(check_url("https://10.0.0.5/internal", true).is_ok());
assert!(check_url("http://127.0.0.1:9000/hook", true).is_ok());
assert!(check_url("https://93.184.216.34/agentd", false).is_ok());
}
#[test]
fn a_config_round_trips_without_leaking_the_credential() {
let cfg = json!({
"url": "https://hooks.example/agentd",
"token": "caller-token",
"authentication": {"schemes": ["Bearer"], "credentials": "secret-bearer"}
});
let t = from_wire(&cfg, "pc-1".into()).expect("a valid config");
assert_eq!(t.url, "https://hooks.example/agentd");
assert_eq!(t.token, "caller-token");
assert_eq!(t.bearer.as_deref(), Some("secret-bearer"));
let wire = to_wire("task-1", &t);
assert_eq!(wire["taskId"], "task-1");
assert_eq!(wire["token"], "caller-token");
assert!(wire.get("authentication").is_none(), "{wire}");
assert!(!wire.to_string().contains("secret-bearer"), "{wire}");
}
#[test]
fn a_config_without_a_url_is_not_a_config() {
assert!(from_wire(&json!({"token": "t"}), "pc-1".into()).is_err());
assert!(from_wire(&json!({"url": ""}), "pc-1".into()).is_err());
}
#[test]
fn an_unasked_for_scheme_does_not_become_a_bearer() {
let cfg = json!({
"url": "https://hooks.example/x",
"authentication": {"schemes": ["ApiKey"], "credentials": "k"}
});
assert!(from_wire(&cfg, "p".into()).unwrap().bearer.is_none());
}
}