Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ jobs:
AARCH64_CRATES: >-
-p litebox
-p litebox_common_linux
-p litebox_egress_proxy
-p litebox_syscall_rewriter
-p litebox_packager
-p litebox_platform_linux_userland
Expand Down Expand Up @@ -199,6 +200,7 @@ jobs:
WINDOWS_CRATES: >-
-p litebox_common_linux
-p litebox_common_windows
-p litebox_egress_proxy
-p litebox_syscall_rewriter
-p litebox_packager
-p litebox_broker_protocol
Expand Down Expand Up @@ -290,6 +292,9 @@ jobs:
# - `litebox_broker_userland` is allowed to have `std` access,
# since it is the hosted userland broker executable.
#
# - `litebox_egress_proxy` is allowed to have `std` access, since it
# is the hosted userland CONNECT proxy executable.
#
# - `litebox_platform_lvbs` has a custom target (`no_std`), so it does
# not work with the current no_std checker.
#
Expand Down Expand Up @@ -351,6 +356,7 @@ jobs:
-not -path './litebox_broker_transport_linux_userland/Cargo.toml' \
-not -path './litebox_broker_transport_windows_userland/Cargo.toml' \
-not -path './litebox_broker_userland/Cargo.toml' \
-not -path './litebox_egress_proxy/Cargo.toml' \
-not -path './litebox_platform_linux_userland/Cargo.toml' \
-not -path './litebox_platform_windows_userland/Cargo.toml' \
-not -path './litebox_runner_linux_on_windows_userland/Cargo.toml' \
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions litebox_egress_proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@ version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5", default-features = false, features = [
"derive",
"error-context",
"help",
"std",
"usage",
] }
hashbrown = "0.15.2"
http-body-util = { version = "0.1.3", default-features = false }
hyper = { version = "1.8", default-features = false, features = [
"http1",
"server",
] }
hyper-util = { version = "0.1.20", default-features = false, features = [
"tokio",
] }
thiserror = { version = "2.0", default-features = false }
tokio = { version = "1.50", default-features = false, features = [
"io-util",
"net",
"rt",
"sync",
"time",
] }

[dev-dependencies]
tokio = { version = "1.50", default-features = false, features = [
"macros",
"test-util",
] }

[lints]
workspace = true
117 changes: 117 additions & 0 deletions litebox_egress_proxy/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! Executable configuration.

use std::net::{Ipv4Addr, SocketAddrV4};

use clap::Parser;
use thiserror::Error;

use crate::policy::{HostPolicy, PolicyError};

/// The standalone egress proxy for LiteBox sandboxes.
#[derive(Debug, Parser)]
#[command(
name = "litebox_egress_proxy",
about = "Hostname-filtering CONNECT egress proxy"
)]
pub struct Cli {
/// Loopback address to bind, for example `127.0.0.1:0`.
#[arg(long, value_name = "IPV4:PORT")]
listen: String,

/// Allowed hostname and destination ports, repeatable.
#[arg(long = "allow-host", value_name = "HOST:PORT[-PORT]")]
allow_host: Vec<String>,
}

