use std::fmt;
use serde_json::{json, Value};
use crate::{
setup_resource_tags, Network, NetworkSettings, ResourceEntry, Sandbox, SandboxEgress, Stack,
};
pub const SANDBOX_EGRESS_POLICY_NAME: &str = "sandbox-egress-connector";
pub const NETWORK_CONNECTOR_TYPE_NAME: &str = "AWS::Lambda::NetworkConnector";
pub const LOOPBACK_ONLY_CIDR: &str = "127.0.0.1/32";
pub fn sandbox_egress_name(resource_prefix: &str, sandbox_id: &str) -> String {
format!("{resource_prefix}-{sandbox_id}-egress")
}
pub fn sandbox_egress_connector_name(resource_prefix: &str, sandbox_id: &str) -> String {
format!("{resource_prefix}-{sandbox_id}")
}
pub fn sandbox_egress_operator_trust_policy() -> Value {
json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
})
}
pub fn sandbox_egress_operator_policy(partition: &str, account_id: &str, region: &str) -> Value {
let arn = |kind: &str| format!("arn:{partition}:ec2:{region}:{account_id}:{kind}/*");
json!({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateENI",
"Effect": "Allow",
"Action": "ec2:CreateNetworkInterface",
"Resource": [arn("network-interface"), arn("subnet"), arn("security-group")]
},
{
"Sid": "TagENI",
"Effect": "Allow",
"Action": "ec2:CreateTags",
"Resource": arn("network-interface"),
"Condition": {
"StringEquals": {
"ec2:CreateAction": "CreateNetworkInterface",
"ec2:ManagedResourceOperator": "network-connectors.lambda.amazonaws.com"
}
}
}
]
})
}
#[derive(Debug, Clone, bon::Builder)]
pub struct SandboxEgressConnector<'a> {
resource_prefix: &'a str,
sandbox_id: &'a str,
operator_role_arn: &'a str,
private_subnet_ids: &'a [String],
security_group_id: &'a str,
}
impl SandboxEgressConnector<'_> {
pub fn desired_state(&self) -> Value {
let tags: Vec<Value> = setup_resource_tags(
self.resource_prefix,
self.sandbox_id,
Sandbox::RESOURCE_TYPE.as_ref(),
)
.into_iter()
.map(|(key, value)| json!({ "Key": key, "Value": value }))
.collect();
json!({
"Name": sandbox_egress_connector_name(self.resource_prefix, self.sandbox_id),
"OperatorRole": self.operator_role_arn,
"Configuration": {
"VpcEgressConfiguration": {
"AssociatedComputeResourceTypes": ["MicroVm"],
"NetworkProtocol": "IPv4",
"SubnetIds": self.private_subnet_ids,
"SecurityGroupIds": [self.security_group_id]
}
},
"Tags": tags
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEgressVpc {
Created,
BroughtByCustomer,
}
#[derive(Debug, Clone, Copy)]
pub struct SandboxEgressNetwork<'a> {
pub id: &'a str,
pub entry: &'a ResourceEntry,
pub vpc: SandboxEgressVpc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEgressRefusal {
DomainAllowList,
NoNetwork,
DefaultVpc,
OtherCloudNetwork,
}
impl fmt::Display for SandboxEgressRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an AWS sandbox routes session traffic through a VPC egress connector")?;
f.write_str(match self {
Self::DomainAllowList => {
", which has no configuration for egress 'allowDomains'. Declare egress: deny for \
a connector that reaches nothing, or egress: allow for no connector at all"
}
Self::NoNetwork => ", and this stack declares no network for it to attach to",
Self::DefaultVpc => {
", which needs private subnets, and the account's default VPC has only public \
subnets. Set the network to create or byo-vpc-aws"
}
Self::OtherCloudNetwork => ", and this stack's network settings are for another cloud",
})
}
}
pub fn sandbox_egress_network<'a>(
stack: &'a Stack,
egress: &SandboxEgress,
) -> Result<Option<SandboxEgressNetwork<'a>>, SandboxEgressRefusal> {
match egress {
SandboxEgress::Allow => return Ok(None),
SandboxEgress::AllowDomains { .. } => return Err(SandboxEgressRefusal::DomainAllowList),
SandboxEgress::Deny => {}
}
let (id, entry, network) = stack
.resources()
.find_map(|(id, entry)| Some((id, entry, entry.config.downcast_ref::<Network>()?)))
.ok_or(SandboxEgressRefusal::NoNetwork)?;
let vpc = match &network.settings {
NetworkSettings::Create { .. } => SandboxEgressVpc::Created,
NetworkSettings::ByoVpcAws { .. } => SandboxEgressVpc::BroughtByCustomer,
NetworkSettings::UseDefault => return Err(SandboxEgressRefusal::DefaultVpc),
NetworkSettings::ByoVpcGcp { .. } | NetworkSettings::ByoVnetAzure { .. } => {
return Err(SandboxEgressRefusal::OtherCloudNetwork)
}
};
Ok(Some(SandboxEgressNetwork { id, entry, vpc }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ResourceLifecycle, SandboxCode, SandboxLifecyclePolicy};
use SandboxEgressRefusal::{DefaultVpc, DomainAllowList, NoNetwork, OtherCloudNetwork};
use SandboxEgressVpc::{BroughtByCustomer, Created};
fn outcome(
egress: SandboxEgress,
network: Option<NetworkSettings>,
) -> Result<Option<SandboxEgressVpc>, SandboxEgressRefusal> {
let sandbox = Sandbox::new("agents".to_string())
.code(SandboxCode::Image {
image: "s3://acme/agents/bundle.zip".to_string(),
})
.egress(egress.clone())
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
let mut stack = Stack::new("acme".to_string()).add(sandbox, ResourceLifecycle::Frozen);
if let Some(settings) = network {
stack = stack.add(
Network::new("net".to_string()).settings(settings).build(),
ResourceLifecycle::Frozen,
);
}
let stack = stack.build();
let found = sandbox_egress_network(&stack, &egress)?;
if let Some(found) = &found {
assert_eq!(found.id, "net");
}
Ok(found.map(|found| found.vpc))
}
#[test]
fn a_deny_sandbox_attaches_to_the_stacks_first_network() {
let create = || NetworkSettings::Create {
cidr: None,
availability_zones: 2,
};
let byo_aws = || NetworkSettings::ByoVpcAws {
vpc_id: "vpc-1".to_string(),
public_subnet_ids: vec!["subnet-pub".to_string()],
private_subnet_ids: vec!["subnet-priv".to_string()],
security_group_ids: vec![],
};
for (first, second, expected) in [
(create(), byo_aws(), Created),
(byo_aws(), create(), BroughtByCustomer),
] {
let stack = Stack::new("acme".to_string())
.add(
Network::new("first".to_string()).settings(first).build(),
ResourceLifecycle::Frozen,
)
.add(
Network::new("second".to_string()).settings(second).build(),
ResourceLifecycle::Frozen,
)
.build();
let found = sandbox_egress_network(&stack, &SandboxEgress::Deny)
.expect("a created or brought VPC is attachable")
.expect("deny attaches to a network");
assert_eq!((found.id, found.vpc), ("first", expected));
}
}
#[test]
fn the_operator_role_tags_only_the_interface_it_is_creating() {
let policy = sandbox_egress_operator_policy("aws", "123456789012", "us-east-1");
let tagging = policy["Statement"]
.as_array()
.expect("statements")
.iter()
.find(|statement| statement["Action"] == "ec2:CreateTags")
.expect("the role tags the interfaces it creates");
assert_eq!(
tagging["Condition"],
json!({
"StringEquals": {
"ec2:CreateAction": "CreateNetworkInterface",
"ec2:ManagedResourceOperator": "network-connectors.lambda.amazonaws.com"
}
})
);
}
#[test]
fn every_egress_mode_and_network_setting_resolves_to_one_outcome() {
let create = || NetworkSettings::Create {
cidr: None,
availability_zones: 2,
};
let byo_aws = || NetworkSettings::ByoVpcAws {
vpc_id: "vpc-1".to_string(),
public_subnet_ids: vec!["subnet-pub".to_string()],
private_subnet_ids: vec!["subnet-priv".to_string()],
security_group_ids: vec![],
};
let byo_gcp = || NetworkSettings::ByoVpcGcp {
network_name: "vpc".to_string(),
subnet_name: "subnet".to_string(),
region: "us-central1".to_string(),
};
let domains = || SandboxEgress::AllowDomains {
domains: vec!["example.com".to_string()],
};
let cases = [
(SandboxEgress::Allow, None, Ok(None)),
(
SandboxEgress::Allow,
Some(NetworkSettings::UseDefault),
Ok(None),
),
(SandboxEgress::Allow, Some(byo_gcp()), Ok(None)),
(domains(), None, Err(DomainAllowList)),
(domains(), Some(create()), Err(DomainAllowList)),
(SandboxEgress::Deny, None, Err(NoNetwork)),
(SandboxEgress::Deny, Some(create()), Ok(Some(Created))),
(
SandboxEgress::Deny,
Some(byo_aws()),
Ok(Some(BroughtByCustomer)),
),
(
SandboxEgress::Deny,
Some(NetworkSettings::UseDefault),
Err(DefaultVpc),
),
(SandboxEgress::Deny, Some(byo_gcp()), Err(OtherCloudNetwork)),
];
for (egress, network, expected) in cases {
let label = format!("{egress:?} on {network:?}");
assert_eq!(outcome(egress, network), expected, "{label}");
}
}
}