40 Commits
v0.3.0 ... main

Author SHA1 Message Date
Eric Zhang
8e059cdaf9 Bump version to 0.6.0 2025-06-09 16:12:10 -04:00
Eric Zhang
7969486d32 Use copy_bidirectional, handle half-closed TCP streams (#165) 2025-06-09 16:10:40 -04:00
Eric Zhang
8ad7ee212b Fix actions/cache action deprecation 2025-04-14 17:52:17 -04:00
Eric Zhang
19e7da1aad Bump version to 0.5.3 2025-04-14 17:48:02 -04:00
Eric Zhang
0128459a50 Minor API changes 2025-04-14 17:44:14 -04:00
confor
299ad61030 default --bind-tunnels to --bind-addr 2025-04-14 17:18:43 -04:00
confor
6a71c9a855 Merge branch 'ekzhang:main' into main 2025-04-14 16:57:24 -04:00
confor
dd954c98e2 rename flags to bind_addr and bind_tunnels 2025-04-14 16:46:31 -04:00
confor
53dad89514 use IpAddr type for validation and add error messages
From: https://github.com/ekzhang/bore/pull/162#discussion_r2042195219
2025-04-14 16:39:09 -04:00
狗娃子
03f2e53f39 docs: update installation instructions for Linux (#152)
* docs: update installation instructions for Linux

* Nits

* More nits

---------

Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
2025-04-14 10:01:33 -04:00
confor
e137357267 Fix tests and run cargo fmt 2025-04-14 01:46:33 -04:00
confor
4bdb00c385 Document new server changes 2025-04-14 01:34:52 -04:00
confor
2a2541e866 Allow configurable tunnel IP address for server
This commit introduces a new option to specify the IP address where tunnels
will listen, which may be different from the control server's IP address.

A new flag `--tunnels-addr` can specify the IP addr to bind to. Default is set
to `0.0.0.0` which is the previous behavior.
2025-04-14 01:18:03 -04:00
confor
aa0d6e0ae5 Allow configurable control server bind address
This commit adds the ability to bind the control server to a specific network
interface via an additional flag `--control-addr`.

By default it listens on `0.0.0.0` which is the previous behaviour.
2025-04-14 00:59:38 -04:00
Stefan M.
b23beb98a2 Fix Dockerfile (#151) 2025-01-10 13:31:56 -05:00
Eric Zhang
baa42d0f7b Bump version to 0.5.2 2024-12-05 13:42:39 -05:00
Silas Alberti
da86f5bbba chore: update tracing-subscriber to 0.3.18 for NO_COLOR support (#146)
This update adds support for the NO_COLOR environment variable in tracing output,
which allows suppressing ANSI color codes in log output. This is particularly
useful when running bore as a Windows service.

Fixes #65

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2024-12-05 13:33:13 -05:00
J/A
0546092ce0 Expose local port as env value (#132)
Co-authored-by: Eric Zhang <ekzhang1@gmail.com>
2024-10-16 12:32:35 -04:00
Ricardo Araújo Paes
7fec9700c4 🔧 Adding minimum and maximum port as environment variable (#136)
* 🔧 Adding minimum and maximum port as environment variable

* 🏗️ Updating `actions/*` to v4 because nodejs version is old
2024-10-15 19:50:33 -04:00
虫子樱桃
a1e1f55a29 feat:add more target in github action flow (#120)
Co-authored-by: czyt <czyt@w.cn>
2024-06-13 09:38:59 -04:00
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
20 changed files with 1283 additions and 443 deletions

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

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

View File

@@ -1,13 +1,17 @@
name: CI name: CI
on: [push, pull_request] on:
push:
branches:
- main
pull_request:
jobs: jobs:
rust: rust:
name: Build and Test name: Build and Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1 - uses: actions-rs/toolchain@v1
with: with:
@@ -22,7 +26,7 @@ jobs:
name: Rustfmt name: Rustfmt
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1 - uses: actions-rs/toolchain@v1
with: with:
@@ -36,7 +40,7 @@ jobs:
name: Clippy name: Clippy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1 - uses: actions-rs/toolchain@v1
with: with:
@@ -44,4 +48,4 @@ jobs:
toolchain: stable toolchain: stable
components: clippy components: clippy
- run: cargo clippy - run: cargo clippy -- -D warnings

View File

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

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

@@ -0,0 +1,130 @@
name: Mean Bean CI
on:
push:
branches:
- main
pull_request:
jobs:
install-cross:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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@v4
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@v4
- 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@v4
with:
fetch-depth: 50
- name: Download Cross
uses: actions/download-artifact@v4
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:
- aarch64-unknown-linux-musl
- arm-unknown-linux-musleabi
- arm-unknown-linux-gnueabi
- armv7-unknown-linux-gnueabihf
- armv7-unknown-linux-musleabihf
- 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@v4
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

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

@@ -0,0 +1,195 @@
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@v4
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@v4
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@v4
# Cache files between builds
- name: Setup | Cache Cargo
uses: actions/cache@v4
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:
- aarch64-unknown-linux-musl
- arm-unknown-linux-musleabi
- arm-unknown-linux-gnueabi
- armv7-unknown-linux-gnueabihf
- armv7-unknown-linux-musleabihf
- i686-unknown-linux-musl
- x86_64-unknown-linux-musl
steps:
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
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@v4
- 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

806
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "bore-cli" name = "bore-cli"
version = "0.3.0" version = "0.6.0"
authors = ["Eric Zhang <ekzhang1@gmail.com>"] authors = ["Eric Zhang <ekzhang1@gmail.com>"]
license = "MIT" license = "MIT"
description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls." 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] [dependencies]
anyhow = { version = "1.0.56", features = ["backtrace"] } 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" dashmap = "5.2.0"
fastrand = "1.9.0"
futures-util = { version = "0.3.21", features = ["sink"] }
hex = "0.4.3" hex = "0.4.3"
hmac = "0.12.1" hmac = "0.12.1"
serde = { version = "1.0.136", features = ["derive"] } serde = { version = "1.0.136", features = ["derive"] }
serde_json = "1.0.79" serde_json = "1.0.79"
sha2 = "0.10.2" sha2 = "0.10.2"
tokio = { version = "1.17.0", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] } 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 = "0.1.32"
tracing-subscriber = "0.3.10" tracing-subscriber = "0.3.18"
uuid = { version = "0.8.2", features = ["serde", "v4"] } uuid = { version = "1.2.1", features = ["serde", "v4"] }
[dev-dependencies] [dev-dependencies]
lazy_static = "1.4.0" lazy_static = "1.4.0"
rstest = "0.12.0" rstest = "0.15.0"
tokio = { version = "1.17.0", features = ["sync"] } tokio = { version = "1.17.0", features = ["sync"] }

View File

@@ -1,4 +1,4 @@
FROM rust:alpine as builder FROM rust:alpine AS builder
WORKDIR /home/rust/src WORKDIR /home/rust/src
RUN apk --no-cache add musl-dev RUN apk --no-cache add musl-dev
COPY . . COPY . .

View File

@@ -1,6 +1,6 @@
# bore # 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) [![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.** 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) ![Video demo](https://i.imgur.com/vDeGsmx.gif)
```shell ```shell
# Installation (requires Rust) # Installation (requires Rust, see alternatives below)
cargo install bore-cli cargo install bore-cli
# On your local machine # On your local machine
@@ -19,17 +19,53 @@ 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. 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 ## 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. ### macOS
`bore` is packaged as a Homebrew core formula.
```shell
brew install bore-cli
```
### Linux
#### Arch Linux
`bore` is available in the AUR as `bore`.
```shell
yay -S bore # or your favorite AUR helper
```
#### Gentoo Linux
`bore` is available in the [gentoo-zh](https://github.com/microcai/gentoo-zh) overlay.
```shell
sudo eselect repository enable gentoo-zh
sudo emerge --sync gentoo-zh
sudo emerge net-proxy/bore
```
### Binary Distribution
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.
### Cargo
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 ```shell
cargo install bore-cli 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. ### Docker
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 ```shell
docker run -it --init --rm --network host ekzhang/bore <ARGS> docker run -it --init --rm --network host ekzhang/bore <ARGS>
@@ -52,22 +88,19 @@ You can optionally pass in a `--port` option to pick a specific port on the remo
The full options are shown below. The full options are shown below.
```shell ```shell
bore-local 0.3.0
Starts a local proxy to the remote server Starts a local proxy to the remote server
USAGE: Usage: bore local [OPTIONS] --to <TO> <LOCAL_PORT>
bore local [OPTIONS] --to <TO> <LOCAL_PORT>
ARGS: Arguments:
<LOCAL_PORT> The local port to expose <LOCAL_PORT> The local port to expose [env: BORE_LOCAL_PORT=]
OPTIONS: Options:
-h, --help Print help information
-l, --local-host <HOST> The local host to expose [default: localhost] -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] -p, --port <PORT> Optional port on the remote server to select [default: 0]
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET] -s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
-t, --to <TO> Address of the remote server to expose local ports to -h, --help Print help
-V, --version Print version information
``` ```
### Self-Hosting ### Self-Hosting
@@ -80,20 +113,22 @@ bore server
That's all it takes! After the server starts running at a given address, you can then update the `bore local` command with option `--to <ADDRESS>` to forward a local port to this remote server. That's all it takes! After the server starts running at a given address, you can then update the `bore local` command with option `--to <ADDRESS>` to forward a local port to this remote server.
It's possible to specify different IP addresses for the control server and for the tunnels. This setup is useful for cases where you might want the control server to be on a private network while allowing tunnel connections over a public interface, or vice versa.
The full options for the `bore server` command are shown below. The full options for the `bore server` command are shown below.
```shell ```shell
bore-server 0.3.0
Runs the remote proxy server Runs the remote proxy server
USAGE: Usage: bore server [OPTIONS]
bore server [OPTIONS]
OPTIONS: Options:
-h, --help Print help information --min-port <MIN_PORT> Minimum accepted TCP port number [env: BORE_MIN_PORT=] [default: 1024]
--min-port <MIN_PORT> Minimum TCP port number to accept [default: 1024] --max-port <MAX_PORT> Maximum accepted TCP port number [env: BORE_MAX_PORT=] [default: 65535]
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET] -s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
-V, --version Print version information --bind-addr <BIND_ADDR> IP address to bind to, clients must reach this [default: 0.0.0.0]
--bind-tunnels <BIND_TUNNELS> IP address where tunnels will listen on, defaults to --bind-addr
-h, --help Print help
``` ```
## Protocol ## 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 anyhow::{bail, ensure, Result};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid; 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. /// Wrapper around a MAC used for authenticating clients that have a secret.
pub struct Authenticator(Hmac<Sha256>); pub struct Authenticator(Hmac<Sha256>);
@@ -48,13 +48,13 @@ impl Authenticator {
} }
/// As the server, send a challenge to the client and validate their response. /// 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, &self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin), stream: &mut Delimited<T>,
) -> Result<()> { ) -> Result<()> {
let challenge = Uuid::new_v4(); let challenge = Uuid::new_v4();
send_json(stream, ServerMessage::Challenge(challenge)).await?; stream.send(ServerMessage::Challenge(challenge)).await?;
match recv_json_timeout(stream).await? { match stream.recv_timeout().await? {
Some(ClientMessage::Authenticate(tag)) => { Some(ClientMessage::Authenticate(tag)) => {
ensure!(self.validate(&challenge, &tag), "invalid secret"); ensure!(self.validate(&challenge, &tag), "invalid secret");
Ok(()) Ok(())
@@ -64,16 +64,16 @@ impl Authenticator {
} }
/// As the client, answer a challenge to attempt to authenticate with the server. /// 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, &self,
stream: &mut (impl AsyncBufRead + AsyncWrite + Unpin), stream: &mut Delimited<T>,
) -> Result<()> { ) -> Result<()> {
let challenge = match recv_json_timeout(stream).await? { let challenge = match stream.recv_timeout().await? {
Some(ServerMessage::Challenge(challenge)) => challenge, Some(ServerMessage::Challenge(challenge)) => challenge,
_ => bail!("expected authentication challenge, but no secret was required"), _ => bail!("expected authentication challenge, but no secret was required"),
}; };
let tag = self.answer(&challenge); let tag = self.answer(&challenge);
send_json(stream, ClientMessage::Authenticate(tag)).await?; stream.send(ClientMessage::Authenticate(tag)).await?;
Ok(()) Ok(())
} }
} }

View File

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

View File

@@ -1,10 +1,11 @@
use std::net::IpAddr;
use anyhow::Result; use anyhow::Result;
use bore_cli::{client::Client, server::Server}; use bore_cli::{client::Client, server::Server};
use clap::{Parser, Subcommand}; use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[clap(author, version, about)] #[clap(author, version, about)]
#[clap(propagate_version = true)]
struct Args { struct Args {
#[clap(subcommand)] #[clap(subcommand)]
command: Command, command: Command,
@@ -15,6 +16,7 @@ enum Command {
/// Starts a local proxy to the remote server. /// Starts a local proxy to the remote server.
Local { Local {
/// The local port to expose. /// The local port to expose.
#[clap(env = "BORE_LOCAL_PORT")]
local_port: u16, local_port: u16,
/// The local host to expose. /// The local host to expose.
@@ -22,7 +24,7 @@ enum Command {
local_host: String, local_host: String,
/// Address of the remote server to expose local ports to. /// Address of the remote server to expose local ports to.
#[clap(short, long)] #[clap(short, long, env = "BORE_SERVER")]
to: String, to: String,
/// Optional port on the remote server to select. /// Optional port on the remote server to select.
@@ -36,13 +38,25 @@ enum Command {
/// Runs the remote proxy server. /// Runs the remote proxy server.
Server { Server {
/// Minimum TCP port number to accept. /// Minimum accepted TCP port number.
#[clap(long, default_value_t = 1024)] #[clap(long, default_value_t = 1024, env = "BORE_MIN_PORT")]
min_port: u16, min_port: u16,
/// Maximum accepted TCP port number.
#[clap(long, default_value_t = 65535, env = "BORE_MAX_PORT")]
max_port: u16,
/// Optional secret for authentication. /// Optional secret for authentication.
#[clap(short, long, env = "BORE_SECRET", hide_env_values = true)] #[clap(short, long, env = "BORE_SECRET", hide_env_values = true)]
secret: Option<String>, secret: Option<String>,
/// IP address to bind to, clients must reach this.
#[clap(long, default_value = "0.0.0.0")]
bind_addr: IpAddr,
/// IP address where tunnels will listen on, defaults to --bind-addr.
#[clap(long)]
bind_tunnels: Option<IpAddr>,
}, },
} }
@@ -59,8 +73,23 @@ async fn run(command: Command) -> Result<()> {
let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?; let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?;
client.listen().await?; client.listen().await?;
} }
Command::Server { min_port, secret } => { Command::Server {
Server::new(min_port, secret.as_deref()).listen().await?; min_port,
max_port,
secret,
bind_addr,
bind_tunnels,
} => {
let port_range = min_port..=max_port;
if port_range.is_empty() {
Args::command()
.error(ErrorKind::InvalidValue, "port range is empty")
.exit();
}
let mut server = Server::new(port_range, secret.as_deref());
server.set_bind_addr(bind_addr);
server.set_bind_tunnels(bind_tunnels.unwrap_or(bind_addr));
server.listen().await?;
} }
} }

View File

@@ -1,50 +1,65 @@
//! Server implementation for the `bore` service. //! Server implementation for the `bore` service.
use std::net::SocketAddr; use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc; use std::{io, ops::RangeInclusive, sync::Arc, time::Duration};
use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use dashmap::DashMap; use dashmap::DashMap;
use tokio::io::BufReader; use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::time::{sleep, timeout}; use tokio::time::{sleep, timeout};
use tracing::{info, info_span, warn, Instrument}; use tracing::{info, info_span, warn, Instrument};
use uuid::Uuid; use uuid::Uuid;
use crate::auth::Authenticator; use crate::auth::Authenticator;
use crate::shared::{ use crate::shared::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT};
proxy, recv_json_timeout, send_json, ClientMessage, ServerMessage, CONTROL_PORT,
};
/// State structure for the server. /// State structure for the server.
pub struct Server { pub struct Server {
/// The minimum TCP port that can be forwarded. /// Range of TCP ports that can be forwarded.
min_port: u16, port_range: RangeInclusive<u16>,
/// Optional secret used to authenticate clients. /// Optional secret used to authenticate clients.
auth: Option<Authenticator>, auth: Option<Authenticator>,
/// Concurrent map of IDs to incoming connections. /// Concurrent map of IDs to incoming connections.
conns: Arc<DashMap<Uuid, TcpStream>>, conns: Arc<DashMap<Uuid, TcpStream>>,
/// IP address where the control server will bind to.
bind_addr: IpAddr,
/// IP address where tunnels will listen on.
bind_tunnels: IpAddr,
} }
impl Server { impl Server {
/// Create a new server with a specified minimum port number. /// 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 { Server {
min_port, port_range,
conns: Arc::new(DashMap::new()), conns: Arc::new(DashMap::new()),
auth: secret.map(Authenticator::new), auth: secret.map(Authenticator::new),
bind_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
bind_tunnels: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
} }
} }
/// Set the IP address where tunnels will listen on.
pub fn set_bind_addr(&mut self, bind_addr: IpAddr) {
self.bind_addr = bind_addr;
}
/// Set the IP address where the control server will bind to.
pub fn set_bind_tunnels(&mut self, bind_tunnels: IpAddr) {
self.bind_tunnels = bind_tunnels;
}
/// Start the server, listening for new connections. /// Start the server, listening for new connections.
pub async fn listen(self) -> Result<()> { pub async fn listen(self) -> Result<()> {
let this = Arc::new(self); let this = Arc::new(self);
let addr = SocketAddr::from(([0, 0, 0, 0], CONTROL_PORT)); let listener = TcpListener::bind((this.bind_addr, CONTROL_PORT)).await?;
let listener = TcpListener::bind(&addr).await?; info!(addr = ?this.bind_addr, "server listening");
info!(?addr, "server listening");
loop { loop {
let (stream, addr) = listener.accept().await?; let (stream, addr) = listener.accept().await?;
@@ -63,47 +78,73 @@ impl Server {
} }
} }
async fn create_listener(&self, port: u16) -> Result<TcpListener, &'static str> {
let try_bind = |port: u16| async move {
TcpListener::bind((self.bind_tunnels, 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<()> { 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 Some(auth) = &self.auth {
if let Err(err) = auth.server_handshake(&mut stream).await { if let Err(err) = auth.server_handshake(&mut stream).await {
warn!(%err, "server handshake failed"); 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(()); return Ok(());
} }
} }
match recv_json_timeout(&mut stream).await? { match stream.recv_timeout().await? {
Some(ClientMessage::Authenticate(_)) => { Some(ClientMessage::Authenticate(_)) => {
warn!("unexpected authenticate"); warn!("unexpected authenticate");
Ok(()) Ok(())
} }
Some(ClientMessage::Hello(port)) => { Some(ClientMessage::Hello(port)) => {
if port != 0 && port < self.min_port { let listener = match self.create_listener(port).await {
warn!(?port, "client port number too low");
return Ok(());
}
info!(?port, "new client");
let listener = match TcpListener::bind(("::", port)).await {
Ok(listener) => listener, Ok(listener) => listener,
Err(_) => { Err(err) => {
warn!(?port, "could not bind to local port"); stream.send(ServerMessage::Error(err.into())).await?;
send_json(
&mut stream,
ServerMessage::Error("port already in use".into()),
)
.await?;
return Ok(()); return Ok(());
} }
}; };
let host = listener.local_addr()?.ip();
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
send_json(&mut stream, ServerMessage::Hello(port)).await?; info!(?host, ?port, "new client");
stream.send(ServerMessage::Hello(port)).await?;
loop { loop {
if send_json(&mut stream, ServerMessage::Heartbeat) if stream.send(ServerMessage::Heartbeat).await.is_err() {
.await
.is_err()
{
// Assume that the TCP connection has been dropped. // Assume that the TCP connection has been dropped.
return Ok(()); return Ok(());
} }
@@ -123,28 +164,24 @@ impl Server {
warn!(%id, "removed stale connection"); warn!(%id, "removed stale connection");
} }
}); });
send_json(&mut stream, ServerMessage::Connection(id)).await?; stream.send(ServerMessage::Connection(id)).await?;
} }
} }
} }
Some(ClientMessage::Accept(id)) => { Some(ClientMessage::Accept(id)) => {
info!(%id, "forwarding connection"); info!(%id, "forwarding connection");
match self.conns.remove(&id) { match self.conns.remove(&id) {
Some((_, stream2)) => proxy(stream, stream2).await?, Some((_, mut stream2)) => {
let mut parts = stream.into_parts();
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
stream2.write_all(&parts.read_buf).await?;
tokio::io::copy_bidirectional(&mut parts.io, &mut stream2).await?;
}
None => warn!(%id, "missing connection"), None => warn!(%id, "missing connection"),
} }
Ok(()) Ok(())
} }
None => { None => Ok(()),
warn!("unexpected EOF");
Ok(())
}
} }
} }
} }
impl Default for Server {
fn default() -> Self {
Server::new(1024, None)
}
}

