From 8519fa2f8649483a24b04ffc1595702fc54582da Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 9 Jan 2025 16:50:39 +0400 Subject: [PATCH] Support TCP port exposure via "--expose" command-line argument (#70) --- Cargo.lock | 80 ++++++++++++++++++++++++++++-- Cargo.toml | 2 + lib/dhcp_snooper.rs | 10 +++- lib/host.rs | 31 ++++++++++++ lib/proxy/exposed_port.rs | 47 ++++++++++++++++++ lib/proxy/mod.rs | 13 +++++ lib/proxy/port_forwarder.rs | 99 +++++++++++++++++++++++++++++++++++++ src/main.rs | 19 ++++++- 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 lib/proxy/exposed_port.rs create mode 100644 lib/proxy/port_forwarder.rs diff --git a/Cargo.lock b/Cargo.lock index bc18137..dbad3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,6 +303,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -565,6 +578,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.2" @@ -900,7 +919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -960,6 +979,16 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.22" @@ -1218,6 +1247,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1389,6 +1442,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "regex" version = "1.11.0" @@ -1516,6 +1578,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.11.1" @@ -1765,9 +1833,11 @@ dependencies = [ "ip_network", "ipnet", "libc", + "log", "mac_address", "nix 0.29.0", "num_enum 0.7.3", + "oslog", "polling", "prefix-trie", "privdrop", @@ -2153,9 +2223,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vmnet" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2063a13f916de87aa0946f4af0fa68956a0712f8f2e7994b49a70b06ecc06377" +checksum = "c96eae216a9fa27c8e458ee5ef56dfc92d43fc8c25d67ade4a7fa83f0cb8b21b" dependencies = [ "bitflags 1.3.2", "block", @@ -2171,9 +2241,9 @@ dependencies = [ [[package]] name = "vmnet-derive" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a04ced3e757af6aa37c079c3ce756a579d627904f37482342874c642e47068" +checksum = "fa20b8d2b5b0504355848b99b54fda9411f8201f44c1a9ee5a2d3c3134d31297" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index ecefe22..7688be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,5 @@ sentry-anyhow = { version = "0", features = ["backtrace"] } nix = { version = "0", features = ["signal"] } prefix-trie = "0" ipnet = "2" +oslog = "0.2.0" +log = "0.4.22" diff --git a/lib/dhcp_snooper.rs b/lib/dhcp_snooper.rs index 10ea36d..c6a3e8e 100644 --- a/lib/dhcp_snooper.rs +++ b/lib/dhcp_snooper.rs @@ -73,7 +73,15 @@ impl Lease { } } + pub fn address(&self) -> Ipv4Address { + self.address + } + + pub fn valid(&self) -> bool { + Instant::now() < self.valid_until + } + pub fn valid_ip_source(&self, address: Ipv4Address) -> bool { - self.address == address && Instant::now() < self.valid_until + self.address == address && self.valid() } } diff --git a/lib/host.rs b/lib/host.rs index 75a83fd..4115b81 100644 --- a/lib/host.rs +++ b/lib/host.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Context, Result}; use clap::ValueEnum; +use log::info; use std::net::Ipv4Addr; use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::net::UnixDatagram; @@ -7,6 +8,7 @@ use std::str::FromStr; use std::sync::mpsc::{sync_channel, SyncSender}; use vmnet::mode::Mode; use vmnet::parameters::{Parameter, ParameterKind}; +use vmnet::port_forwarding::{AddressFamily, Protocol}; use vmnet::{Events, Options}; #[derive(ValueEnum, Clone, Debug)] @@ -100,6 +102,35 @@ impl Host { } impl Host { + pub fn port_forwarding_add_rule( + &mut self, + external_port: u16, + internal_addr: Ipv4Addr, + internal_port: u16, + ) -> Result<()> { + let details = format!("external_port={external_port}, internal_addr={internal_addr}, internal_port={internal_port}"); + + self.interface + .port_forwarding_rule_add( + AddressFamily::Ipv4, + Protocol::Tcp, + external_port, + internal_addr.into(), + internal_port, + ) + .map(|_| info!("added port forwarding rule {details}")) + .map_err(|err| anyhow!("failed to add port forwarding rule {details}: {err}")) + } + + pub fn port_forwarding_remove_rule(&mut self, external_port: u16) -> Result<()> { + let details = format!("external_port={external_port}"); + + self.interface + .port_forwarding_rule_remove(AddressFamily::Ipv4, Protocol::Tcp, external_port) + .map(|_| info!("removed port forwarding rule {details}")) + .map_err(|err| anyhow!("failed to remove port forwarding rule {details}: {err}")) + } + pub fn read(&mut self, buf: &mut [u8]) -> vmnet::Result { // Dequeue dummy datagram from the socket (if any) // to free up buffer space and reduce false-positives diff --git a/lib/proxy/exposed_port.rs b/lib/proxy/exposed_port.rs new file mode 100644 index 0000000..1d6b7b7 --- /dev/null +++ b/lib/proxy/exposed_port.rs @@ -0,0 +1,47 @@ +use anyhow::{anyhow, Context, Error}; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ExposedPort { + pub external_port: u16, + pub internal_port: u16, +} + +impl FromStr for ExposedPort { + type Err = Error; + + fn from_str(s: &str) -> Result { + let splits: Vec<&str> = s.split(':').collect(); + + match splits.len() { + 2 => Ok(ExposedPort { + external_port: splits[0] + .parse() + .context(format!("invalid external port {:?}", splits[0]))?, + internal_port: splits[1] + .parse() + .context(format!("invalid internal port {:?}", splits[1]))?, + }), + _ => Err(anyhow!( + "invalid exposed port specification {:?}, the format should be EXTERNAL:INTERNAL", + s + )), + } + } +} + +#[cfg(test)] +mod tests { + use crate::proxy::exposed_port::ExposedPort; + + #[test] + fn exposed_port() { + assert_eq!( + ExposedPort { + external_port: 2222, + internal_port: 22 + }, + "2222:22".parse().unwrap() + ); + } +} diff --git a/lib/proxy/mod.rs b/lib/proxy/mod.rs index 8ece0b1..03588da 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -1,4 +1,6 @@ +mod exposed_port; mod host; +mod port_forwarder; mod udp_packet_helper; mod vm; @@ -8,8 +10,10 @@ use crate::host::NetType; use crate::poller::Poller; use crate::vm::VM; use anyhow::Result; +pub use exposed_port::ExposedPort; use ipnet::Ipv4Net; use mac_address::MacAddress; +use port_forwarder::PortForwarder; use prefix_trie::{Prefix, PrefixSet}; use smoltcp::wire::EthernetFrame; use std::io::ErrorKind; @@ -23,6 +27,7 @@ pub struct Proxy<'proxy> { dhcp_snooper: DhcpSnooper, allow: PrefixSet, enobufs_encountered: bool, + port_forwarder: PortForwarder, } impl Proxy<'_> { @@ -31,6 +36,7 @@ impl Proxy<'_> { vm_mac_address: MacAddress, vm_net_type: NetType, allow: PrefixSet, + exposed_ports: Vec, ) -> Result> { let vm = VM::new(vm_fd)?; let host = Host::new(vm_net_type, !allow.contains(&Ipv4Net::zero()))?; @@ -44,6 +50,7 @@ impl Proxy<'_> { dhcp_snooper: Default::default(), allow, enobufs_encountered: false, + port_forwarder: PortForwarder::new(exposed_ports), }) } @@ -68,6 +75,12 @@ impl Proxy<'_> { return Ok(()); } + // Timeout + if !vm_readable && !host_readable && !interrupt { + self.port_forwarder + .tick(&mut self.host, self.dhcp_snooper.lease()); + } + self.poller.rearm()?; } } diff --git a/lib/proxy/port_forwarder.rs b/lib/proxy/port_forwarder.rs new file mode 100644 index 0000000..377d0ab --- /dev/null +++ b/lib/proxy/port_forwarder.rs @@ -0,0 +1,99 @@ +use crate::dhcp_snooper::Lease; +use crate::host::Host; +use crate::proxy::exposed_port::ExposedPort; +use anyhow::Result; +use log::error; +use std::net::Ipv4Addr; + +#[derive(Default)] +pub struct PortForwarder { + port_forwardings: Vec, + failed: bool, +} + +#[derive(Debug, Clone, Copy, Default)] +struct PortForwarding { + exposed_port: ExposedPort, + forwarding_to_addr: Option, +} + +impl PortForwarder { + pub fn new(exposed_ports: Vec) -> PortForwarder { + let port_forwardings = exposed_ports + .into_iter() + .map(|exposed_port| PortForwarding { + exposed_port, + ..Default::default() + }) + .collect(); + + PortForwarder { + port_forwardings, + ..Default::default() + } + } + + pub fn tick(&mut self, host: &mut Host, lease: &Option) { + if self.failed { + return; + } + + if let Err(err) = self.tick_inner(host, lease) { + error!("port-forwarding failed: {}", err); + + self.failed = true; + } + } + + fn tick_inner(&mut self, host: &mut Host, lease: &Option) -> Result<()> { + if let Some(lease) = lease { + // Lease exists, but is not valid, remove all port forwardings + if !lease.valid() { + self.remove_all_port_forwardings(host)?; + + return Ok(()); + } + + // Lease exists and is valid, install/re-install port forwardings + for port_forwarding in &mut self.port_forwardings { + if let Some(installed_addr) = port_forwarding.forwarding_to_addr { + // Port forwarding already installed, perhaps it's outdated? + if installed_addr == lease.address() { + // Nope, the port forwarding is up to date + continue; + } + + // Remove port forwarding since the lease address had changed + host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?; + port_forwarding.forwarding_to_addr = None; + } + + // Install new port forwarding + host.port_forwarding_add_rule( + port_forwarding.exposed_port.external_port, + lease.address(), + port_forwarding.exposed_port.internal_port, + )?; + port_forwarding.forwarding_to_addr = Some(lease.address()); + } + } else { + // Lease does not exist, remove all port forwardings + self.remove_all_port_forwardings(host)?; + } + + Ok(()) + } + + fn remove_all_port_forwardings(&mut self, host: &mut Host) -> Result<()> { + for port_forwarding in &mut self.port_forwardings { + if port_forwarding.forwarding_to_addr.is_none() { + continue; + } + + host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?; + port_forwarding.forwarding_to_addr = None; + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 757893a..492f793 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ use anyhow::{anyhow, Context}; use clap::Parser; use ipnet::Ipv4Net; +use log::LevelFilter; use nix::sys::signal::{signal, SigHandler, Signal}; +use oslog::OsLogger; use prefix_trie::PrefixSet; use privdrop::PrivDrop; +use softnet::proxy::ExposedPort; use softnet::proxy::Proxy; use softnet::NetType; use std::borrow::Cow; use std::env; - use std::os::raw::c_int; use std::os::unix::io::RawFd; use std::os::unix::process::CommandExt; @@ -57,6 +59,15 @@ struct Args { )] allow: Vec, + #[clap( + long, + help = "comma-separated list of TCP ports to expose (e.g. --expose 2222:22,8080:80)", + value_name = "comma-separated port specifications", + use_value_delimiter = true, + action = clap::ArgAction::Set + )] + expose: Vec, + #[clap(long, hide = true)] sudo_escalation_probing: bool, @@ -101,6 +112,11 @@ fn main() -> ExitCode { } fn try_main() -> anyhow::Result<()> { + // Initialize logger + OsLogger::new("org.cirruslabs.softnet") + .level_filter(LevelFilter::Info) + .init()?; + // The default signal(3)[1] action for SIGINT is to interrupt program, // but we want to handle SIGINT ourselves, so we ignore it. The kqueue(2)'s[2] // EVFILT_SIGNAL will receive it anyways, because it has lower precedence. @@ -161,6 +177,7 @@ fn try_main() -> anyhow::Result<()> { args.vm_mac_address, args.vm_net_type, PrefixSet::from_iter(args.allow), + args.expose, ) .context("failed to initialize proxy")?;