/// Reason the arguments were rejected.
#[derive(Debug, Error)]
pub enum ConfigError {
/// `--listen` was not a socket address.
#[error("--listen must be an IPv4 address and port, for example 127.0.0.1:0")]
ListenAddress,
/// `--listen` was not canonical IPv4 loopback.
#[error("--listen must use the canonical loopback address 127.0.0.1")]
ListenNotLoopback,
/// An `--allow-host` rule was invalid.
#[error("invalid --allow-host rule: {0}")]
Policy(#[from] PolicyError),
}

/// The validated configuration of one proxy process.
#[derive(Debug)]
pub struct ProxyConfig {
/// IPv4 loopback address to bind.
pub listen: SocketAddrV4,
/// The immutable hostname policy.
pub policy: HostPolicy,
}

impl Cli {
/// Converts parsed arguments into a validated configuration.
pub fn into_config(self) -> Result<ProxyConfig, ConfigError> {
let listen = parse_listen_address(&self.listen)?;
let policy = HostPolicy::from_rules(&self.allow_host)?;

Ok(ProxyConfig { listen, policy })
}
}

fn parse_listen_address(raw: &str) -> Result<SocketAddrV4, ConfigError> {
let address: SocketAddrV4 = raw.parse().map_err(|_| ConfigError::ListenAddress)?;
if *address.ip() != Ipv4Addr::LOCALHOST {
return Err(ConfigError::ListenNotLoopback);
}
Ok(address)
}

#[cfg(test)]
mod tests {
use super::*;

use crate::policy::Hostname;

fn parse(arguments: &[&str]) -> Result<ProxyConfig, ConfigError> {
let mut all = vec!["litebox_egress_proxy"];
all.extend_from_slice(arguments);
Cli::try_parse_from(all).unwrap().into_config()
}

#[test]
fn parses_a_standalone_configuration() {
let config = parse(&[
"--listen",
"127.0.0.1:0",
"--allow-host",
"Example.COM:443",
"--allow-host",
"example.com:8000-8100",
])
.unwrap();

assert_eq!(config.listen, SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));

let host = Hostname::parse("example.com").unwrap();
assert!(config.policy.allows(&host, 443));
assert!(config.policy.allows(&host, 8100));
assert!(!config.policy.allows(&host, 80));
}

#[test]
fn rejects_non_loopback_listen_addresses() {
assert!(matches!(
parse(&["--listen", "0.0.0.0:8080"]),
Err(ConfigError::ListenNotLoopback)
));
assert!(matches!(
parse(&["--listen", "127.0.0.2:8080"]),
Err(ConfigError::ListenNotLoopback)
));
assert!(matches!(
parse(&["--listen", "localhost:8080"]),
Err(ConfigError::ListenAddress)
));
}
}
73 changes: 73 additions & 0 deletions litebox_egress_proxy/src/connector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

//! Upstream hostname resolution and connection.

use core::future::Future;
use core::pin::Pin;
use core::time::Duration;
use std::io;

use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::{TcpStream, lookup_host};
use tokio::time::{Instant, timeout};

use crate::policy::Hostname;

pub(crate) const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);

/// A bidirectional upstream byte stream.
pub trait UpstreamStream: AsyncRead + AsyncWrite + Send + Unpin {}

impl<T: AsyncRead + AsyncWrite + Send + Unpin + ?Sized> UpstreamStream for T {}

/// An owned upstream byte stream.
pub type BoxedUpstreamStream = Box<dyn UpstreamStream>;

/// A future returned by an [`UpstreamConnector`].
pub type ConnectFuture<'a> =
Pin<Box<dyn Future<Output = io::Result<BoxedUpstreamStream>> + Send + 'a>>;

/// Resolves and connects to an authorized hostname and port.
pub trait UpstreamConnector: Send + Sync + 'static {
/// Opens an upstream connection.
fn connect(&self, host: Hostname, port: u16) -> ConnectFuture<'_>;
}

/// The production connector, using the trusted host resolver.
pub struct TcpUpstreamConnector;

impl UpstreamConnector for TcpUpstreamConnector {
fn connect(&self, host: Hostname, port: u16) -> ConnectFuture<'_> {
Box::pin(async move {
let deadline = Instant::now() + UPSTREAM_CONNECT_TIMEOUT;
let addresses: Vec<_> = lookup_host((host.as_str(), port)).await?.collect();
let address_count = addresses.len();
let mut last_error = None;

for (index, address) in addresses.into_iter().enumerate() {
let attempts_left = u32::try_from(address_count - index).unwrap_or(u32::MAX);
let attempt_timeout =
deadline.saturating_duration_since(Instant::now()) / attempts_left;

match timeout(attempt_timeout, TcpStream::connect(address)).await {
Ok(Ok(stream)) => {
stream.set_nodelay(true)?;
return Ok(Box::new(stream) as BoxedUpstreamStream);
}
Ok(Err(error)) => last_error = Some(error),
Err(_elapsed) => {
last_error = Some(io::Error::new(
io::ErrorKind::TimedOut,
"upstream address connection timed out",
));
}
}
}

Err(last_error.unwrap_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "hostname resolved to no addresses")
}))
})
}
}
Loading