View File

@@ -3,16 +3,20 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::de::DeserializeOwned; use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::io::{self, AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncWrite};
use tokio::time::timeout; use tokio::time::timeout;
use tokio_util::codec::{AnyDelimiterCodec, Framed, FramedParts};
use tracing::trace; use tracing::trace;
use uuid::Uuid; use uuid::Uuid;
/// TCP port used for control connections with the server. /// TCP port used for control connections with the server.
pub const CONTROL_PORT: u16 = 7835; 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. /// Timeout for network connections and initial protocol messages.
pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(3); pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(3);
@@ -48,55 +52,48 @@ pub enum ServerMessage {
Error(String), Error(String),
} }
/// Copy data mutually between two read/write streams. /// Transport stream with JSON frames delimited by null characters.
pub async fn proxy<S1, S2>(stream1: S1, stream2: S2) -> io::Result<()> pub struct Delimited<U>(Framed<U, AnyDelimiterCodec>);
where
S1: AsyncRead + AsyncWrite + Unpin,
S2: AsyncRead + AsyncWrite + Unpin,
{
let (mut s1_read, mut s1_write) = io::split(stream1);
let (mut s2_read, mut s2_write) = io::split(stream2);
tokio::select! {
res = io::copy(&mut s1_read, &mut s2_write) => res,
res = io::copy(&mut s2_read, &mut s1_write) => res,
}?;
Ok(())
}
/// Read the next null-delimited JSON instruction from a stream. impl<U: AsyncRead + AsyncWrite + Unpin> Delimited<U> {
pub async fn recv_json<T: DeserializeOwned>( /// Construct a new delimited stream.
reader: &mut (impl AsyncBufRead + Unpin), pub fn new(stream: U) -> Self {
buf: &mut Vec<u8>, let codec = AnyDelimiterCodec::new_with_max_length(vec![0], vec![0], MAX_FRAME_LENGTH);
) -> Result<Option<T>> { 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"); trace!("waiting to receive json message");
buf.clear(); if let Some(next_message) = self.0.next().await {
reader.read_until(0, buf).await?; let byte_message = next_message.context("frame error, invalid byte length")?;
if buf.is_empty() { let serialized_obj =
return Ok(None); serde_json::from_slice(&byte_message).context("unable to parse message")?;
Ok(serialized_obj)
} else {
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. /// 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 /// 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. /// other protocol purposes, where we do not want to wait indefinitely.
pub async fn recv_json_timeout<T: DeserializeOwned>( pub async fn recv_timeout<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
reader: &mut (impl AsyncBufRead + Unpin), timeout(NETWORK_TIMEOUT, self.recv())
) -> Result<Option<T>> {
timeout(NETWORK_TIMEOUT, recv_json(reader, &mut Vec::new()))
.await .await
.context("timed out waiting for initial message")? .context("timed out waiting for initial message")?
} }
/// Send a null-terminated JSON instruction on a stream. /// Send a null-terminated JSON instruction on a stream.
pub async fn send_json<T: Serialize>(writer: &mut (impl AsyncWrite + Unpin), msg: T) -> Result<()> { pub async fn send<T: Serialize>(&mut self, msg: T) -> Result<()> {
trace!("sending json message"); trace!("sending json message");
let msg = serde_json::to_vec(&msg)?; self.0.send(serde_json::to_string(&msg)?).await?;
writer.write_all(&msg).await?;
writer.write_all(&[0]).await?;
Ok(()) Ok(())
}
/// Consume this object, returning current buffers and the inner transport.
pub fn into_parts(self) -> FramedParts<U, AnyDelimiterCodec> {
self.0.into_parts()
}
} }

View File

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

View File

@@ -2,7 +2,7 @@ use std::net::SocketAddr;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Result}; 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 lazy_static::lazy_static;
use rstest::*; use rstest::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -17,7 +17,7 @@ lazy_static! {
/// Spawn the server, giving some time for the control port TcpListener to start. /// Spawn the server, giving some time for the control port TcpListener to start.
async fn spawn_server(secret: Option<&str>) { 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; time::sleep(Duration::from_millis(50)).await;
} }
@@ -26,7 +26,7 @@ async fn spawn_client(secret: Option<&str>) -> Result<(TcpListener, SocketAddr)>
let listener = TcpListener::bind("localhost:0").await?; let listener = TcpListener::bind("localhost:0").await?;
let local_port = listener.local_addr()?.port(); let local_port = listener.local_addr()?.port();
let client = Client::new("localhost", local_port, "localhost", 0, secret).await?; 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()); tokio::spawn(client.listen());
Ok((listener, remote_addr)) Ok((listener, remote_addr))
} }
@@ -84,7 +84,7 @@ async fn mismatched_secret(
async fn invalid_address() -> Result<()> { async fn invalid_address() -> Result<()> {
// We don't need the serial guard for this test because it doesn't create a server. // 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<()> { 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}")), Ok(_) => Err(anyhow!("expected error for {to}, use_secret={use_secret}")),
Err(_) => Ok(()), Err(_) => Ok(()),
} }
@@ -99,3 +99,66 @@ async fn invalid_address() -> Result<()> {
)?; )?;
Ok(()) 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);
}
#[tokio::test]
async fn half_closed_tcp_stream() -> Result<()> {
// Check that "half-closed" TCP streams will not result in spontaneous hangups.
let _guard = SERIAL_GUARD.lock().await;
spawn_server(None).await;
let (listener, addr) = spawn_client(None).await?;
let (mut cli, (mut srv, _)) = tokio::try_join!(TcpStream::connect(addr), listener.accept())?;
// Send data before half-closing one of the streams.
let mut buf = b"message before shutdown".to_vec();
cli.write_all(&buf).await?;
// Only close the write half of the stream. This is a half-closed stream. In the
// TCP protocol, it is represented as a FIN packet on one end. The entire stream
// is only closed after two FINs are exchanged and ACKed by the other end.
cli.shutdown().await?;
srv.read_exact(&mut buf).await?;
assert_eq!(buf, b"message before shutdown");
assert_eq!(srv.read(&mut buf).await?, 0); // EOF
// Now make sure that the other stream can still send data, despite
// half-shutdown on client->server side.
let mut buf = b"hello from the other side!".to_vec();
srv.write_all(&buf).await?;
cli.read_exact(&mut buf).await?;
assert_eq!(buf, b"hello from the other side!");
// We don't have to think about CLOSE_RD handling because that's not really
// part of the TCP protocol, just the POSIX streams API. It is implemented by
// the OS ignoring future packets received on that stream.
Ok(())
}