use std::collections::HashMap;
use std::net::Ipv4Addr;
use anyhow::{Context, Result, bail};
use tokio::process::Command;
const PORT_RANGE_START: u16 = 40000;
const PORT_RANGE_END: u16 = 49999;
const RULE_COMMENT: &str = "arcbox-sbx";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Protocol {
Tcp,
Udp,
}
impl Protocol {
pub fn parse(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"" | "tcp" => Ok(Self::Tcp),
"udp" => Ok(Self::Udp),
other => bail!("unsupported protocol '{other}' (expected tcp or udp)"),
}
}
const fn iptables_name(self) -> &'static str {
match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
}
}
}
struct ForwardRule {
guest_port: u16,
rule_args: Vec<String>,
}
#[derive(Default)]
pub struct PortForwardManager {
rules: HashMap<(String, u16, Protocol), ForwardRule>,
next_offset: u16,
}
impl PortForwardManager {
pub async fn forward(
&mut self,
sandbox_id: &str,
sandbox_ip: Ipv4Addr,
sandbox_port: u16,
protocol: Protocol,
) -> Result<u16> {
let key = (sandbox_id.to_owned(), sandbox_port, protocol);
if let Some(rule) = self.rules.get(&key) {
return Ok(rule.guest_port);
}
let guest_port = self.allocate_port()?;
let rule_args = dnat_rule_args(guest_port, sandbox_ip, sandbox_port, protocol);
run_iptables(&prepend(&["-t", "nat", "-I", "PREROUTING"], &rule_args))
.await
.context("installing DNAT rule")?;
tracing::info!(
sandbox_id,
guest_port,
sandbox_port,
%sandbox_ip,
"sandbox port forward installed"
);
self.rules.insert(
key,
ForwardRule {
guest_port,
rule_args,
},
);
Ok(guest_port)
}
pub async fn remove(
&mut self,
sandbox_id: &str,
sandbox_port: u16,
protocol: Protocol,
) -> Result<()> {
let key = (sandbox_id.to_owned(), sandbox_port, protocol);
let Some(rule) = self.rules.get(&key) else {
return Ok(()); };
let del_args = prepend(&["-t", "nat", "-D", "PREROUTING"], &rule.rule_args);
let guest_port = rule.guest_port;
run_iptables(&del_args)
.await
.context("removing DNAT rule")?;
self.rules.remove(&key);
tracing::info!(sandbox_id, guest_port, "sandbox port forward removed");
Ok(())
}
pub async fn remove_all_for(&mut self, sandbox_id: &str) {
let keys: Vec<_> = self
.rules
.keys()
.filter(|(id, _, _)| id == sandbox_id)
.cloned()
.collect();
for (id, port, proto) in keys {
if let Err(e) = self.remove(&id, port, proto).await {
tracing::warn!(sandbox_id, port, error = %e, "failed to remove port forward");
}
}
}
fn allocate_port(&mut self) -> Result<u16> {
let span = PORT_RANGE_END - PORT_RANGE_START + 1;
for probe in 0..span {
let offset = (self.next_offset + probe) % span;
let candidate = PORT_RANGE_START + offset;
if !self.rules.values().any(|r| r.guest_port == candidate) {
self.next_offset = (offset + 1) % span;
return Ok(candidate);
}
}
bail!("no free port in the reserved range {PORT_RANGE_START}-{PORT_RANGE_END}")
}
}
fn dnat_rule_args(
guest_port: u16,
sandbox_ip: Ipv4Addr,
sandbox_port: u16,
protocol: Protocol,
) -> Vec<String> {
vec![
"-p".into(),
protocol.iptables_name().into(),
"--dport".into(),
guest_port.to_string(),
"-m".into(),
"comment".into(),
"--comment".into(),
RULE_COMMENT.into(),
"-j".into(),
"DNAT".into(),
"--to-destination".into(),
format!("{sandbox_ip}:{sandbox_port}"),
]
}
fn orphan_delete_args(line: &str) -> Option<Vec<String>> {
let spec = line.strip_prefix("-A PREROUTING ")?;
if !spec.contains(RULE_COMMENT) {
return None;
}
let mut args = vec![
"-t".to_owned(),
"nat".to_owned(),
"-D".to_owned(),
"PREROUTING".to_owned(),
];
args.extend(spec.split_whitespace().map(str::to_owned));
Some(args)
}
pub async fn flush_orphan_rules() {
let output = match Command::new("/sbin/iptables")
.args(["-t", "nat", "-S", "PREROUTING"])
.output()
.await
{
Ok(o) if o.status.success() => o,
_ => return,
};
let listing = String::from_utf8_lossy(&output.stdout);
let mut removed = 0usize;
for line in listing.lines() {
if let Some(args) = orphan_delete_args(line)
&& run_iptables(&args).await.is_ok()
{
removed += 1;
}
}
if removed > 0 {
tracing::info!(
removed,
"flushed orphaned sandbox DNAT rules from a previous agent"
);
}
}
fn prepend(head: &[&str], tail: &[String]) -> Vec<String> {
head.iter()
.map(|s| (*s).to_owned())
.chain(tail.iter().cloned())
.collect()
}
async fn run_iptables(args: &[String]) -> Result<()> {
let output = Command::new("/sbin/iptables")
.args(args)
.output()
.await
.context("failed to run iptables")?;
if !output.status.success() {
bail!(
"iptables {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_parses_and_defaults_to_tcp() {
assert_eq!(Protocol::parse("").unwrap(), Protocol::Tcp);
assert_eq!(Protocol::parse("TCP").unwrap(), Protocol::Tcp);
assert_eq!(Protocol::parse("udp").unwrap(), Protocol::Udp);
assert!(Protocol::parse("sctp").is_err());
}
#[test]
fn dnat_spec_is_symmetric_and_tagged() {
let args = dnat_rule_args(40000, Ipv4Addr::new(172, 20, 0, 2), 8080, Protocol::Tcp);
assert_eq!(
args,
[
"-p",
"tcp",
"--dport",
"40000",
"-m",
"comment",
"--comment",
"arcbox-sbx",
"-j",
"DNAT",
"--to-destination",
"172.20.0.2:8080"
]
);
}
#[test]
fn orphan_delete_targets_only_tagged_prerouting_rules() {
let line = "-A PREROUTING -p tcp -m tcp --dport 40000 -m comment \
--comment arcbox-sbx -j DNAT --to-destination 172.20.0.2:8080";
let args = orphan_delete_args(line).expect("tagged rule should match");
assert_eq!(args[..4], ["-t", "nat", "-D", "PREROUTING"]);
assert_eq!(args.last().unwrap(), "172.20.0.2:8080");
assert!(args.iter().any(|a| a == "arcbox-sbx"));
let docker = "-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT \
--to-destination 172.17.0.2:80";
assert!(orphan_delete_args(docker).is_none());
assert!(orphan_delete_args("-N DOCKER").is_none());
}
#[test]
fn allocator_skips_used_ports_and_wraps() {
let mut mgr = PortForwardManager::default();
let first = mgr.allocate_port().unwrap();
assert_eq!(first, PORT_RANGE_START);
mgr.rules.insert(
("a".into(), 80, Protocol::Tcp),
ForwardRule {
guest_port: PORT_RANGE_START + 1,
rule_args: vec![],
},
);
let second = mgr.allocate_port().unwrap();
assert_eq!(second, PORT_RANGE_START + 2);
}
}