20 Commits

Author SHA1 Message Date
Eric Zhang
a6045fb1a7 Bump version to 0.5.1 (#119)
* Update dependencies

* Bump version to 0.5.1

* Try to deflake tests in GitHub Actions

* Add some retries to e2e_test

* Revert cargo.lock changes

* Update only a couple deps

* Fix CI running twice :(

* Fix typo
2024-06-10 21:50:23 -04:00
Eric Zhang
82acea3477 Fix CI runners (#118)
macos-latest has been switched to use arm64 mac instances
2024-06-10 21:04:14 -04:00
kennycallado
1f81f01fe2 arm64 docker image (#114)
* Update docker.yml

Testing aarch64 build works

* Update docker.yml

Revert event to trigger the action

* Update docker.yml

reassign proper images name
2024-06-10 20:54:40 -04:00
Eric Zhang
32a7233c9d Fix clippy lint on recent Rust versions 2023-07-27 11:26:01 -04:00
Kian-Meng Ang
3ae14209a4 Fix typo, Maxmium -> Maximum (#81)
Found via `codespell -L crate`
2023-04-29 11:08:35 -04:00
Eric Zhang
f8ccbae378 Update Homebrew installation instructions (#80)
Thanks @Moulick for making the Homebrew formula and contributing the
package to Homebrew's core tap!
2023-04-28 09:57:45 -04:00
Eric Zhang
6512ca4770 chore: Release bore-cli version 0.5.0 2023-04-27 22:02:18 -04:00
Eric Zhang
d630b47d38 Update dependencies 2023-04-27 21:55:44 -04:00
Eric Zhang
0860c6e018 Use random ports when port number is 0 (#79)
* Use random ports when port number is 0

* Add support for --max-port CLI option

* Fix typo

* Fix another typo

* Update README
2023-04-27 21:48:47 -04:00
Eric Zhang
931f2aa20b Fix badge in README
See <https://github.com/badges/shields/issues/8671>.
2023-01-01 15:06:59 -06:00
Eric Zhang
b65481abb0 Update docker deploy, turn off arm64 for now 2022-11-11 01:42:04 -05:00
Eric Zhang
3bf7a665c1 (cargo-release) version 0.4.1 2022-11-11 01:14:42 -05:00
Eric Zhang
d3d5d434ad Update dependencies, including clap v4
Also fix Clippy lints for rustc 1.65 along the way.
2022-11-11 01:08:50 -05:00
calfzhou
f1ed0b2ecb Support reading remote server address from an environment variable (#45) (#46) 2022-11-11 00:52:58 -05:00
Eric Zhang
664723cba5 Create FUNDING.yml 2022-06-26 18:01:08 -04:00
Eric Zhang
fd83d4a207 Add Homebrew installation instructions 2022-06-05 13:00:16 -04:00
Eric Zhang
045324d7dc Bump version to 0.4.0 2022-04-22 18:14:05 -04:00
Praveen Perera
a2b8382681 GitHub actions CI/CD releases (#10)
* Add GitHub CI/CD

* Add instructions for installing bin from GitHub releases

* Will work after a new version is released

* Add windows jobs for CI/CD

* Only run CI on stable rust

* Use `cargo fmt` instead of `rustfmt`, undo change

* Delete install.sh

* Update README.md

Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
2022-04-22 18:04:26 -04:00
Prasanth
9cd43f458a use framed codecs to avoid unbounded buffer (#33)
* using stream

* fix tests

* 💄

* 💄 fix review comments

* clean up buffered data

* 💄 fix review comments

* Refactor Delimited to be its own struct

* Add very_long_frame test to ensure behavior

Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
2022-04-21 23:48:38 -04:00
B.O.S.S
e61362915d Fix Windows cargo test (#35) 2022-04-21 00:17:14 -04:00
19 changed files with 1172 additions and 423 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [ekzhang]

View File

@@ -1,6 +1,10 @@
name: CI
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
jobs:
rust:
@@ -44,4 +48,4 @@ jobs:
toolchain: stable
components: clippy
- run: cargo clippy
- run: cargo clippy -- -D warnings

View File

@@ -11,11 +11,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ekzhang/bore
tags: |
@@ -23,22 +23,22 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
platforms: linux/amd64,linux/arm64
push: true

127
.github/workflows/mean_bean_ci.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Mean Bean CI
on:
push:
branches:
- main
pull_request:
jobs:
install-cross:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 50
- uses: XAMPPRocky/get-github-release@v1
id: cross
with:
owner: rust-embedded
repo: cross
matches: ${{ matrix.platform }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v1
with:
name: cross-${{ matrix.platform }}
path: ${{ steps.cross.outputs.install_path }}
strategy:
matrix:
platform: [linux-musl]
macos:
runs-on: macos-latest
strategy:
fail-fast: true
matrix:
channel: [stable]
target:
- aarch64-apple-darwin
steps:
- name: Setup | Checkout
uses: actions/checkout@v2
- name: Setup | Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
profile: minimal
target: ${{ matrix.target }}
- run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }}
- name: Test
uses: actions-rs/cargo@v1
with:
command: test
args: --target ${{ matrix.target }}
use-cross: false
linux:
runs-on: ubuntu-latest
needs: install-cross
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 50
- name: Download Cross
uses: actions/download-artifact@v1
with:
name: cross-linux-musl
path: /tmp/
- run: chmod +x /tmp/cross
- run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }}
- run: ci/build.bash /tmp/cross ${{ matrix.target }}
# These targets have issues with being tested so they are disabled
# by default. You can try disabling to see if they work for
# your project.
- run: ci/test.bash /tmp/cross ${{ matrix.target }}
if: |
!contains(matrix.target, 'android') &&
!contains(matrix.target, 'bsd') &&
!contains(matrix.target, 'solaris') &&
matrix.target != 'armv5te-unknown-linux-musleabi' &&
matrix.target != 'sparc64-unknown-linux-gnu'
strategy:
fail-fast: true
matrix:
channel: [stable]
target:
- arm-unknown-linux-gnueabi
- armv7-unknown-linux-gnueabihf
- i686-unknown-linux-musl
- x86_64-unknown-linux-musl
windows:
runs-on: windows-latest
# Windows technically doesn't need this, but if we don't block windows on it
# some of the windows jobs could fill up the concurrent job queue before
# one of the install-cross jobs has started, so this makes sure all
# artifacts are downloaded first.
needs: install-cross
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 50
- run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }}
shell: bash
- run: ci/build.bash cargo ${{ matrix.target }}
shell: bash
- run: ci/test.bash cargo ${{ matrix.target }}
shell: bash
strategy:
fail-fast: true
matrix:
channel: [stable]
target:
# MSVC
- i686-pc-windows-msvc
- x86_64-pc-windows-msvc
# GNU: You typically only need to test Windows GNU if you're
# specifically targetting it, and it can cause issues with some
# dependencies if you're not so it's disabled by self.
# - i686-pc-windows-gnu
# - x86_64-pc-windows-gnu

192
.github/workflows/mean_bean_deploy.yml vendored Normal file
View File

@@ -0,0 +1,192 @@
on:
push:
# # Sequence of patterns matched against refs/tags
tags:
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
name: Mean Bean Deploy
env:
BIN: bore
jobs:
# This job downloads and stores `cross` as an artifact, so that it can be
# redownloaded across all of the jobs. Currently this copied pasted between
# `mean_bean_ci.yml` and `mean_bean_deploy.yml`. Make sure to update both places when making
# changes.
install-cross:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 50
- uses: XAMPPRocky/get-github-release@v1
id: cross
with:
owner: rust-embedded
repo: cross
matches: ${{ matrix.platform }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v1
with:
name: cross-${{ matrix.platform }}
path: ${{ steps.cross.outputs.install_path }}
strategy:
matrix:
platform: [linux-musl]
macos:
runs-on: macos-latest
strategy:
matrix:
target:
# macOS
- x86_64-apple-darwin
- aarch64-apple-darwin
steps:
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- name: Setup | Checkout
uses: actions/checkout@v2
# Cache files between builds
- name: Setup | Cache Cargo
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.target }}
- name: Setup | Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
profile: minimal
target: ${{ matrix.target }}
- name: Build | Build
uses: actions-rs/cargo@v1
with:
command: build
args: --release --target ${{ matrix.target }}
- run: tar -czvf ${{ env.BIN }}.tar.gz --directory=target/${{ matrix.target }}/release ${{ env.BIN }}
- uses: XAMPPRocky/create-release@v1.0.2
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
- uses: actions/upload-release-asset@v1
id: upload-release-asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.BIN }}.tar.gz
asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.tar.gz
asset_content_type: application/gzip
linux:
runs-on: ubuntu-latest
needs: install-cross
strategy:
matrix:
target:
- arm-unknown-linux-gnueabi
- armv7-unknown-linux-gnueabihf
- i686-unknown-linux-musl
- x86_64-unknown-linux-musl
steps:
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- uses: actions/checkout@v2
- uses: actions/download-artifact@v1
with:
name: cross-linux-musl
path: /tmp/
- run: chmod +x /tmp/cross
- run: ci/set_rust_version.bash stable ${{ matrix.target }}
- run: ci/build.bash /tmp/cross ${{ matrix.target }} RELEASE
- run: tar -czvf ${{ env.BIN }}.tar.gz --directory=target/${{ matrix.target }}/release ${{ env.BIN }}
- uses: XAMPPRocky/create-release@v1.0.2
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.BIN }}.tar.gz
asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.tar.gz
asset_content_type: application/gzip
windows:
runs-on: windows-latest
needs: install-cross
strategy:
matrix:
target:
# MSVC
- i686-pc-windows-msvc
- x86_64-pc-windows-msvc
# GNU
# - i686-pc-windows-gnu
# - x86_64-pc-windows-gnu
steps:
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- uses: actions/checkout@v2
- run: bash ci/set_rust_version.bash stable ${{ matrix.target }}
- run: bash ci/build.bash cargo ${{ matrix.target }} RELEASE
- run: |
cd ./target/${{ matrix.target }}/release/
7z a "${{ env.BIN }}.zip" "${{ env.BIN }}.exe"
mv "${{ env.BIN }}.zip" $GITHUB_WORKSPACE
shell: bash
# We're using using a fork of `actions/create-release` that detects
# whether a release is already available or not first.
- uses: XAMPPRocky/create-release@v1.0.2
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
# Draft should **always** be false. GitHub doesn't provide a way to
# get draft releases from its API, so there's no point using it.
draft: false
prerelease: false
- uses: actions/upload-release-asset@v1
id: upload-release-asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.BIN }}.zip
asset_name: ${{ env.BIN }}-${{steps.tag.outputs.tag}}-${{ matrix.target }}.zip
asset_content_type: application/zip

