11 Commits

Author SHA1 Message Date
Eric Zhang
b0bfd52707 Bump version to 0.2.1 2022-04-09 02:55:52 -04:00
Eric Zhang
526d02d789 Add integration tests for auth and end-to-end proxying (#4)
* Add authentication handshake tests

* Add basic proxy test

* Add mismatched secret failure test

* Add a failure test for invalid addresses
2022-04-09 02:54:52 -04:00
Orhun Parmaksız
23db4047ff Add LICENSE (#3) 2022-04-08 16:42:58 -04:00
Eric Zhang
2d0dcf9889 Improve stability by exiting immediately on common errors (#2)
* Kill connections immediately on missing or close

* Add timeout to initial protocol messages

* Add low-level tracing for JSON messages

* Add timeout to initial TCP connections
2022-04-08 15:55:54 -04:00
Eric Zhang
c1efefeddf Update version to 0.2.0 2022-04-08 04:18:21 -04:00
Eric Zhang
e84bd34bd9 Fix failing doc test 2022-04-08 04:18:21 -04:00
jtroo
d5089cab2a Add optional secret for authenticating clients (#1)
* Add optional secret for authenticating clients

* Add server challenge to authentication

* Refactor and simplify code, reduce dependencies

* Update README to describe HMAC authentication

Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
2022-04-08 04:13:31 -04:00
Eric Zhang
ebae01417d Enable CI for all branches 2022-04-07 14:24:02 -04:00
Eric Zhang
6169d54012 (cargo-release) version 0.1.1 2022-04-06 03:10:28 -04:00
Eric Zhang
f00281f2dc Fix minor bug in server error handling 2022-04-06 03:10:02 -04:00
Eric Zhang
9a8fc8ec44 Fix CI badge and add GIF demo to README 2022-04-06 03:06:36 -04:00
13 changed files with 500 additions and 44 deletions

View File

@@ -1,9 +1,6 @@
name: CI
on:
push:
branches:
- main
on: push
jobs:
rust:

122
Cargo.lock generated
View File

@@ -73,15 +73,29 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
dependencies = [
"generic-array",
]
[[package]]
name = "bore-cli"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"anyhow",
"clap",
"dashmap",
"hex",
"hmac",
"lazy_static",
"rstest",
"serde",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
@@ -136,6 +150,25 @@ dependencies = [
"syn",
]
[[package]]
name = "cpufeatures"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dashmap"
version = "5.2.0"
@@ -147,6 +180,27 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "digest"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "generic-array"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.6"
@@ -185,6 +239,21 @@ dependencies = [
"libc",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "indexmap"
version = "1.8.1"
@@ -394,12 +463,34 @@ dependencies = [
"bitflags",
]
[[package]]
name = "rstest"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d912f35156a3f99a66ee3e11ac2e0b3f34ac85a07e05263d05a7e2c8810d616f"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "rustc-demangle"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
[[package]]
name = "rustc_version"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.9"
@@ -412,6 +503,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "semver"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d65bd28f48be7196d222d95b9243287f48d27aca604e08497513019ff0502cc4"
[[package]]
name = "serde"
version = "1.0.136"
@@ -443,6 +540,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.4"
@@ -483,6 +591,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"
version = "1.0.90"
@@ -607,6 +721,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unicode-xid"
version = "0.2.2"

View File

@@ -1,6 +1,6 @@
[package]
name = "bore-cli"
version = "0.1.0"
version = "0.2.1"
authors = ["Eric Zhang <ekzhang1@gmail.com>"]
license = "MIT"
description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls."
@@ -19,9 +19,16 @@ path = "src/main.rs"
anyhow = { version = "1.0.56", features = ["backtrace"] }
clap = { version = "3.1.8", features = ["derive"] }
dashmap = "5.2.0"
hex = "0.4.3"
hmac = "0.12.1"
serde = { version = "1.0.136", features = ["derive"] }
serde_json = "1.0.79"
sha2 = "0.10.2"
tokio = { version = "1.17.0", features = ["full"] }
tracing = "0.1.32"
tracing-subscriber = "0.3.10"
uuid = { version = "0.8.2", features = ["serde", "v4"] }
[dev-dependencies]
lazy_static = "1.4.0"
rstest = "0.12.0"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Eric Zhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,10 +1,12 @@
# bore
[![Build status](https://github.com/ekzhang/bore/workflows/ci/badge.svg)](https://github.com/ekzhang/bore/actions)
[![Build status](https://img.shields.io/github/workflow/status/ekzhang/bore/CI)](https://github.com/ekzhang/bore/actions)
[![Crates.io](https://img.shields.io/crates/v/bore-cli.svg)](https://crates.io/crates/bore-cli)
A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls. **That's all it does: no more, and no less.**
![Video demo](https://i.imgur.com/vDeGsmx.gif)
```shell
# Installation (requires Rust)
cargo install bore-cli
@@ -17,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 300 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.)
(`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.)
## Detailed Usage
@@ -36,7 +38,7 @@ You can optionally pass in a `--port` option to pick a specific port on the remo
The full options are shown below.
```shell
bore-local 0.1.0
bore-local 0.2.1
Starts a local proxy to the remote server
USAGE:
@@ -46,10 +48,11 @@ ARGS:
<LOCAL_PORT> The local port to listen on
OPTIONS:
-h, --help Print help information
-p, --port <PORT> Optional port on the remote server to select [default: 0]
-t, --to <TO> Address of the remote server to expose local ports to
-V, --version Print version information
-h, --help Print help information
-p, --port <PORT> Optional port on the remote server to select [default: 0]
-s, --secret <SECRET> Optional secret for authentication
-t, --to <TO> Address of the remote server to expose local ports to
-V, --version Print version information
```
### Self-Hosting
@@ -65,7 +68,7 @@ That's all it takes! After the server starts running at a given address, you can
The full options for the `bore server` command are shown below.
```shell
bore-server 0.1.0
bore-server 0.2.1
Runs the remote proxy server
USAGE:
@@ -74,6 +77,7 @@ USAGE:
OPTIONS:
-h, --help Print help information
--min-port <MIN_PORT> Minimum TCP port number to accept [default: 1024]
-s, --secret <SECRET> Optional secret for authentication
-V, --version Print version information
```
@@ -85,6 +89,18 @@ Whenever the server obtains a connection on the remote port, it generates a secu
For correctness reasons and to avoid memory leaks, incoming connections are only stored by the server for up to 10 seconds before being discarded if the client does not accept them.
## Authentication
On a custom deployment of `bore server`, you can optionally require a _secret_ to prevent the server from being used by others. The protocol requires clients to verify possession of the secret on each TCP connection by answering random challenges in the form of HMAC codes. (This secret is only used for the initial handshake, and no further traffic is encrypted by default.)
```shell
# on the server
bore server --secret my_secret_string
# on the client
bore local <LOCAL_PORT> --to <TO> --secret my_secret_string
```
## Acknowledgements
Created by Eric Zhang ([@ekzhang1](https://twitter.com/ekzhang1)). Licensed under the [MIT license](LICENSE).

79
src/auth.rs Normal file
View File

@@ -0,0 +1,79 @@
//! Auth implementation for bore client and server.
use anyhow::{bail, ensure, Result};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufRead, AsyncWrite};
use uuid::Uuid;
use crate::shared::{recv_json_timeout, send_json, ClientMessage, ServerMessage};
/// Wrapper around a MAC used for authenticating clients that have a secret.
pub struct Authenticator(Hmac<Sha256>);
impl Authenticator {
/// Generate an authenticator from a secret.
pub fn new(secret: &str) -> Self {
let hashed_secret = Sha256::new().chain_update(secret).finalize();
Self(Hmac::new_from_slice(&hashed_secret).expect("HMAC can take key of any size"))
}
/// Generate a reply message for a challenge.
pub fn answer(&self, challenge: &Uuid) -> String {
let mut hmac = self.0.clone();
hmac.update(challenge.as_bytes());
hex::encode(hmac.finalize().into_bytes())
}
/// Validate a reply to a challenge.
///
/// ```
/// use bore_cli::auth::Authenticator;
/// use uuid::Uuid;
///
/// let auth = Authenticator::new("secret");
/// let challenge = Uuid::new_v4();
///
/// assert!(auth.validate(&challenge, &auth.answer(&challenge)));
/// assert!(!auth.validate(&challenge, "wrong answer"));
/// ```
pub fn validate(&self, challenge: &Uuid, tag: &str) -> bool {
if let Ok(tag) = hex::decode(tag) {
let mut hmac = self.0.clone();
hmac.update(challenge.as_bytes());
hmac.verify_slice(&tag).is_ok()
} else {
false
}
}
/// As the server, send a challenge to the client and validate their response.
pub async fn server_handshake(
&self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin),
) -> Result<()> {
let challenge = Uuid::new_v4();
send_json(stream, ServerMessage::Challenge(challenge)).await?;
match recv_json_timeout(stream).await? {
Some(ClientMessage::Authenticate(tag)) => {
ensure!(self.validate(&challenge, &tag), "invalid secret");
Ok(())
}
_ => bail!("server requires secret, but no secret was provided"),
}
}
/// As the client, answer a challenge to attempt to authenticate with the server.
pub async fn client_handshake(
&self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin),
) -> Result<()> {
let challenge = match recv_json_timeout(stream).await? {
Some(ServerMessage::Challenge(challenge)) => challenge,
_ => bail!("expected authentication challenge, but no secret was required"),
};
let tag = self.answer(&challenge);
send_json(stream, ClientMessage::Authenticate(tag)).await?;
Ok(())
}
}

View File

@@ -3,11 +3,15 @@
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use tokio::{io::BufReader, net::TcpStream};
use tokio::{io::BufReader, net::TcpStream, time::timeout};
use tracing::{error, info, info_span, warn, Instrument};
use uuid::Uuid;
use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT};
use crate::auth::Authenticator;
use crate::shared::{
proxy, recv_json, recv_json_timeout, send_json, ClientMessage, ServerMessage, CONTROL_PORT,
NETWORK_TIMEOUT,
};
/// State structure for the client.
pub struct Client {
@@ -22,18 +26,28 @@ pub struct Client {
/// Port that is publicly available on the remote.
remote_port: u16,
/// Optional secret used to authenticate clients.
auth: Option<Authenticator>,
}
impl Client {
/// Create a new client.
pub async fn new(local_port: u16, to: &str, port: u16) -> Result<Self> {
let stream = TcpStream::connect((to, CONTROL_PORT)).await?;
let mut stream = BufReader::new(stream);
pub async fn new(local_port: u16, to: &str, port: u16, secret: Option<&str>) -> Result<Self> {
let mut stream = BufReader::new(connect_with_timeout(to, CONTROL_PORT).await?);
let auth = secret.map(Authenticator::new);
if let Some(auth) = &auth {
auth.client_handshake(&mut stream).await?;
}
send_json(&mut stream, ClientMessage::Hello(port)).await?;
let remote_port = match recv_json(&mut stream, &mut Vec::new()).await? {
let remote_port = match recv_json_timeout(&mut stream).await? {
Some(ServerMessage::Hello(remote_port)) => remote_port,
Some(ServerMessage::Error(message)) => bail!("server error: {message}"),
Some(ServerMessage::Challenge(_)) => {
bail!("server requires authentication, but no client secret was provided");
}
Some(_) => bail!("unexpected initial non-hello message"),
None => bail!("unexpected EOF"),
};
@@ -45,6 +59,7 @@ impl Client {
to: to.to_string(),
local_port,
remote_port,
auth,
})
}
@@ -62,6 +77,7 @@ impl Client {
let msg = recv_json(&mut conn, &mut buf).await?;
match msg {
Some(ServerMessage::Hello(_)) => warn!("unexpected hello"),
Some(ServerMessage::Challenge(_)) => warn!("unexpected challenge"),
Some(ServerMessage::Heartbeat) => (),
Some(ServerMessage::Connection(id)) => {
let this = Arc::clone(&this);
@@ -83,15 +99,23 @@ impl Client {
}
async fn handle_connection(&self, id: Uuid) -> Result<()> {
let local_conn = TcpStream::connect(("localhost", self.local_port))
.await
.context("failed TCP connection to local port")?;
let mut remote_conn = TcpStream::connect((&self.to[..], CONTROL_PORT))
.await
.context("failed TCP connection to remote port")?;
let mut remote_conn =
BufReader::new(connect_with_timeout(&self.to[..], CONTROL_PORT).await?);
if let Some(auth) = &self.auth {
auth.client_handshake(&mut remote_conn).await?;
}
send_json(&mut remote_conn, ClientMessage::Accept(id)).await?;
let local_conn = connect_with_timeout("localhost", self.local_port).await?;
proxy(local_conn, remote_conn).await?;
Ok(())
}
}
async fn connect_with_timeout(to: &str, port: u16) -> Result<TcpStream> {
match timeout(NETWORK_TIMEOUT, TcpStream::connect((to, port))).await {
Ok(res) => res,
Err(err) => Err(err.into()),
}
.with_context(|| format!("could not connect to {to}:{port}"))
}

View File

@@ -15,6 +15,7 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod auth;
pub mod client;
pub mod server;
pub mod shared;

View File

@@ -24,6 +24,10 @@ enum Command {
/// Optional port on the remote server to select.
#[clap(short, long, default_value_t = 0)]
port: u16,
/// Optional secret for authentication.
#[clap(short, long)]
secret: Option<String>,
},
/// Runs the remote proxy server.
@@ -31,6 +35,10 @@ enum Command {
/// Minimum TCP port number to accept.
#[clap(long, default_value_t = 1024)]
min_port: u16,
/// Optional secret for authentication.
#[clap(short, long)]
secret: Option<String>,
},
}
@@ -44,12 +52,13 @@ async fn main() -> Result<()> {
local_port,
to,
port,
secret,
} => {
let client = Client::new(local_port, &to, port).await?;
let client = Client::new(local_port, &to, port, secret.as_deref()).await?;
client.listen().await?;
}
Command::Server { min_port } => {
Server::new(min_port).listen().await?;
Command::Server { min_port, secret } => {
Server::new(min_port, secret.as_deref()).listen().await?;
}
}

View File

@@ -12,23 +12,30 @@ use tokio::time::{sleep, timeout};
use tracing::{info, info_span, warn, Instrument};
use uuid::Uuid;
use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT};
use crate::auth::Authenticator;
use crate::shared::{
proxy, recv_json_timeout, send_json, ClientMessage, ServerMessage, CONTROL_PORT,
};
/// State structure for the server.
pub struct Server {
/// The minimum TCP port that can be forwarded.
min_port: u16,
/// Optional secret used to authenticate clients.
auth: Option<Authenticator>,
/// Concurrent map of IDs to incoming connections.
conns: Arc<DashMap<Uuid, TcpStream>>,
}
impl Server {
/// Create a new server with a specified minimum port number.
pub fn new(min_port: u16) -> Self {
pub fn new(min_port: u16, secret: Option<&str>) -> Self {
Server {
min_port,
conns: Arc::new(DashMap::new()),
auth: secret.map(Authenticator::new),
}
}
@@ -58,11 +65,19 @@ impl Server {
async fn handle_connection(&self, stream: TcpStream) -> Result<()> {
let mut stream = BufReader::new(stream);
if let Some(auth) = &self.auth {
if let Err(err) = auth.server_handshake(&mut stream).await {
warn!(%err, "server handshake failed");
send_json(&mut stream, ServerMessage::Error(err.to_string())).await?;
return Ok(());
}
}
let mut buf = Vec::new();
let msg = recv_json(&mut stream, &mut buf).await?;
match msg {
match recv_json_timeout(&mut stream).await? {
Some(ClientMessage::Authenticate(_)) => {
warn!("unexpected authenticate");
Ok(())
}
Some(ClientMessage::Hello(port)) => {
if port != 0 && port < self.min_port {
warn!(?port, "client port number too low");
@@ -73,7 +88,11 @@ impl Server {
Ok(listener) => listener,
Err(_) => {
warn!(?port, "could not bind to local port");
send_json(&mut stream, "port already in use").await?;
send_json(
&mut stream,
ServerMessage::Error("port already in use".into()),
)
.await?;
return Ok(());
}
};
@@ -95,6 +114,7 @@ impl Server {
let id = Uuid::new_v4();
let conns = Arc::clone(&self.conns);
conns.insert(id, stream2);
tokio::spawn(async move {
// Remove stale entries to avoid memory leaks.
@@ -125,6 +145,6 @@ impl Server {
impl Default for Server {
fn default() -> Self {
Server::new(1024)
Server::new(1024, None)
}
}

View File

@@ -1,17 +1,27 @@
//! Shared data structures, utilities, and protocol definitions.
use std::time::Duration;
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::io::{self, AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio::time::timeout;
use tracing::trace;
use uuid::Uuid;
/// TCP port used for control connections with the server.
pub const CONTROL_PORT: u16 = 7835;
/// Timeout for network connections and initial protocol messages.
pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(3);
/// A message from the client on the control connection.
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub enum ClientMessage {
/// Response to an authentication challenge from the server.
Authenticate(String),
/// Initial client message specifying a port to forward.
Hello(u16),
@@ -20,8 +30,11 @@ pub enum ClientMessage {
}
/// A message from the server on the control connection.
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub enum ServerMessage {
/// Authentication challenge, sent as the first message, if enabled.
Challenge(Uuid),
/// Response to a client's initial message, with actual public port.
Hello(u16),
@@ -43,10 +56,10 @@ where
{
let (mut s1_read, mut s1_write) = io::split(stream1);
let (mut s2_read, mut s2_write) = io::split(stream2);
tokio::try_join!(
io::copy(&mut s1_read, &mut s2_write),
io::copy(&mut s2_read, &mut s1_write),
)?;
tokio::select! {
res = io::copy(&mut s1_read, &mut s2_write) => res,
res = io::copy(&mut s2_read, &mut s1_write) => res,
}?;
Ok(())
}
@@ -55,6 +68,7 @@ pub async fn recv_json<T: DeserializeOwned>(
reader: &mut (impl AsyncBufRead + Unpin),
buf: &mut Vec<u8>,
) -> Result<Option<T>> {
trace!("waiting to receive json message");
buf.clear();
reader.read_until(0, buf).await?;
if buf.is_empty() {
@@ -66,8 +80,21 @@ pub async fn recv_json<T: DeserializeOwned>(
Ok(serde_json::from_slice(buf).context("failed to parse JSON")?)
}
/// Read the next null-delimited JSON instruction, with a default timeout.
///
/// This is useful for parsing the initial message of a stream for handshake or
/// other protocol purposes, where we do not want to wait indefinitely.
pub async fn recv_json_timeout<T: DeserializeOwned>(
reader: &mut (impl AsyncBufRead + Unpin),
) -> Result<Option<T>> {
timeout(NETWORK_TIMEOUT, recv_json(reader, &mut Vec::new()))
.await
.context("timed out waiting for initial message")?
}
/// Send a null-terminated JSON instruction on a stream.
pub async fn send_json<T: Serialize>(writer: &mut (impl AsyncWrite + Unpin), msg: T) -> Result<()> {
trace!("sending json message");
let msg = serde_json::to_vec(&msg)?;
writer.write_all(&msg).await?;
writer.write_all(&[0]).await?;

35
tests/auth_test.rs Normal file
View File

@@ -0,0 +1,35 @@
use anyhow::Result;
use bore_cli::auth::Authenticator;
use tokio::io::{self, BufReader};
#[tokio::test]
async fn auth_handshake() -> Result<()> {
let auth = Authenticator::new("some secret string");
let (client, server) = io::duplex(8); // Ensure correctness with limited capacity.
let mut client = BufReader::new(client);
let mut server = BufReader::new(server);
tokio::try_join!(
auth.client_handshake(&mut client),
auth.server_handshake(&mut server),
)?;
Ok(())
}
#[tokio::test]
async fn auth_handshake_fail() {
let auth = Authenticator::new("client secret");
let auth2 = Authenticator::new("different server secret");
let (client, server) = io::duplex(8); // Ensure correctness with limited capacity.
let mut client = BufReader::new(client);
let mut server = BufReader::new(server);
let result = tokio::try_join!(
auth.client_handshake(&mut client),
auth2.server_handshake(&mut server),
);
assert!(result.is_err());
}

100
tests/e2e_test.rs Normal file
View File

@@ -0,0 +1,100 @@
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{anyhow, Result};
use bore_cli::{client::Client, server::Server};
use lazy_static::lazy_static;
use rstest::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use tokio::time;
lazy_static! {
/// Guard to make sure that tests are run serially, not concurrently.
static ref SERIAL_GUARD: Mutex<()> = Mutex::new(());
}
/// 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());
time::sleep(Duration::from_millis(50)).await;
}
/// Spawns a client with randomly assigned ports, returning the listener and remote address.
async fn spawn_client(secret: Option<&str>) -> Result<(TcpListener, SocketAddr)> {
let listener = TcpListener::bind("localhost:0").await?;
let client = Client::new(listener.local_addr()?.port(), "localhost", 0, secret).await?;
let remote_addr = ([0, 0, 0, 0], client.remote_port()).into();
tokio::spawn(client.listen());
Ok((listener, remote_addr))
}
#[rstest]
#[tokio::test]
async fn basic_proxy(#[values(None, Some(""), Some("abc"))] secret: Option<&str>) -> Result<()> {
let _guard = SERIAL_GUARD.lock().await;
spawn_server(secret).await;
let (listener, addr) = spawn_client(secret).await?;
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await?;
let mut buf = [0u8; 11];
stream.read_exact(&mut buf).await?;
assert_eq!(&buf, b"hello world");
stream.write_all(b"I can send a message too!").await?;
anyhow::Ok(())
});
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(b"hello world").await?;
let mut buf = [0u8; 25];
stream.read_exact(&mut buf).await?;
assert_eq!(&buf, b"I can send a message too!");
// Ensure that the client end of the stream is closed now.
assert_eq!(stream.read(&mut buf).await?, 0);
// Also ensure that additional connections do not produce any data.
let mut stream = TcpStream::connect(addr).await?;
assert_eq!(stream.read(&mut buf).await?, 0);
Ok(())
}
#[rstest]
#[case(None, Some("my secret"))]
#[case(Some("my secret"), None)]
#[tokio::test]
async fn mismatched_secret(
#[case] server_secret: Option<&str>,
#[case] client_secret: Option<&str>,
) {
let _guard = SERIAL_GUARD.lock().await;
spawn_server(server_secret).await;
assert!(spawn_client(client_secret).await.is_err());
}
#[tokio::test]
async fn invalid_address() -> Result<()> {
// We don't need the serial guard for this test because it doesn't create a server.
async fn check_address(to: &str, use_secret: bool) -> Result<()> {
match Client::new(5000, to, 0, use_secret.then(|| "a secret")).await {
Ok(_) => Err(anyhow!("expected error for {to}, use_secret={use_secret}")),
Err(_) => Ok(()),
}
}
tokio::try_join!(
check_address("google.com", false),
check_address("google.com", true),
check_address("nonexistent.domain.for.demonstration", false),
check_address("nonexistent.domain.for.demonstration", true),
check_address("malformed !$uri$%", false),
check_address("malformed !$uri$%", true),
)?;
Ok(())
}