Repository: ekzhang/bore Branch: main Commit: 00a735a89917 Files: 23 Total size: 50.5 KB Directory structure: gitextract_o1_hc2gb/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── ci.yml │ ├── docker.yml │ ├── mean_bean_ci.yml │ └── mean_bean_deploy.yml ├── .gitignore ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── ci/ │ ├── build.bash │ ├── common.bash │ ├── set_rust_version.bash │ └── test.bash ├── src/ │ ├── auth.rs │ ├── client.rs │ ├── lib.rs │ ├── main.rs │ ├── server.rs │ └── shared.rs └── tests/ ├── auth_test.rs └── e2e_test.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /target ================================================ FILE: .github/FUNDING.yml ================================================ github: [ekzhang] ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: jobs: rust: name: Build and Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable - run: cargo build --all-features - run: cargo test rustfmt: name: Rustfmt runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable components: rustfmt - run: cargo fmt -- --check clippy: name: Clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable components: clippy - run: cargo clippy -- -D warnings ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker on: push: tags: - "v*.*.*" jobs: build_deploy: name: Build and Deploy runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Docker meta id: meta uses: docker/metadata-action@v4 with: images: ekzhang/bore tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - name: Set up QEMU uses: docker/setup-qemu-action@v2 with: platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Login to DockerHub 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@v3 with: platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} ================================================ FILE: .github/workflows/mean_bean_ci.yml ================================================ 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 ================================================ FILE: .github/workflows/mean_bean_deploy.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: Cargo.toml ================================================ [package] name = "bore-cli" version = "0.6.0" authors = ["Eric Zhang "] license = "MIT" description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls." repository = "https://github.com/ekzhang/bore" documentation = "https://docs.rs/bore-cli" keywords = ["network", "cli", "tunnel", "tcp"] categories = ["network-programming", "web-programming", "command-line-utilities"] readme = "README.md" edition = "2021" [[bin]] name = "bore" path = "src/main.rs" [dependencies] anyhow = { version = "1.0.56", features = ["backtrace"] } clap = { version = "4.0.22", features = ["derive", "env"] } dashmap = "5.2.0" fastrand = "1.9.0" futures-util = { version = "0.3.21", features = ["sink"] } hex = "0.4.3" hmac = "0.12.1" 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.18" uuid = { version = "1.2.1", features = ["serde", "v4"] } [dev-dependencies] lazy_static = "1.4.0" rstest = "0.15.0" tokio = { version = "1.17.0", features = ["sync"] } ================================================ FILE: Dockerfile ================================================ FROM rust:alpine AS builder WORKDIR /home/rust/src RUN apk --no-cache add musl-dev COPY . . RUN cargo install --path . FROM scratch COPY --from=builder /usr/local/cargo/bin/bore . USER 1000:1000 ENTRYPOINT ["./bore"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Eric Zhang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # bore [![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.** ![Video demo](https://i.imgur.com/vDeGsmx.gif) ```shell # Installation (requires Rust, see alternatives below) cargo install bore-cli # On your local machine bore local 8000 --to bore.pub ``` This will expose your local port at `localhost:8000` to the public internet at `bore.pub:`, where the port number is assigned randomly. 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 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 ### 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 cargo install bore-cli ``` ### 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 docker run -it --init --rm --network host ekzhang/bore ``` ## Detailed Usage This section describes detailed usage for the `bore` CLI command. ### Local Forwarding You can forward a port on your local machine by using the `bore local` command. This takes a positional argument, the local port to forward, as well as a mandatory `--to` option, which specifies the address of the remote server. ```shell bore local 5000 --to bore.pub ``` You can optionally pass in a `--port` option to pick a specific port on the remote to expose, although the command will fail if this port is not available. Also, passing `--local-host` allows you to expose a different host on your local area network besides the loopback address `localhost`. The full options are shown below. ```shell Starts a local proxy to the remote server Usage: bore local [OPTIONS] --to Arguments: The local port to expose [env: BORE_LOCAL_PORT=] Options: -l, --local-host The local host to expose [default: localhost] -t, --to Address of the remote server to expose local ports to [env: BORE_SERVER=] -p, --port Optional port on the remote server to select [default: 0] -s, --secret Optional secret for authentication [env: BORE_SECRET] -h, --help Print help ``` ### Self-Hosting As mentioned in the startup instructions, there is a public instance of the `bore` server running at `bore.pub`. However, if you want to self-host `bore` on your own network, you can do so with the following command: ```shell 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
` 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. ```shell Runs the remote proxy server Usage: bore server [OPTIONS] Options: --min-port Minimum accepted TCP port number [env: BORE_MIN_PORT=] [default: 1024] --max-port Maximum accepted TCP port number [env: BORE_MAX_PORT=] [default: 65535] -s, --secret Optional secret for authentication [env: BORE_SECRET] --bind-addr IP address to bind to, clients must reach this [default: 0.0.0.0] --bind-tunnels IP address where tunnels will listen on, defaults to --bind-addr -h, --help Print help ``` ## Protocol There is an implicit _control port_ at `7835`, used for creating new connections on demand. At initialization, the client sends a "Hello" message to the server on the TCP control port, asking to proxy a selected remote port. The server then responds with an acknowledgement and begins listening for external TCP connections. Whenever the server obtains a connection on the remote port, it generates a secure [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) for that connection and sends it back to the client. The client then opens a separate TCP stream to the server and sends an "Accept" message containing the UUID on that stream. The server then proxies the two connections between each other. For correctness reasons and to avoid memory leaks, incoming connections are only stored by the server for up to 10 seconds before being discarded if the client does not accept them. ## Authentication On a custom deployment of `bore server`, you can optionally require a _secret_ to prevent the server from being used by others. The protocol requires clients to verify possession of the secret on each TCP connection by answering random challenges in the form of HMAC codes. (This secret is only used for the initial handshake, and no further traffic is encrypted by default.) ```shell # on the server bore server --secret my_secret_string # on the client bore local --to --secret my_secret_string ``` If a secret is not present in the arguments, `bore` will also attempt to read from the `BORE_SECRET` environment variable. ## Acknowledgements Created by Eric Zhang ([@ekzhang1](https://twitter.com/ekzhang1)). Licensed under the [MIT license](LICENSE). The author would like to thank the contributors and maintainers of the [Tokio](https://tokio.rs/) project for making it possible to write ergonomic and efficient network services in Rust. ================================================ FILE: ci/build.bash ================================================ #!/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} = 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 '' 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 ================================================ FILE: ci/common.bash ================================================ required_arg() { if [ -z "$1" ]; then echo "Required argument $2 missing" exit 1 fi } ================================================ FILE: ci/set_rust_version.bash ================================================ #!/usr/bin/env bash set -e rustup default $1 rustup target add $2 ================================================ FILE: ci/test.bash ================================================ #!/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=$2 required_arg $CROSS 'CROSS' required_arg $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 ================================================ FILE: src/auth.rs ================================================ //! Auth implementation for bore client and server. use anyhow::{bail, ensure, Result}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncWrite}; use uuid::Uuid; use crate::shared::{ClientMessage, Delimited, ServerMessage}; /// Wrapper around a MAC used for authenticating clients that have a secret. pub struct Authenticator(Hmac); impl Authenticator { /// Generate an authenticator from a secret. pub fn new(secret: &str) -> Self { let hashed_secret = Sha256::new().chain_update(secret).finalize(); Self(Hmac::new_from_slice(&hashed_secret).expect("HMAC can take key of any size")) } /// Generate a reply message for a challenge. pub fn answer(&self, challenge: &Uuid) -> String { let mut hmac = self.0.clone(); hmac.update(challenge.as_bytes()); hex::encode(hmac.finalize().into_bytes()) } /// Validate a reply to a challenge. /// /// ``` /// use bore_cli::auth::Authenticator; /// use uuid::Uuid; /// /// let auth = Authenticator::new("secret"); /// let challenge = Uuid::new_v4(); /// /// assert!(auth.validate(&challenge, &auth.answer(&challenge))); /// assert!(!auth.validate(&challenge, "wrong answer")); /// ``` pub fn validate(&self, challenge: &Uuid, tag: &str) -> bool { if let Ok(tag) = hex::decode(tag) { let mut hmac = self.0.clone(); hmac.update(challenge.as_bytes()); hmac.verify_slice(&tag).is_ok() } else { false } } /// As the server, send a challenge to the client and validate their response. pub async fn server_handshake( &self, stream: &mut Delimited, ) -> Result<()> { let challenge = Uuid::new_v4(); stream.send(ServerMessage::Challenge(challenge)).await?; match stream.recv_timeout().await? { Some(ClientMessage::Authenticate(tag)) => { ensure!(self.validate(&challenge, &tag), "invalid secret"); Ok(()) } _ => bail!("server requires secret, but no secret was provided"), } } /// As the client, answer a challenge to attempt to authenticate with the server. pub async fn client_handshake( &self, stream: &mut Delimited, ) -> Result<()> { 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); stream.send(ClientMessage::Authenticate(tag)).await?; Ok(()) } } ================================================ FILE: src/client.rs ================================================ //! Client implementation for the `bore` service. use std::sync::Arc; use anyhow::{bail, Context, Result}; 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::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT, NETWORK_TIMEOUT}; /// State structure for the client. pub struct Client { /// Control connection to the server. conn: Option>, /// Destination address of the server. to: String, // Local host that is forwarded. local_host: String, /// Local port that is forwarded. local_port: u16, /// Port that is publicly available on the remote. remote_port: u16, /// Optional secret used to authenticate clients. auth: Option, } impl Client { /// Create a new client. pub async fn new( local_host: &str, local_port: u16, to: &str, port: u16, secret: Option<&str>, ) -> Result { 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?; } 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(_)) => { bail!("server requires authentication, but no client secret was provided"); } Some(_) => bail!("unexpected initial non-hello message"), None => bail!("unexpected EOF"), }; info!(remote_port, "connected to server"); info!("listening at {to}:{remote_port}"); Ok(Client { conn: Some(stream), to: to.to_string(), local_host: local_host.to_string(), local_port, remote_port, auth, }) } /// Returns the port publicly available on the remote. pub fn remote_port(&self) -> u16 { self.remote_port } /// Start the client, listening for new connections. pub async fn listen(mut self) -> Result<()> { let mut conn = self.conn.take().unwrap(); let this = Arc::new(self); loop { match conn.recv().await? { Some(ServerMessage::Hello(_)) => warn!("unexpected hello"), Some(ServerMessage::Challenge(_)) => warn!("unexpected challenge"), Some(ServerMessage::Heartbeat) => (), Some(ServerMessage::Connection(id)) => { let this = Arc::clone(&this); tokio::spawn( async move { info!("new connection"); match this.handle_connection(id).await { Ok(_) => info!("connection exited"), Err(err) => warn!(%err, "connection exited with error"), } } .instrument(info_span!("proxy", %id)), ); } Some(ServerMessage::Error(err)) => error!(%err, "server error"), None => return Ok(()), } } } async fn handle_connection(&self, id: Uuid) -> Result<()> { let mut remote_conn = Delimited::new(connect_with_timeout(&self.to[..], CONTROL_PORT).await?); if let Some(auth) = &self.auth { auth.client_handshake(&mut 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 mut 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 tokio::io::copy_bidirectional(&mut local_conn, &mut parts.io).await?; Ok(()) } } async fn connect_with_timeout(to: &str, port: u16) -> Result { match timeout(NETWORK_TIMEOUT, TcpStream::connect((to, port))).await { Ok(res) => res, Err(err) => Err(err.into()), } .with_context(|| format!("could not connect to {to}:{port}")) } ================================================ FILE: src/lib.rs ================================================ //! A modern, simple TCP tunnel in Rust that exposes local ports to a remote //! server, bypassing standard NAT connection firewalls. //! //! This is the library crate documentation. If you're looking for usage //! information about the binary, see the command below. //! //! ```shell //! $ bore help //! ``` //! //! There are two components to the crate, offering implementations of the //! server network daemon and client local forwarding proxy. Both are public //! members and can be run programmatically with a Tokio 1.0 runtime. #![forbid(unsafe_code)] #![warn(missing_docs)] pub mod auth; pub mod client; pub mod server; pub mod shared; ================================================ FILE: src/main.rs ================================================ use std::net::IpAddr; use anyhow::Result; use bore_cli::{client::Client, server::Server}; use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand}; #[derive(Parser, Debug)] #[clap(author, version, about)] struct Args { #[clap(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { /// Starts a local proxy to the remote server. Local { /// The local port to expose. #[clap(env = "BORE_LOCAL_PORT")] local_port: u16, /// The local host to expose. #[clap(short, long, value_name = "HOST", default_value = "localhost")] local_host: String, /// Address of the remote server to expose local ports to. #[clap(short, long, env = "BORE_SERVER")] to: String, /// Optional port on the remote server to select. #[clap(short, long, default_value_t = 0)] port: u16, /// Optional secret for authentication. #[clap(short, long, env = "BORE_SECRET", hide_env_values = true)] secret: Option, }, /// Runs the remote proxy server. Server { /// Minimum accepted TCP port number. #[clap(long, default_value_t = 1024, env = "BORE_MIN_PORT")] 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. #[clap(short, long, env = "BORE_SECRET", hide_env_values = true)] secret: Option, /// 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, }, } #[tokio::main] async fn run(command: Command) -> Result<()> { match command { Command::Local { local_host, local_port, to, port, secret, } => { let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?; client.listen().await?; } Command::Server { 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?; } } Ok(()) } fn main() -> Result<()> { tracing_subscriber::fmt::init(); run(Args::parse().command) } ================================================ FILE: src/server.rs ================================================ //! Server implementation for the `bore` service. use std::net::{IpAddr, Ipv4Addr}; use std::{io, ops::RangeInclusive, sync::Arc, time::Duration}; use anyhow::Result; use dashmap::DashMap; 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::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT}; /// State structure for the server. pub struct Server { /// Range of TCP ports that can be forwarded. port_range: RangeInclusive, /// Optional secret used to authenticate clients. auth: Option, /// Concurrent map of IDs to incoming connections. conns: Arc>, /// IP address where the control server will bind to. bind_addr: IpAddr, /// IP address where tunnels will listen on. bind_tunnels: IpAddr, } impl Server { /// Create a new server with a specified minimum port number. pub fn new(port_range: RangeInclusive, secret: Option<&str>) -> Self { assert!(!port_range.is_empty(), "must provide at least one port"); Server { port_range, conns: Arc::new(DashMap::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. pub async fn listen(self) -> Result<()> { let this = Arc::new(self); let listener = TcpListener::bind((this.bind_addr, CONTROL_PORT)).await?; info!(addr = ?this.bind_addr, "server listening"); loop { let (stream, addr) = listener.accept().await?; let this = Arc::clone(&this); tokio::spawn( async move { info!("incoming connection"); if let Err(err) = this.handle_connection(stream).await { warn!(%err, "connection exited with error"); } else { info!("connection exited"); } } .instrument(info_span!("control", ?addr)), ); } } async fn create_listener(&self, port: u16) -> Result { 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<()> { 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"); stream.send(ServerMessage::Error(err.to_string())).await?; return Ok(()); } } match stream.recv_timeout().await? { Some(ClientMessage::Authenticate(_)) => { warn!("unexpected authenticate"); Ok(()) } Some(ClientMessage::Hello(port)) => { let listener = match self.create_listener(port).await { Ok(listener) => listener, Err(err) => { stream.send(ServerMessage::Error(err.into())).await?; return Ok(()); } }; let host = listener.local_addr()?.ip(); let port = listener.local_addr()?.port(); info!(?host, ?port, "new client"); stream.send(ServerMessage::Hello(port)).await?; loop { if stream.send(ServerMessage::Heartbeat).await.is_err() { // Assume that the TCP connection has been dropped. return Ok(()); } const TIMEOUT: Duration = Duration::from_millis(500); if let Ok(result) = timeout(TIMEOUT, listener.accept()).await { let (stream2, addr) = result?; info!(?addr, ?port, "new connection"); let id = Uuid::new_v4(); let conns = Arc::clone(&self.conns); conns.insert(id, stream2); tokio::spawn(async move { // Remove stale entries to avoid memory leaks. sleep(Duration::from_secs(10)).await; if conns.remove(&id).is_some() { warn!(%id, "removed stale connection"); } }); stream.send(ServerMessage::Connection(id)).await?; } } } Some(ClientMessage::Accept(id)) => { info!(%id, "forwarding connection"); match self.conns.remove(&id) { 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"), } Ok(()) } None => Ok(()), } } } ================================================ FILE: src/shared.rs ================================================ //! Shared data structures, utilities, and protocol definitions. use std::time::Duration; use anyhow::{Context, Result}; use futures_util::{SinkExt, StreamExt}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::io::{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); /// A message from the client on the control connection. #[derive(Debug, Serialize, Deserialize)] pub enum ClientMessage { /// Response to an authentication challenge from the server. Authenticate(String), /// Initial client message specifying a port to forward. Hello(u16), /// Accepts an incoming TCP connection, using this stream as a proxy. Accept(Uuid), } /// A message from the server on the control connection. #[derive(Debug, Serialize, Deserialize)] pub enum ServerMessage { /// Authentication challenge, sent as the first message, if enabled. Challenge(Uuid), /// Response to a client's initial message, with actual public port. Hello(u16), /// No-op used to test if the client is still reachable. Heartbeat, /// Asks the client to accept a forwarded TCP connection. Connection(Uuid), /// Indicates a server error that terminates the connection. Error(String), } /// Transport stream with JSON frames delimited by null characters. pub struct Delimited(Framed); impl Delimited { /// 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(&mut self) -> Result> { 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(&mut self) -> Result> { 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(&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 { self.0.into_parts() } } ================================================ FILE: tests/auth_test.rs ================================================ use anyhow::Result; 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 = Delimited::new(client); let mut server = Delimited::new(server); tokio::try_join!( auth.client_handshake(&mut client), auth.server_handshake(&mut server), )?; Ok(()) } #[tokio::test] async fn auth_handshake_fail() { let auth = Authenticator::new("client secret"); let auth2 = Authenticator::new("different server secret"); let (client, server) = io::duplex(8); // Ensure correctness with limited capacity. let mut client = Delimited::new(client); let mut server = Delimited::new(server); let result = tokio::try_join!( auth.client_handshake(&mut client), auth2.server_handshake(&mut server), ); assert!(result.is_err()); } ================================================ FILE: tests/e2e_test.rs ================================================ use std::net::SocketAddr; use std::time::Duration; use anyhow::{anyhow, Result}; use bore_cli::{client::Client, server::Server, shared::CONTROL_PORT}; use lazy_static::lazy_static; use rstest::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; use tokio::time; lazy_static! { /// Guard to make sure that tests are run serially, not concurrently. static ref SERIAL_GUARD: Mutex<()> = Mutex::new(()); } /// Spawn the server, giving some time for the control port TcpListener to start. async fn spawn_server(secret: Option<&str>) { tokio::spawn(Server::new(1024..=65535, secret).listen()); time::sleep(Duration::from_millis(50)).await; } /// Spawns a client with randomly assigned ports, returning the listener and remote address. async fn spawn_client(secret: Option<&str>) -> Result<(TcpListener, SocketAddr)> { let listener = TcpListener::bind("localhost:0").await?; let local_port = listener.local_addr()?.port(); let client = Client::new("localhost", local_port, "localhost", 0, secret).await?; let remote_addr = ([127, 0, 0, 1], client.remote_port()).into(); tokio::spawn(client.listen()); Ok((listener, remote_addr)) } #[rstest] #[tokio::test] async fn basic_proxy(#[values(None, Some(""), Some("abc"))] secret: Option<&str>) -> Result<()> { let _guard = SERIAL_GUARD.lock().await; spawn_server(secret).await; let (listener, addr) = spawn_client(secret).await?; tokio::spawn(async move { let (mut stream, _) = listener.accept().await?; let mut buf = [0u8; 11]; stream.read_exact(&mut buf).await?; assert_eq!(&buf, b"hello world"); stream.write_all(b"I can send a message too!").await?; anyhow::Ok(()) }); let mut stream = TcpStream::connect(addr).await?; stream.write_all(b"hello world").await?; let mut buf = [0u8; 25]; stream.read_exact(&mut buf).await?; assert_eq!(&buf, b"I can send a message too!"); // Ensure that the client end of the stream is closed now. assert_eq!(stream.read(&mut buf).await?, 0); // Also ensure that additional connections do not produce any data. let mut stream = TcpStream::connect(addr).await?; assert_eq!(stream.read(&mut buf).await?, 0); Ok(()) } #[rstest] #[case(None, Some("my secret"))] #[case(Some("my secret"), None)] #[tokio::test] async fn mismatched_secret( #[case] server_secret: Option<&str>, #[case] client_secret: Option<&str>, ) { let _guard = SERIAL_GUARD.lock().await; spawn_server(server_secret).await; assert!(spawn_client(client_secret).await.is_err()); } #[tokio::test] async fn invalid_address() -> Result<()> { // We don't need the serial guard for this test because it doesn't create a server. async fn check_address(to: &str, use_secret: bool) -> Result<()> { match Client::new("localhost", 5000, to, 0, use_secret.then_some("a secret")).await { Ok(_) => Err(anyhow!("expected error for {to}, use_secret={use_secret}")), Err(_) => Ok(()), } } tokio::try_join!( check_address("google.com", false), check_address("google.com", true), check_address("nonexistent.domain.for.demonstration", false), check_address("nonexistent.domain.for.demonstration", true), check_address("malformed !$uri$%", false), check_address("malformed !$uri$%", true), )?; Ok(()) } #[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(()) }