use agent_sdk_core::{AgentError, NetworkIsolationPolicy};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EgressProtocol {
Https,
Http,
Tcp,
Udp,
}
impl EgressProtocol {
fn from_scheme(scheme: &str) -> Option<Self> {
match scheme {
"https" => Some(Self::Https),
"http" => Some(Self::Http),
"tcp" => Some(Self::Tcp),
"udp" => Some(Self::Udp),
_ => None,
}
}
fn default_port(self) -> Option<u16> {
match self {
Self::Https => Some(443),
Self::Http => Some(80),
Self::Tcp | Self::Udp => None,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::Http => "http",
Self::Tcp => "tcp",
Self::Udp => "udp",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct EgressTarget {
pub host: String,
pub port: u16,
pub protocol: EgressProtocol,
}
impl EgressTarget {
pub fn parse(input: impl AsRef<str>) -> Result<Self, AgentError> {
let input = input.as_ref().trim();
if input.is_empty() {
return Err(invalid_egress_target("egress target is empty"));
}
if input.chars().any(char::is_whitespace) {
return Err(invalid_egress_target(
"egress target must not contain whitespace",
));
}
let (protocol, authority) = if let Some((scheme, rest)) = input.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
let protocol = EgressProtocol::from_scheme(&scheme).ok_or_else(|| {
invalid_egress_target(format!("unsupported egress protocol: {scheme}"))
})?;
(protocol, rest)
} else {
(EgressProtocol::Https, input)
};
if authority
.chars()
.any(|ch| matches!(ch, '/' | '?' | '#' | '@' | '\\'))
{
return Err(invalid_egress_target(
"egress target must be a host or host:port, not a URL path or credential",
));
}
let (host, port) = parse_authority(authority, protocol)?;
validate_host(&host)?;
Ok(Self {
host: host.to_ascii_lowercase(),
port,
protocol,
})
}
pub fn canonical(&self) -> String {
format!("{}://{}:{}", self.protocol.as_str(), self.host, self.port)
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct EgressAllowlist {
entries: Vec<String>,
}
impl EgressAllowlist {
pub fn new() -> Self {
Self::default()
}
pub fn allow(mut self, target: impl Into<String>) -> Self {
self.entries.push(target.into());
self
}
pub fn try_allow(mut self, target: impl AsRef<str>) -> Result<Self, AgentError> {
self.entries.push(EgressTarget::parse(target)?.canonical());
Ok(self)
}
pub fn from_targets(targets: impl IntoIterator<Item = impl Into<String>>) -> Self {
let mut allowlist = Self::new();
for target in targets {
allowlist = allowlist.allow(target);
}
allowlist
}
pub fn try_from_targets(
targets: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<Self, AgentError> {
let mut allowlist = Self::new();
for target in targets {
allowlist = allowlist.try_allow(target)?;
}
Ok(allowlist)
}
pub fn targets(&self) -> Result<Vec<EgressTarget>, AgentError> {
self.parsed_targets()
}
pub fn canonical_entries(&self) -> Result<Vec<String>, AgentError> {
Ok(self
.parsed_targets()?
.iter()
.map(EgressTarget::canonical)
.collect::<Vec<_>>())
}
pub fn network_policy(&self) -> Result<NetworkIsolationPolicy, AgentError> {
Ok(NetworkIsolationPolicy::EgressScoped {
rules: self.canonical_entries()?,
})
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn parsed_targets(&self) -> Result<Vec<EgressTarget>, AgentError> {
let mut targets = self
.entries
.iter()
.map(EgressTarget::parse)
.collect::<Result<Vec<_>, _>>()?;
targets.sort();
targets.dedup();
Ok(targets)
}
}
fn parse_authority(authority: &str, protocol: EgressProtocol) -> Result<(String, u16), AgentError> {
if authority.is_empty() {
return Err(invalid_egress_target("egress target host is empty"));
}
if authority.matches(':').count() > 1 {
return Err(invalid_egress_target(
"egress target must not use raw IPv6 or multiple port separators",
));
}
if let Some((host, port)) = authority.rsplit_once(':') {
if host.is_empty() {
return Err(invalid_egress_target("egress target host is empty"));
}
let port = port
.parse::<u16>()
.map_err(|_| invalid_egress_target("egress target port must be 1..65535"))?;
if port == 0 {
return Err(invalid_egress_target("egress target port must be nonzero"));
}
Ok((host.to_string(), port))
} else if let Some(port) = protocol.default_port() {
Ok((authority.to_string(), port))
} else {
Err(invalid_egress_target(
"tcp and udp egress targets must include an explicit port",
))
}
}
fn validate_host(host: &str) -> Result<(), AgentError> {
if host.is_empty() {
return Err(invalid_egress_target("egress target host is empty"));
}
if host == "*" || host.contains('*') {
return Err(invalid_egress_target(
"egress target host wildcards are not supported",
));
}
if host.starts_with('.') || host.ends_with('.') {
return Err(invalid_egress_target(
"egress target host must not start or end with '.'",
));
}
for label in host.split('.') {
if label.is_empty() {
return Err(invalid_egress_target(
"egress target host must not contain empty labels",
));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(invalid_egress_target(
"egress target host labels must not start or end with '-'",
));
}
if !label
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
return Err(invalid_egress_target(
"egress target host labels must be ASCII alphanumeric or '-'",
));
}
}
Ok(())
}
fn invalid_egress_target(message: impl Into<String>) -> AgentError {
AgentError::contract_violation(message.into())
}