804
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "bore-cli"
version = "0.3.0"
version = "0.5.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."
@@ -17,19 +17,22 @@ path = "src/main.rs"
[dependencies]
anyhow = { version = "1.0.56", features = ["backtrace"] }
clap = { version = "3.1.8", features = ["derive", "env"] }
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"
serde = { version = "1.0.136", features = ["derive"] }
serde_json = "1.0.79"
sha2 = "0.10.2"
tokio = { version = "1.17.0", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
tokio-util = { version = "0.7.1", features = ["codec"] }
tracing = "0.1.32"
tracing-subscriber = "0.3.10"
uuid = { version = "0.8.2", features = ["serde", "v4"] }
uuid = { version = "1.2.1", features = ["serde", "v4"] }
[dev-dependencies]
lazy_static = "1.4.0"
rstest = "0.12.0"
rstest = "0.15.0"
tokio = { version = "1.17.0", features = ["sync"] }

View File

@@ -1,6 +1,6 @@
# bore
[![Build status](https://img.shields.io/github/workflow/status/ekzhang/bore/CI)](https://github.com/ekzhang/bore/actions)
[![Build status](https://img.shields.io/github/actions/workflow/status/ekzhang/bore/ci.yml)](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.**
@@ -8,7 +8,7 @@ A modern, simple TCP tunnel in Rust that exposes local ports to a remote server,
![Video demo](https://i.imgur.com/vDeGsmx.gif)
```shell
# Installation (requires Rust)
# Installation (requires Rust, see alternatives below)
cargo install bore-cli
# On your local machine
@@ -19,17 +19,25 @@ 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
You can build the `bore` CLI command from source using [Cargo](https://doc.rust-lang.org/cargo/), the Rust package manager. This command installs the `bore` binary at a user-accessible path.
If you're on macOS, `bore` is packaged as a Homebrew core formula.
```shell
brew install bore-cli
```
Otherwise, the easiest way to install bore is from prebuilt binaries. These are available on the [releases page](https://github.com/ekzhang/bore/releases) for macOS, Windows, and Linux. Just unzip the appropriate file for your platform and move the `bore` executable into a folder on your PATH.
You also can build `bore` from source using [Cargo](https://doc.rust-lang.org/cargo/), the Rust package manager. This command installs the `bore` binary at a user-accessible path.
```shell
cargo install bore-cli
```
We also publish versioned Docker images for each release. Each image is built for AMD 64-bit and Arm 64-bit architectures. They're tagged with the specific version and allow you to run the statically-linked `bore` binary from a minimal "scratch" container.
We also publish versioned Docker images for each release. The image is built for an AMD 64-bit architecture. They're tagged with the specific version and allow you to run the statically-linked `bore` binary from a minimal "scratch" container.
```shell
docker run -it --init --rm --network host ekzhang/bore <ARGS>
@@ -52,22 +60,19 @@ 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.3.0
Starts a local proxy to the remote server
USAGE:
bore local [OPTIONS] --to <TO> <LOCAL_PORT>
Usage: bore local [OPTIONS] --to <TO> <LOCAL_PORT>
ARGS:
<LOCAL_PORT> The local port to expose
Arguments:
<LOCAL_PORT> The local port to expose
OPTIONS:
-h, --help Print help information
-l, --local-host <HOST> The local host to expose [default: localhost]
-p, --port <PORT> Optional port on the remote server to select [default: 0]
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
-t, --to <TO> Address of the remote server to expose local ports to
-V, --version Print version information
Options:
-l, --local-host <HOST> The local host to expose [default: localhost]
-t, --to <TO> Address of the remote server to expose local ports to [env: BORE_SERVER=]
-p, --port <PORT> Optional port on the remote server to select [default: 0]
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
-h, --help Print help information
```
### Self-Hosting
@@ -83,17 +88,15 @@ 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.3.0
Runs the remote proxy server
USAGE:
bore server [OPTIONS]
Usage: bore server [OPTIONS]
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 [env: BORE_SECRET]
-V, --version Print version information
Options:
--min-port <MIN_PORT> Minimum accepted TCP port number [default: 1024]
--max-port <MAX_PORT> Maximum accepted TCP port number [default: 65535]
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
-h, --help Print help information
```
## Protocol

22
ci/build.bash Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Script for building your rust projects.
set -e
source ci/common.bash
# $1 {path} = Path to cross/cargo executable
CROSS=$1
# $1 {string} = <Target Triple> e.g. x86_64-pc-windows-msvc
TARGET_TRIPLE=$2
# $3 {boolean} = Are we building for deployment?
RELEASE_BUILD=$3
required_arg $CROSS 'CROSS'
required_arg $TARGET_TRIPLE '<Target Triple>'
if [ -z "$RELEASE_BUILD" ]; then
$CROSS build --target $TARGET_TRIPLE
$CROSS build --target $TARGET_TRIPLE --all-features
else
$CROSS build --target $TARGET_TRIPLE --all-features --release
fi

6
ci/common.bash Normal file
View File

@@ -0,0 +1,6 @@
required_arg() {
if [ -z "$1" ]; then
echo "Required argument $2 missing"
exit 1
fi
}

4
ci/set_rust_version.bash Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
rustup default $1
rustup target add $2

32
ci/test.bash Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Script for building your rust projects.
set -e
source ci/common.bash
# $1 {path} = Path to cross/cargo executable
CROSS=$1
# $1 {string} = <Target Triple>
TARGET_TRIPLE=$2
required_arg $CROSS 'CROSS'
required_arg $TARGET_TRIPLE '<Target Triple>'
max_attempts=3
count=0
while [ $count -lt $max_attempts ]; do
$CROSS test --target $TARGET_TRIPLE
status=$?
if [ $status -eq 0 ]; then
echo "Test passed"
break
else
echo "Test failed, attempt $(($count + 1))"
fi
count=$(($count + 1))
done
if [ $status -ne 0 ]; then
echo "Test failed after $max_attempts attempts"
fi

View File

@@ -3,10 +3,10 @@
use anyhow::{bail, ensure, Result};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufRead, AsyncWrite};
use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid;
use crate::shared::{recv_json_timeout, send_json, ClientMessage, ServerMessage};
use crate::shared::{ClientMessage, Delimited, ServerMessage};
/// Wrapper around a MAC used for authenticating clients that have a secret.
pub struct Authenticator(Hmac<Sha256>);
@@ -48,13 +48,13 @@ impl Authenticator {
}
/// As the server, send a challenge to the client and validate their response.
pub async fn server_handshake(
pub async fn server_handshake<T: AsyncRead + AsyncWrite + Unpin>(
&self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin),
stream: &mut Delimited<T>,
) -> Result<()> {
let challenge = Uuid::new_v4();
send_json(stream, ServerMessage::Challenge(challenge)).await?;
match recv_json_timeout(stream).await? {
stream.send(ServerMessage::Challenge(challenge)).await?;
match stream.recv_timeout().await? {
Some(ClientMessage::Authenticate(tag)) => {
ensure!(self.validate(&challenge, &tag), "invalid secret");
Ok(())
@@ -64,16 +64,16 @@ impl Authenticator {
}
/// As the client, answer a challenge to attempt to authenticate with the server.
pub async fn client_handshake(
pub async fn client_handshake<T: AsyncRead + AsyncWrite + Unpin>(
&self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin),
stream: &mut Delimited<T>,
) -> Result<()> {
let challenge = match recv_json_timeout(stream).await? {
let challenge = match stream.recv_timeout().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?;
stream.send(ClientMessage::Authenticate(tag)).await?;
Ok(())
}
}

View File

@@ -3,20 +3,19 @@
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use tokio::{io::BufReader, net::TcpStream, time::timeout};
use tokio::{io::AsyncWriteExt, net::TcpStream, time::timeout};
use tracing::{error, info, info_span, warn, Instrument};
use uuid::Uuid;
use crate::auth::Authenticator;
use crate::shared::{
proxy, recv_json, recv_json_timeout, send_json, ClientMessage, ServerMessage, CONTROL_PORT,
NETWORK_TIMEOUT,
proxy, ClientMessage, Delimited, ServerMessage, CONTROL_PORT, NETWORK_TIMEOUT,
};
/// State structure for the client.
pub struct Client {
/// Control connection to the server.
conn: Option<BufReader<TcpStream>>,
conn: Option<Delimited<TcpStream>>,
/// Destination address of the server.
to: String,
@@ -43,15 +42,14 @@ impl Client {
port: u16,
secret: Option<&str>,
) -> Result<Self> {
let mut stream = BufReader::new(connect_with_timeout(to, CONTROL_PORT).await?);
let mut stream = Delimited::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_timeout(&mut stream).await? {
stream.send(ClientMessage::Hello(port)).await?;
let remote_port = match stream.recv_timeout().await? {
Some(ServerMessage::Hello(remote_port)) => remote_port,
Some(ServerMessage::Error(message)) => bail!("server error: {message}"),
Some(ServerMessage::Challenge(_)) => {
@@ -82,10 +80,8 @@ impl Client {
pub async fn listen(mut self) -> Result<()> {
let mut conn = self.conn.take().unwrap();
let this = Arc::new(self);
let mut buf = Vec::new();
loop {
let msg = recv_json(&mut conn, &mut buf).await?;
match msg {
match conn.recv().await? {
Some(ServerMessage::Hello(_)) => warn!("unexpected hello"),
Some(ServerMessage::Challenge(_)) => warn!("unexpected challenge"),
Some(ServerMessage::Heartbeat) => (),
@@ -110,14 +106,16 @@ impl Client {
async fn handle_connection(&self, id: Uuid) -> Result<()> {
let mut remote_conn =
BufReader::new(connect_with_timeout(&self.to[..], CONTROL_PORT).await?);
Delimited::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(&self.local_host, self.local_port).await?;
proxy(local_conn, remote_conn).await?;
remote_conn.send(ClientMessage::Accept(id)).await?;
let mut local_conn = connect_with_timeout(&self.local_host, self.local_port).await?;
let parts = remote_conn.into_parts();
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
local_conn.write_all(&parts.read_buf).await?; // mostly of the cases, this will be empty
proxy(local_conn, parts.io).await?;
Ok(())
}
}

View File

@@ -1,10 +1,9 @@
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)]
#[clap(propagate_version = true)]
struct Args {
#[clap(subcommand)]
command: Command,
@@ -22,7 +21,7 @@ enum Command {
local_host: String,
/// Address of the remote server to expose local ports to.
#[clap(short, long)]
#[clap(short, long, env = "BORE_SERVER")]
to: String,
/// Optional port on the remote server to select.
@@ -36,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<String>,
@@ -59,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?;
}
}

View File

@@ -1,26 +1,22 @@
//! 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;
use tokio::io::BufReader;
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};
use tokio::time::{sleep, timeout};
use tracing::{info, info_span, warn, Instrument};
use uuid::Uuid;
use crate::auth::Authenticator;
use crate::shared::{
proxy, recv_json_timeout, send_json, ClientMessage, ServerMessage, CONTROL_PORT,
};
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<u16>,
/// Optional secret used to authenticate clients.
auth: Option<Authenticator>,
@@ -31,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<u16>, 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),
}
@@ -63,47 +60,72 @@ impl Server {
}
}
async fn create_listener(&self, port: u16) -> Result<TcpListener, &'static str> {
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 = BufReader::new(stream);
let mut stream = Delimited::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?;
stream.send(ServerMessage::Error(err.to_string())).await?;
return Ok(());
}
}
match recv_json_timeout(&mut stream).await? {
match stream.recv_timeout().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");
return Ok(());
}
info!(?port, "new client");
let listener = match TcpListener::bind(("::", port)).await {
let listener = match self.create_listener(port).await {
Ok(listener) => listener,
Err(_) => {
warn!(?port, "could not bind to local port");
send_json(
&mut stream,
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();
send_json(&mut stream, ServerMessage::Hello(port)).await?;
info!(?port, "new client");
stream.send(ServerMessage::Hello(port)).await?;
loop {
if send_json(&mut stream, ServerMessage::Heartbeat)
.await
.is_err()
{
if stream.send(ServerMessage::Heartbeat).await.is_err() {
// Assume that the TCP connection has been dropped.
return Ok(());
}
@@ -123,28 +145,24 @@ impl Server {
warn!(%id, "removed stale connection");
}
});
send_json(&mut stream, ServerMessage::Connection(id)).await?;
stream.send(ServerMessage::Connection(id)).await?;
}
}
}
Some(ClientMessage::Accept(id)) => {
info!(%id, "forwarding connection");
match self.conns.remove(&id) {
Some((_, stream2)) => proxy(stream, stream2).await?,
Some((_, mut stream2)) => {
let parts = stream.into_parts();
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
stream2.write_all(&parts.read_buf).await?;
proxy(parts.io, stream2).await?
}
None => warn!(%id, "missing connection"),
}
Ok(())
}
None => {
warn!("unexpected EOF");
Ok(())
}
None => Ok(()),
}
}
}
impl Default for Server {
fn default() -> Self {
Server::new(1024, None)
}
}

View File

@@ -3,16 +3,20 @@
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 futures_util::{SinkExt, StreamExt};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::io::{self, AsyncRead, AsyncWrite};
use tokio::time::timeout;
use tokio_util::codec::{AnyDelimiterCodec, Framed, FramedParts};
use tracing::trace;
use uuid::Uuid;
/// TCP port used for control connections with the server.
pub const CONTROL_PORT: u16 = 7835;
/// Maximum byte length for a JSON frame in the stream.
pub const MAX_FRAME_LENGTH: usize = 256;
/// Timeout for network connections and initial protocol messages.
pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(3);
@@ -48,6 +52,52 @@ pub enum ServerMessage {
Error(String),
}
/// Transport stream with JSON frames delimited by null characters.
pub struct Delimited<U>(Framed<U, AnyDelimiterCodec>);
impl<U: AsyncRead + AsyncWrite + Unpin> Delimited<U> {
/// Construct a new delimited stream.
pub fn new(stream: U) -> Self {
let codec = AnyDelimiterCodec::new_with_max_length(vec![0], vec![0], MAX_FRAME_LENGTH);
Self(Framed::new(stream, codec))
}
/// Read the next null-delimited JSON instruction from a stream.
pub async fn recv<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
trace!("waiting to receive json message");
if let Some(next_message) = self.0.next().await {
let byte_message = next_message.context("frame error, invalid byte length")?;
let serialized_obj =
serde_json::from_slice(&byte_message).context("unable to parse message")?;
Ok(serialized_obj)
} else {
Ok(None)
}
}
/// 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_timeout<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
timeout(NETWORK_TIMEOUT, self.recv())
.await
.context("timed out waiting for initial message")?
}
/// Send a null-terminated JSON instruction on a stream.
pub async fn send<T: Serialize>(&mut self, msg: T) -> Result<()> {
trace!("sending json message");
self.0.send(serde_json::to_string(&msg)?).await?;
Ok(())
}
/// Consume this object, returning current buffers and the inner transport.
pub fn into_parts(self) -> FramedParts<U, AnyDelimiterCodec> {
self.0.into_parts()
}
}
/// Copy data mutually between two read/write streams.
pub async fn proxy<S1, S2>(stream1: S1, stream2: S2) -> io::Result<()>
where
@@ -62,41 +112,3 @@ where
}?;
Ok(())
}
/// Read the next null-delimited JSON instruction from a stream.
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() {
return Ok(None);
}
if buf.last() == Some(&0) {
buf.pop();
}
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?;
Ok(())
}

View File

@@ -1,14 +1,14 @@
use anyhow::Result;
use bore_cli::auth::Authenticator;
use tokio::io::{self, BufReader};
use bore_cli::{auth::Authenticator, shared::Delimited};
use tokio::io::{self};
#[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);
let mut client = Delimited::new(client);
let mut server = Delimited::new(server);
tokio::try_join!(
auth.client_handshake(&mut client),
@@ -24,8 +24,8 @@ async fn auth_handshake_fail() {
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 mut client = Delimited::new(client);
let mut server = Delimited::new(server);
let result = tokio::try_join!(
auth.client_handshake(&mut client),

View File

@@ -1,8 +1,10 @@
#![allow(clippy::items_after_test_module)]
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{anyhow, Result};
use bore_cli::{client::Client, server::Server};
use bore_cli::{client::Client, server::Server, shared::CONTROL_PORT};
use lazy_static::lazy_static;
use rstest::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -17,7 +19,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;
}
@@ -26,7 +28,7 @@ async fn spawn_client(secret: Option<&str>) -> Result<(TcpListener, SocketAddr)>
let listener = TcpListener::bind("localhost:0").await?;
let local_port = listener.local_addr()?.port();
let client = Client::new("localhost", local_port, "localhost", 0, secret).await?;
let remote_addr = ([0, 0, 0, 0], client.remote_port()).into();
let remote_addr = ([127, 0, 0, 1], client.remote_port()).into();
tokio::spawn(client.listen());
Ok((listener, remote_addr))
}
@@ -84,7 +86,7 @@ async fn mismatched_secret(
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("localhost", 5000, to, 0, use_secret.then(|| "a secret")).await {
match Client::new("localhost", 5000, to, 0, use_secret.then_some("a secret")).await {
Ok(_) => Err(anyhow!("expected error for {to}, use_secret={use_secret}")),
Err(_) => Ok(()),
}
@@ -99,3 +101,29 @@ async fn invalid_address() -> Result<()> {
)?;
Ok(())
}
#[tokio::test]
async fn very_long_frame() -> Result<()> {
let _guard = SERIAL_GUARD.lock().await;
spawn_server(None).await;
let mut attacker = TcpStream::connect(("localhost", CONTROL_PORT)).await?;
// Slowly send a very long frame.
for _ in 0..10 {
let result = attacker.write_all(&[42u8; 100000]).await;
if result.is_err() {
return Ok(());
}
time::sleep(Duration::from_millis(10)).await;
}
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);
}