diff --git a/Cargo.lock b/Cargo.lock index b0de182..2526314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "anyhow", "clap", "dashmap", + "fastrand", "futures-util", "hex", "hmac", @@ -193,6 +194,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "futures-core" version = "0.3.25" @@ -300,6 +310,15 @@ dependencies = [ "digest", ] +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + [[package]] name = "itoa" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7bfb106..7e4a370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" anyhow = { version = "1.0.56", features = ["backtrace"] } clap = { version = "4.0.22", features = ["derive", "env"] } dashmap = "5.2.0" +fastrand = "1.9.0" futures-util = { version = "0.3.21", features = ["sink"] } hex = "0.4.3" hmac = "0.12.1" diff --git a/README.md b/README.md index bb7ea16..769aec3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This will expose your local port at `localhost:8000` to the public internet at ` Similar to [localtunnel](https://github.com/localtunnel/localtunnel) and [ngrok](https://ngrok.io/), except `bore` is intended to be a highly efficient, unopinionated tool for forwarding TCP traffic that is simple to install and easy to self-host, with no frills attached. -(`bore` totals less than 400 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.) +(`bore` totals about 400 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.) ## Installation @@ -93,7 +93,8 @@ Runs the remote proxy server Usage: bore server [OPTIONS] Options: - --min-port Minimum TCP port number to accept [default: 1024] + --min-port Minimum accepted TCP port number [default: 1024] + --max-port Maximum accepted TCP port number [default: 65535] -s, --secret Optional secret for authentication [env: BORE_SECRET] -h, --help Print help information ``` diff --git a/src/client.rs b/src/client.rs index 9ea31a4..2c21c16 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use anyhow::{bail, Context, Result}; - -use tokio::io::AsyncWriteExt; -use tokio::{net::TcpStream, time::timeout}; +use tokio::{io::AsyncWriteExt, net::TcpStream, time::timeout}; use tracing::{error, info, info_span, warn, Instrument}; use uuid::Uuid; diff --git a/src/main.rs b/src/main.rs index 630804a..0735ae1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use bore_cli::{client::Client, server::Server}; -use clap::{Parser, Subcommand}; +use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand}; #[derive(Parser, Debug)] #[clap(author, version, about)] @@ -35,10 +35,14 @@ enum Command { /// Runs the remote proxy server. Server { - /// Minimum TCP port number to accept. + /// Minimum accepted TCP port number. #[clap(long, default_value_t = 1024)] min_port: u16, + /// Maximum accepted TCP port number. + #[clap(long, default_value_t = 65535)] + max_port: u16, + /// Optional secret for authentication. #[clap(short, long, env = "BORE_SECRET", hide_env_values = true)] secret: Option, @@ -58,8 +62,18 @@ async fn run(command: Command) -> Result<()> { let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?; client.listen().await?; } - Command::Server { min_port, secret } => { - Server::new(min_port, secret.as_deref()).listen().await?; + Command::Server { + min_port, + max_port, + secret, + } => { + let port_range = min_port..=max_port; + if port_range.is_empty() { + Args::command() + .error(ErrorKind::InvalidValue, "port range is empty") + .exit(); + } + Server::new(port_range, secret.as_deref()).listen().await?; } } diff --git a/src/server.rs b/src/server.rs index ab71278..de3158c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,6 @@ //! Server implementation for the `bore` service. -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; +use std::{io, net::SocketAddr, ops::RangeInclusive, sync::Arc, time::Duration}; use anyhow::Result; use dashmap::DashMap; @@ -17,8 +15,8 @@ use crate::shared::{proxy, ClientMessage, Delimited, ServerMessage, CONTROL_PORT /// State structure for the server. pub struct Server { - /// The minimum TCP port that can be forwarded. - min_port: u16, + /// Range of TCP ports that can be forwarded. + port_range: RangeInclusive, /// Optional secret used to authenticate clients. auth: Option, @@ -29,9 +27,10 @@ pub struct Server { impl Server { /// Create a new server with a specified minimum port number. - pub fn new(min_port: u16, secret: Option<&str>) -> Self { + pub fn new(port_range: RangeInclusive, secret: Option<&str>) -> Self { + assert!(!port_range.is_empty(), "must provide at least one port"); Server { - min_port, + port_range, conns: Arc::new(DashMap::new()), auth: secret.map(Authenticator::new), } @@ -61,6 +60,43 @@ impl Server { } } + async fn create_listener(&self, port: u16) -> Result { + let try_bind = |port: u16| async move { + TcpListener::bind(("0.0.0.0", port)) + .await + .map_err(|err| match err.kind() { + io::ErrorKind::AddrInUse => "port already in use", + io::ErrorKind::PermissionDenied => "permission denied", + _ => "failed to bind to port", + }) + }; + if port > 0 { + // Client requests a specific port number. + if !self.port_range.contains(&port) { + return Err("client port number not in allowed range"); + } + try_bind(port).await + } else { + // Client requests any available port in range. + // + // In this case, we bind to 150 random port numbers. We choose this value because in + // order to find a free port with probability at least 1-δ, when ε proportion of the + // ports are currently available, it suffices to check approximately -2 ln(δ) / ε + // independently and uniformly chosen ports (up to a second-order term in ε). + // + // Checking 150 times gives us 99.999% success at utilizing 85% of ports under these + // conditions, when ε=0.15 and δ=0.00001. + for _ in 0..150 { + let port = fastrand::u16(self.port_range.clone()); + match try_bind(port).await { + Ok(listener) => return Ok(listener), + Err(_) => continue, + } + } + Err("failed to find an available port") + } + } + async fn handle_connection(&self, stream: TcpStream) -> Result<()> { let mut stream = Delimited::new(stream); if let Some(auth) = &self.auth { @@ -77,22 +113,15 @@ impl Server { Ok(()) } Some(ClientMessage::Hello(port)) => { - if port != 0 && port < self.min_port { - warn!(?port, "client port number too low"); - return Ok(()); - } - info!(?port, "new client"); - let listener = match TcpListener::bind(("0.0.0.0", port)).await { + let listener = match self.create_listener(port).await { Ok(listener) => listener, - Err(_) => { - warn!(?port, "could not bind to local port"); - stream - .send(ServerMessage::Error("port already in use".into())) - .await?; + Err(err) => { + stream.send(ServerMessage::Error(err.into())).await?; return Ok(()); } }; let port = listener.local_addr()?.port(); + info!(?port, "new client"); stream.send(ServerMessage::Hello(port)).await?; loop { @@ -133,16 +162,7 @@ impl Server { } Ok(()) } - None => { - warn!("unexpected EOF"); - Ok(()) - } + None => Ok(()), } } } - -impl Default for Server { - fn default() -> Self { - Server::new(1024, None) - } -} diff --git a/src/shared.rs b/src/shared.rs index 01cc108..a7f3244 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -7,7 +7,6 @@ use futures_util::{SinkExt, StreamExt}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tokio::io::{self, AsyncRead, AsyncWrite}; - use tokio::time::timeout; use tokio_util::codec::{AnyDelimiterCodec, Framed, FramedParts}; use tracing::trace; diff --git a/tests/e2e_test.rs b/tests/e2e_test.rs index 96b79d1..50a6739 100644 --- a/tests/e2e_test.rs +++ b/tests/e2e_test.rs @@ -17,7 +17,7 @@ lazy_static! { /// Spawn the server, giving some time for the control port TcpListener to start. async fn spawn_server(secret: Option<&str>) { - tokio::spawn(Server::new(1024, secret).listen()); + tokio::spawn(Server::new(1024..=65535, secret).listen()); time::sleep(Duration::from_millis(50)).await; } @@ -117,3 +117,11 @@ async fn very_long_frame() -> Result<()> { } panic!("did not exit after a 1 MB frame"); } + +#[test] +#[should_panic] +fn empty_port_range() { + let min_port = 5000; + let max_port = 3000; + let _ = Server::new(min_port..=max_port, None); +}