mod origin;
mod transport;
#[cfg(test)]
mod end_to_end;
use std::{
io,
net::{Ipv4Addr, SocketAddr},
sync::Arc,
};
use basis_acp::{self as acp, ServeConfig};
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::accept_hdr_async;
pub use transport::websocket_transport;
pub const DEFAULT_PORT: u16 = 5260;
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub bind: SocketAddr,
pub allowed_origins: Vec<String>,
pub allow_non_loopback: bool,
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
bind: SocketAddr::from((Ipv4Addr::LOCALHOST, DEFAULT_PORT)),
allowed_origins: Vec::new(),
allow_non_loopback: false,
}
}
}
impl BridgeConfig {
pub fn new(bind: SocketAddr) -> Self {
Self {
bind,
..Self::default()
}
}
pub fn with_origins(mut self, origins: impl IntoIterator<Item = String>) -> Self {
self.allowed_origins.extend(origins);
self
}
pub fn allowing_non_loopback(mut self) -> Self {
self.allow_non_loopback = true;
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum BridgeError {
#[error(
"refusing to listen on {0}: a bridge reachable beyond this machine gives anyone who can \
route to it an agent that writes to the workspace, and runs commands where shell is \
granted. Bind to loopback, or say explicitly that this is what you want."
)]
NonLoopbackBind(SocketAddr),
#[error("cannot listen on {address}: {source}")]
Listen {
address: SocketAddr,
#[source]
source: io::Error,
},
#[error("the bridge stopped accepting connections: {0}")]
Accept(#[source] io::Error),
#[error("the bridge has no address: {0}")]
Unbound(#[source] io::Error),
}
#[derive(Debug)]
pub struct Bridge {
listener: TcpListener,
allowed_origins: Arc<Vec<String>>,
}
impl Bridge {
pub async fn bind(config: BridgeConfig) -> Result<Self, BridgeError> {
if !config.allow_non_loopback && !config.bind.ip().is_loopback() {
return Err(BridgeError::NonLoopbackBind(config.bind));
}
let listener =
TcpListener::bind(config.bind)
.await
.map_err(|source| BridgeError::Listen {
address: config.bind,
source,
})?;
Ok(Self {
listener,
allowed_origins: Arc::new(config.allowed_origins),
})
}
pub fn local_addr(&self) -> Result<SocketAddr, BridgeError> {
self.listener.local_addr().map_err(BridgeError::Unbound)
}
pub async fn serve(self, config: ServeConfig) -> Result<(), BridgeError> {
loop {
let (stream, _peer) = self.listener.accept().await.map_err(BridgeError::Accept)?;
tokio::spawn(serve_connection(
stream,
Arc::clone(&self.allowed_origins),
config.clone(),
));
}
}
}
async fn serve_connection(
stream: TcpStream,
allowed_origins: Arc<Vec<String>>,
config: ServeConfig,
) {
let Ok(socket) = accept_hdr_async(stream, origin::origin_guard(allowed_origins)).await else {
return;
};
let _ = acp::serve(config, websocket_transport(socket)).await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv6Addr;
#[tokio::test]
async fn a_non_loopback_bind_is_refused_unless_it_was_asked_for() {
let address = SocketAddr::from(([0, 0, 0, 0], 0));
let error = Bridge::bind(BridgeConfig::new(address))
.await
.expect_err("0.0.0.0 is every interface, which is not a default anyone chose");
assert!(matches!(error, BridgeError::NonLoopbackBind(refused) if refused == address));
}
#[tokio::test]
async fn loopback_binds_without_ceremony() {
let bridge = Bridge::bind(BridgeConfig::new(SocketAddr::from((
Ipv4Addr::LOCALHOST,
0,
))))
.await
.expect("loopback needs no opt-in");
assert!(bridge.local_addr().expect("bound").ip().is_loopback());
}
#[tokio::test]
async fn ipv6_loopback_counts_as_loopback() {
let bridge = Bridge::bind(BridgeConfig::new(SocketAddr::from((
Ipv6Addr::LOCALHOST,
0,
))))
.await
.expect("::1 is loopback too");
assert!(bridge.local_addr().expect("bound").ip().is_loopback());
}
#[test]
fn the_default_is_loopback_and_serves_no_page() {
let config = BridgeConfig::default();
assert!(config.bind.ip().is_loopback());
assert_eq!(config.bind.port(), DEFAULT_PORT);
assert!(
config.allowed_origins.is_empty(),
"a page reaches a loopback socket unasked; the allowlist is what stops it"
);
assert!(!config.allow_non_loopback);
}
#[test]
fn origins_accumulate() {
let config = BridgeConfig::default()
.with_origins(["http://localhost:5173".to_string()])
.with_origins(["https://acp.example".to_string()]);
assert_eq!(
config.allowed_origins,
vec![
"http://localhost:5173".to_string(),
"https://acp.example".to_string()
]
);
}
}