mirror of
https://github.com/ekzhang/bore.git
synced 2025-12-16 11:57:52 +01:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e059cdaf9 | ||
|
|
7969486d32 | ||
|
|
8ad7ee212b | ||
|
|
19e7da1aad | ||
|
|
0128459a50 | ||
|
|
299ad61030 | ||
|
|
6a71c9a855 | ||
|
|
dd954c98e2 | ||
|
|
53dad89514 | ||
|
|
03f2e53f39 | ||
|
|
e137357267 | ||
|
|
4bdb00c385 | ||
|
|
2a2541e866 | ||
|
|
aa0d6e0ae5 | ||
|
|
b23beb98a2 | ||
|
|
baa42d0f7b | ||
|
|
da86f5bbba | ||
|
|
0546092ce0 | ||
|
|
7fec9700c4 | ||
|
|
a1e1f55a29 | ||
|
|
a6045fb1a7 | ||
|
|
82acea3477 | ||
|
|
1f81f01fe2 | ||
|
|
32a7233c9d | ||
|
|
3ae14209a4 | ||
|
|
f8ccbae378 | ||
|
|
6512ca4770 | ||
|
|
d630b47d38 | ||
|
|
0860c6e018 | ||
|
|
931f2aa20b | ||
|
|
b65481abb0 | ||
|
|
3bf7a665c1 | ||
|
|
d3d5d434ad | ||
|
|
f1ed0b2ecb | ||
|
|
664723cba5 | ||
|
|
fd83d4a207 | ||
|
|
045324d7dc | ||
|
|
a2b8382681 | ||
|
|
9cd43f458a | ||
|
|
e61362915d | ||
|
|
e25f021505 | ||
|
|
cae08bb3c2 | ||
|
|
b045d8028e | ||
|
|
36a56c0d4a | ||
|
|
99fc4f7ddb | ||
|
|
634af3f6af | ||
|
|
f6bd20a508 | ||
|
|
c154a846f6 | ||
|
|
d4e7c42949 | ||
|
|
b0bfd52707 | ||
|
|
526d02d789 | ||
|
|
23db4047ff | ||
|
|
2d0dcf9889 | ||
|
|
c1efefeddf | ||
|
|
e84bd34bd9 | ||
|
|
d5089cab2a | ||
|
|
ebae01417d |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [ekzhang]
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -4,13 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
@@ -25,7 +26,7 @@ jobs:
|
||||
name: Rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
@@ -47,4 +48,4 @@ jobs:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
- run: cargo clippy
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
49
.github/workflows/docker.yml
vendored
Normal file
49
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
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 }}
|
||||
130
.github/workflows/mean_bean_ci.yml
vendored
Normal file
130
.github/workflows/mean_bean_ci.yml
vendored
Normal 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
195
.github/workflows/mean_bean_deploy.yml
vendored
Normal 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
|
||||
884
Cargo.lock
generated
884
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bore-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.6.0"
|
||||
authors = ["Eric Zhang <ekzhang1@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls."
|
||||
@@ -17,11 +17,22 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.56", features = ["backtrace"] }
|
||||
clap = { version = "3.1.8", features = ["derive"] }
|
||||
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"
|
||||
tokio = { version = "1.17.0", features = ["full"] }
|
||||
sha2 = "0.10.2"
|
||||
tokio = { version = "1.17.0", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
|
||||
tokio-util = { version = "0.7.1", features = ["codec"] }
|
||||
tracing = "0.1.32"
|
||||
tracing-subscriber = "0.3.10"
|
||||
uuid = { version = "0.8.2", features = ["serde", "v4"] }
|
||||
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"] }
|
||||
|
||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Eric Zhang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
108
README.md
108
README.md
@@ -1,6 +1,6 @@
|
||||
# bore
|
||||
|
||||
[](https://github.com/ekzhang/bore/actions)
|
||||
[](https://github.com/ekzhang/bore/actions)
|
||||
[](https://crates.io/crates/bore-cli)
|
||||
|
||||
A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls. **That's all it does: no more, and no less.**
|
||||
@@ -8,7 +8,7 @@ A modern, simple TCP tunnel in Rust that exposes local ports to a remote server,
|
||||

|
||||
|
||||
```shell
|
||||
# Installation (requires Rust)
|
||||
# Installation (requires Rust, see alternatives below)
|
||||
cargo install bore-cli
|
||||
|
||||
# On your local machine
|
||||
@@ -19,7 +19,57 @@ This will expose your local port at `localhost:8000` to the public internet at `
|
||||
|
||||
Similar to [localtunnel](https://github.com/localtunnel/localtunnel) and [ngrok](https://ngrok.io/), except `bore` is intended to be a highly efficient, unopinionated tool for forwarding TCP traffic that is simple to install and easy to self-host, with no frills attached.
|
||||
|
||||
(`bore` totals less than 300 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.)
|
||||
(`bore` totals 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 <ARGS>
|
||||
```
|
||||
|
||||
## Detailed Usage
|
||||
|
||||
@@ -33,30 +83,29 @@ You can forward a port on your local machine by using the `bore local` command.
|
||||
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.
|
||||
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
|
||||
bore-local 0.1.0
|
||||
Starts a local proxy to the remote server
|
||||
|
||||
USAGE:
|
||||
bore local [OPTIONS] --to <TO> <LOCAL_PORT>
|
||||
Usage: bore local [OPTIONS] --to <TO> <LOCAL_PORT>
|
||||
|
||||
ARGS:
|
||||
<LOCAL_PORT> The local port to listen on
|
||||
Arguments:
|
||||
<LOCAL_PORT> The local port to expose [env: BORE_LOCAL_PORT=]
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Print help information
|
||||
Options:
|
||||
-l, --local-host <HOST> The local host to expose [default: localhost]
|
||||
-t, --to <TO> Address of the remote server to expose local ports to [env: BORE_SERVER=]
|
||||
-p, --port <PORT> Optional port on the remote server to select [default: 0]
|
||||
-t, --to <TO> Address of the remote server to expose local ports to
|
||||
-V, --version Print version information
|
||||
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
|
||||
-h, --help Print help
|
||||
```
|
||||
|
||||
### Self-Hosting
|
||||
|
||||
As mentioned in the startup instructions, there is an 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:
|
||||
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
|
||||
@@ -64,19 +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.
|
||||
|
||||
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
|
||||
bore-server 0.1.0
|
||||
Runs the remote proxy server
|
||||
|
||||
USAGE:
|
||||
bore server [OPTIONS]
|
||||
Usage: bore server [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Print help information
|
||||
--min-port <MIN_PORT> Minimum TCP port number to accept [default: 1024]
|
||||
-V, --version Print version information
|
||||
Options:
|
||||
--min-port <MIN_PORT> Minimum accepted TCP port number [env: BORE_MIN_PORT=] [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]
|
||||
--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
|
||||
@@ -87,6 +139,20 @@ Whenever the server obtains a connection on the remote port, it generates a secu
|
||||
|
||||
For correctness reasons and to avoid memory leaks, incoming connections are only stored by the server for up to 10 seconds before being discarded if the client does not accept them.
|
||||
|
||||
## Authentication
|
||||
|
||||
On a custom deployment of `bore server`, you can optionally require a _secret_ to prevent the server from being used by others. The protocol requires clients to verify possession of the secret on each TCP connection by answering random challenges in the form of HMAC codes. (This secret is only used for the initial handshake, and no further traffic is encrypted by default.)
|
||||
|
||||
```shell
|
||||
# on the server
|
||||
bore server --secret my_secret_string
|
||||
|
||||
# on the client
|
||||
bore local <LOCAL_PORT> --to <TO> --secret my_secret_string
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
22
ci/build.bash
Executable file
22
ci/build.bash
Executable 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
6
ci/common.bash
Normal 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
4
ci/set_rust_version.bash
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
rustup default $1
|
||||
rustup target add $2
|
||||
32
ci/test.bash
Executable file
32
ci/test.bash
Executable 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
|
||||
79
src/auth.rs
Normal file
79
src/auth.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Auth implementation for bore client and server.
|
||||
|
||||
use anyhow::{bail, ensure, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{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<Sha256>);
|
||||
|
||||
impl Authenticator {
|
||||
/// Generate an authenticator from a secret.
|
||||
pub fn new(secret: &str) -> Self {
|
||||
let hashed_secret = Sha256::new().chain_update(secret).finalize();
|
||||
Self(Hmac::new_from_slice(&hashed_secret).expect("HMAC can take key of any size"))
|
||||
}
|
||||
|
||||
/// Generate a reply message for a challenge.
|
||||
pub fn answer(&self, challenge: &Uuid) -> String {
|
||||
let mut hmac = self.0.clone();
|
||||
hmac.update(challenge.as_bytes());
|
||||
hex::encode(hmac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Validate a reply to a challenge.
|
||||
///
|
||||
/// ```
|
||||
/// use bore_cli::auth::Authenticator;
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let auth = Authenticator::new("secret");
|
||||
/// let challenge = Uuid::new_v4();
|
||||
///
|
||||
/// assert!(auth.validate(&challenge, &auth.answer(&challenge)));
|
||||
/// assert!(!auth.validate(&challenge, "wrong answer"));
|
||||
/// ```
|
||||
pub fn validate(&self, challenge: &Uuid, tag: &str) -> bool {
|
||||
if let Ok(tag) = hex::decode(tag) {
|
||||
let mut hmac = self.0.clone();
|
||||
hmac.update(challenge.as_bytes());
|
||||
hmac.verify_slice(&tag).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// As the server, send a challenge to the client and validate their response.
|
||||
pub async fn server_handshake<T: AsyncRead + AsyncWrite + Unpin>(
|
||||
&self,
|
||||
stream: &mut Delimited<T>,
|
||||
) -> 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<T: AsyncRead + AsyncWrite + Unpin>(
|
||||
&self,
|
||||
stream: &mut Delimited<T>,
|
||||
) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tokio::{io::BufReader, net::TcpStream};
|
||||
use tokio::{io::AsyncWriteExt, net::TcpStream, time::timeout};
|
||||
use tracing::{error, info, info_span, warn, Instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT};
|
||||
use crate::auth::Authenticator;
|
||||
use crate::shared::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT, NETWORK_TIMEOUT};
|
||||
|
||||
/// State structure for the client.
|
||||
pub struct Client {
|
||||
/// Control connection to the server.
|
||||
conn: Option<BufReader<TcpStream>>,
|
||||
conn: Option<Delimited<TcpStream>>,
|
||||
|
||||
/// Destination address of the server.
|
||||
to: String,
|
||||
|
||||
// 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<Authenticator>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Create a new client.
|
||||
pub async fn new(local_port: u16, to: &str, port: u16) -> Result<Self> {
|
||||
let stream = TcpStream::connect((to, CONTROL_PORT)).await?;
|
||||
let mut stream = BufReader::new(stream);
|
||||
pub async fn new(
|
||||
local_host: &str,
|
||||
local_port: u16,
|
||||
to: &str,
|
||||
port: u16,
|
||||
secret: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let mut stream = Delimited::new(connect_with_timeout(to, CONTROL_PORT).await?);
|
||||
let auth = secret.map(Authenticator::new);
|
||||
if let Some(auth) = &auth {
|
||||
auth.client_handshake(&mut stream).await?;
|
||||
}
|
||||
|
||||
send_json(&mut stream, ClientMessage::Hello(port)).await?;
|
||||
let remote_port = match recv_json(&mut stream, &mut Vec::new()).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"),
|
||||
};
|
||||
@@ -43,8 +62,10 @@ impl Client {
|
||||
Ok(Client {
|
||||
conn: Some(stream),
|
||||
to: to.to_string(),
|
||||
local_host: local_host.to_string(),
|
||||
local_port,
|
||||
remote_port,
|
||||
auth,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,11 +78,10 @@ impl Client {
|
||||
pub async fn listen(mut self) -> Result<()> {
|
||||
let mut conn = self.conn.take().unwrap();
|
||||
let this = Arc::new(self);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let msg = recv_json(&mut conn, &mut buf).await?;
|
||||
match msg {
|
||||
match conn.recv().await? {
|
||||
Some(ServerMessage::Hello(_)) => warn!("unexpected hello"),
|
||||
Some(ServerMessage::Challenge(_)) => warn!("unexpected challenge"),
|
||||
Some(ServerMessage::Heartbeat) => (),
|
||||
Some(ServerMessage::Connection(id)) => {
|
||||
let this = Arc::clone(&this);
|
||||
@@ -83,15 +103,25 @@ impl Client {
|
||||
}
|
||||
|
||||
async fn handle_connection(&self, id: Uuid) -> Result<()> {
|
||||
let local_conn = TcpStream::connect(("localhost", self.local_port))
|
||||
.await
|
||||
.context("failed TCP connection to local port")?;
|
||||
let mut remote_conn = TcpStream::connect((&self.to[..], CONTROL_PORT))
|
||||
.await
|
||||
.context("failed TCP connection to remote port")?;
|
||||
|
||||
send_json(&mut remote_conn, ClientMessage::Accept(id)).await?;
|
||||
proxy(local_conn, remote_conn).await?;
|
||||
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<TcpStream> {
|
||||
match timeout(NETWORK_TIMEOUT, TcpStream::connect((to, port))).await {
|
||||
Ok(res) => res,
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
.with_context(|| format!("could not connect to {to}:{port}"))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod server;
|
||||
pub mod shared;
|
||||
|
||||
73
src/main.rs
73
src/main.rs
@@ -1,10 +1,11 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use bore_cli::{client::Client, server::Server};
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about)]
|
||||
#[clap(propagate_version = true)]
|
||||
struct Args {
|
||||
#[clap(subcommand)]
|
||||
command: Command,
|
||||
@@ -14,44 +15,88 @@ struct Args {
|
||||
enum Command {
|
||||
/// Starts a local proxy to the remote server.
|
||||
Local {
|
||||
/// The local port to listen on.
|
||||
/// 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)]
|
||||
#[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<String>,
|
||||
},
|
||||
|
||||
/// Runs the remote proxy server.
|
||||
Server {
|
||||
/// Minimum TCP port number to accept.
|
||||
#[clap(long, default_value_t = 1024)]
|
||||
/// 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<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>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args = Args::parse();
|
||||
match args.command {
|
||||
async fn run(command: Command) -> Result<()> {
|
||||
match command {
|
||||
Command::Local {
|
||||
local_host,
|
||||
local_port,
|
||||
to,
|
||||
port,
|
||||
secret,
|
||||
} => {
|
||||
let client = Client::new(local_port, &to, port).await?;
|
||||
let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?;
|
||||
client.listen().await?;
|
||||
}
|
||||
Command::Server { min_port } => {
|
||||
Server::new(min_port).listen().await?;
|
||||
Command::Server {
|
||||
min_port,
|
||||
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)
|
||||
}
|
||||
|
||||
147
src/server.rs
147
src/server.rs
@@ -1,43 +1,65 @@
|
||||
//! Server implementation for the `bore` service.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::{io, ops::RangeInclusive, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::time::{sleep, timeout};
|
||||
use tracing::{info, info_span, warn, Instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::shared::{proxy, recv_json, send_json, ClientMessage, ServerMessage, CONTROL_PORT};
|
||||
use crate::auth::Authenticator;
|
||||
use crate::shared::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT};
|
||||
|
||||
/// State structure for the server.
|
||||
pub struct Server {
|
||||
/// The minimum TCP port that can be forwarded.
|
||||
min_port: u16,
|
||||
/// Range of TCP ports that can be forwarded.
|
||||
port_range: RangeInclusive<u16>,
|
||||
|
||||
/// Optional secret used to authenticate clients.
|
||||
auth: Option<Authenticator>,
|
||||
|
||||
/// Concurrent map of IDs to incoming connections.
|
||||
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 {
|
||||
/// Create a new server with a specified minimum port number.
|
||||
pub fn new(min_port: u16) -> Self {
|
||||
pub fn new(port_range: RangeInclusive<u16>, secret: Option<&str>) -> Self {
|
||||
assert!(!port_range.is_empty(), "must provide at least one port");
|
||||
Server {
|
||||
min_port,
|
||||
port_range,
|
||||
conns: Arc::new(DashMap::new()),
|
||||
auth: secret.map(Authenticator::new),
|
||||
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 addr = SocketAddr::from(([0, 0, 0, 0], CONTROL_PORT));
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
info!(?addr, "server listening");
|
||||
let listener = TcpListener::bind((this.bind_addr, CONTROL_PORT)).await?;
|
||||
info!(addr = ?this.bind_addr, "server listening");
|
||||
|
||||
loop {
|
||||
let (stream, addr) = listener.accept().await?;
|
||||
@@ -56,39 +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<()> {
|
||||
let mut stream = BufReader::new(stream);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let msg = recv_json(&mut stream, &mut buf).await?;
|
||||
|
||||
match msg {
|
||||
Some(ClientMessage::Hello(port)) => {
|
||||
if port != 0 && port < self.min_port {
|
||||
warn!(?port, "client port number too low");
|
||||
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(());
|
||||
}
|
||||
info!(?port, "new client");
|
||||
let listener = match TcpListener::bind(("::", port)).await {
|
||||
}
|
||||
|
||||
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(_) => {
|
||||
warn!(?port, "could not bind to local port");
|
||||
send_json(
|
||||
&mut stream,
|
||||
ServerMessage::Error("port already in use".into()),
|
||||
)
|
||||
.await?;
|
||||
Err(err) => {
|
||||
stream.send(ServerMessage::Error(err.into())).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let host = listener.local_addr()?.ip();
|
||||
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 {
|
||||
if send_json(&mut stream, ServerMessage::Heartbeat)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if stream.send(ServerMessage::Heartbeat).await.is_err() {
|
||||
// Assume that the TCP connection has been dropped.
|
||||
return Ok(());
|
||||
}
|
||||
@@ -99,6 +155,7 @@ impl Server {
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let conns = Arc::clone(&self.conns);
|
||||
|
||||
conns.insert(id, stream2);
|
||||
tokio::spawn(async move {
|
||||
// Remove stale entries to avoid memory leaks.
|
||||
@@ -107,28 +164,24 @@ impl Server {
|
||||
warn!(%id, "removed stale connection");
|
||||
}
|
||||
});
|
||||
send_json(&mut stream, ServerMessage::Connection(id)).await?;
|
||||
stream.send(ServerMessage::Connection(id)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ClientMessage::Accept(id)) => {
|
||||
info!(%id, "forwarding connection");
|
||||
match self.conns.remove(&id) {
|
||||
Some((_, stream2)) => proxy(stream, stream2).await?,
|
||||
Some((_, mut stream2)) => {
|
||||
let 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 => {
|
||||
warn!("unexpected EOF");
|
||||
Ok(())
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Server {
|
||||
fn default() -> Self {
|
||||
Server::new(1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
//! Shared data structures, utilities, and protocol definitions.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{self, AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt};
|
||||
use 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(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
/// Response to an authentication challenge from the server.
|
||||
Authenticate(String),
|
||||
|
||||
/// Initial client message specifying a port to forward.
|
||||
Hello(u16),
|
||||
|
||||
@@ -20,8 +34,11 @@ pub enum ClientMessage {
|
||||
}
|
||||
|
||||
/// A message from the server on the control connection.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum ServerMessage {
|
||||
/// Authentication challenge, sent as the first message, if enabled.
|
||||
Challenge(Uuid),
|
||||
|
||||
/// Response to a client's initial message, with actual public port.
|
||||
Hello(u16),
|
||||
|
||||
@@ -35,41 +52,48 @@ pub enum ServerMessage {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Copy data mutually between two read/write streams.
|
||||
pub async fn proxy<S1, S2>(stream1: S1, stream2: S2) -> io::Result<()>
|
||||
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::try_join!(
|
||||
io::copy(&mut s1_read, &mut s2_write),
|
||||
io::copy(&mut s2_read, &mut s1_write),
|
||||
)?;
|
||||
Ok(())
|
||||
/// Transport stream with JSON frames delimited by null characters.
|
||||
pub struct Delimited<U>(Framed<U, AnyDelimiterCodec>);
|
||||
|
||||
impl<U: AsyncRead + AsyncWrite + Unpin> Delimited<U> {
|
||||
/// Construct a new delimited stream.
|
||||
pub fn new(stream: U) -> Self {
|
||||
let codec = AnyDelimiterCodec::new_with_max_length(vec![0], vec![0], MAX_FRAME_LENGTH);
|
||||
Self(Framed::new(stream, codec))
|
||||
}
|
||||
|
||||
/// Read the next null-delimited JSON instruction from a stream.
|
||||
pub async fn recv_json<T: DeserializeOwned>(
|
||||
reader: &mut (impl AsyncBufRead + Unpin),
|
||||
buf: &mut Vec<u8>,
|
||||
) -> Result<Option<T>> {
|
||||
buf.clear();
|
||||
reader.read_until(0, buf).await?;
|
||||
if buf.is_empty() {
|
||||
return Ok(None);
|
||||
pub async fn recv<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
trace!("waiting to receive json message");
|
||||
if let Some(next_message) = self.0.next().await {
|
||||
let byte_message = next_message.context("frame error, invalid byte length")?;
|
||||
let serialized_obj =
|
||||
serde_json::from_slice(&byte_message).context("unable to parse message")?;
|
||||
Ok(serialized_obj)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
if buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
Ok(serde_json::from_slice(buf).context("failed to parse JSON")?)
|
||||
|
||||
/// Read the next null-delimited JSON instruction, with a default timeout.
|
||||
///
|
||||
/// This is useful for parsing the initial message of a stream for handshake or
|
||||
/// other protocol purposes, where we do not want to wait indefinitely.
|
||||
pub async fn recv_timeout<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
timeout(NETWORK_TIMEOUT, self.recv())
|
||||
.await
|
||||
.context("timed out waiting for initial message")?
|
||||
}
|
||||
|
||||
/// Send a null-terminated JSON instruction on a stream.
|
||||
pub async fn send_json<T: Serialize>(writer: &mut (impl AsyncWrite + Unpin), msg: T) -> Result<()> {
|
||||
let msg = serde_json::to_vec(&msg)?;
|
||||
writer.write_all(&msg).await?;
|
||||
writer.write_all(&[0]).await?;
|
||||
pub async fn send<T: Serialize>(&mut self, msg: T) -> Result<()> {
|
||||
trace!("sending json message");
|
||||
self.0.send(serde_json::to_string(&msg)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume this object, returning current buffers and the inner transport.
|
||||
pub fn into_parts(self) -> FramedParts<U, AnyDelimiterCodec> {
|
||||
self.0.into_parts()
|
||||
}
|
||||
}
|
||||
|
||||
35
tests/auth_test.rs
Normal file
35
tests/auth_test.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
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());
|
||||
}
|
||||
164
tests/e2e_test.rs
Normal file
164
tests/e2e_test.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user