mirror of
https://github.com/ekzhang/bore.git
synced 2025-12-16 20:07:51 +01:00
Compare commits
31 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 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -1,13 +1,17 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Build and Test
|
name: Build and Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions-rs/toolchain@v1
|
- uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
@@ -22,7 +26,7 @@ jobs:
|
|||||||
name: Rustfmt
|
name: Rustfmt
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions-rs/toolchain@v1
|
- uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
@@ -36,7 +40,7 @@ jobs:
|
|||||||
name: Clippy
|
name: Clippy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions-rs/toolchain@v1
|
- uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
12
.github/workflows/docker.yml
vendored
12
.github/workflows/docker.yml
vendored
@@ -11,11 +11,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: ekzhang/bore
|
images: ekzhang/bore
|
||||||
tags: |
|
tags: |
|
||||||
@@ -23,22 +23,22 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
with:
|
with:
|
||||||
platforms: arm64
|
platforms: arm64
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
23
.github/workflows/mean_bean_ci.yml
vendored
23
.github/workflows/mean_bean_ci.yml
vendored
@@ -1,12 +1,16 @@
|
|||||||
name: Mean Bean CI
|
name: Mean Bean CI
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-cross:
|
install-cross:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
|
|
||||||
@@ -18,7 +22,7 @@ jobs:
|
|||||||
matches: ${{ matrix.platform }}
|
matches: ${{ matrix.platform }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v1
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cross-${{ matrix.platform }}
|
name: cross-${{ matrix.platform }}
|
||||||
path: ${{ steps.cross.outputs.install_path }}
|
path: ${{ steps.cross.outputs.install_path }}
|
||||||
@@ -33,11 +37,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
channel: [stable]
|
channel: [stable]
|
||||||
target:
|
target:
|
||||||
- x86_64-apple-darwin
|
- aarch64-apple-darwin
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Setup | Checkout
|
- name: Setup | Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Setup | Rust
|
- name: Setup | Rust
|
||||||
uses: actions-rs/toolchain@v1
|
uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
@@ -57,12 +61,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: install-cross
|
needs: install-cross
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
|
|
||||||
- name: Download Cross
|
- name: Download Cross
|
||||||
uses: actions/download-artifact@v1
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cross-linux-musl
|
name: cross-linux-musl
|
||||||
path: /tmp/
|
path: /tmp/
|
||||||
@@ -85,8 +89,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
channel: [stable]
|
channel: [stable]
|
||||||
target:
|
target:
|
||||||
|
- aarch64-unknown-linux-musl
|
||||||
|
- arm-unknown-linux-musleabi
|
||||||
- arm-unknown-linux-gnueabi
|
- arm-unknown-linux-gnueabi
|
||||||
- armv7-unknown-linux-gnueabihf
|
- armv7-unknown-linux-gnueabihf
|
||||||
|
- armv7-unknown-linux-musleabihf
|
||||||
- i686-unknown-linux-musl
|
- i686-unknown-linux-musl
|
||||||
- x86_64-unknown-linux-musl
|
- x86_64-unknown-linux-musl
|
||||||
|
|
||||||
@@ -98,7 +105,7 @@ jobs:
|
|||||||
# artifacts are downloaded first.
|
# artifacts are downloaded first.
|
||||||
needs: install-cross
|
needs: install-cross
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }}
|
- run: ci/set_rust_version.bash ${{ matrix.channel }} ${{ matrix.target }}
|
||||||
|
|||||||
17
.github/workflows/mean_bean_deploy.yml
vendored
17
.github/workflows/mean_bean_deploy.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
install-cross:
|
install-cross:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
matches: ${{ matrix.platform }}
|
matches: ${{ matrix.platform }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v1
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cross-${{ matrix.platform }}
|
name: cross-${{ matrix.platform }}
|
||||||
path: ${{ steps.cross.outputs.install_path }}
|
path: ${{ steps.cross.outputs.install_path }}
|
||||||
@@ -50,11 +50,11 @@ jobs:
|
|||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
- name: Setup | Checkout
|
- name: Setup | Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Cache files between builds
|
# Cache files between builds
|
||||||
- name: Setup | Cache Cargo
|
- name: Setup | Cache Cargo
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -102,8 +102,11 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
target:
|
target:
|
||||||
|
- aarch64-unknown-linux-musl
|
||||||
|
- arm-unknown-linux-musleabi
|
||||||
- arm-unknown-linux-gnueabi
|
- arm-unknown-linux-gnueabi
|
||||||
- armv7-unknown-linux-gnueabihf
|
- armv7-unknown-linux-gnueabihf
|
||||||
|
- armv7-unknown-linux-musleabihf
|
||||||
- i686-unknown-linux-musl
|
- i686-unknown-linux-musl
|
||||||
- x86_64-unknown-linux-musl
|
- x86_64-unknown-linux-musl
|
||||||
steps:
|
steps:
|
||||||
@@ -111,8 +114,8 @@ jobs:
|
|||||||
id: tag
|
id: tag
|
||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/download-artifact@v1
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cross-linux-musl
|
name: cross-linux-musl
|
||||||
path: /tmp/
|
path: /tmp/
|
||||||
@@ -160,7 +163,7 @@ jobs:
|
|||||||
id: tag
|
id: tag
|
||||||
uses: dawidd6/action-get-tag@v1
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- run: bash ci/set_rust_version.bash stable ${{ matrix.target }}
|
- run: bash ci/set_rust_version.bash stable ${{ matrix.target }}
|
||||||
- run: bash ci/build.bash cargo ${{ matrix.target }} RELEASE
|
- run: bash ci/build.bash cargo ${{ matrix.target }} RELEASE
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
640
Cargo.lock
generated
640
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bore-cli"
|
name = "bore-cli"
|
||||||
version = "0.4.1"
|
version = "0.6.0"
|
||||||
authors = ["Eric Zhang <ekzhang1@gmail.com>"]
|
authors = ["Eric Zhang <ekzhang1@gmail.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls."
|
description = "A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls."
|
||||||
@@ -19,6 +19,7 @@ path = "src/main.rs"
|
|||||||
anyhow = { version = "1.0.56", features = ["backtrace"] }
|
anyhow = { version = "1.0.56", features = ["backtrace"] }
|
||||||
clap = { version = "4.0.22", features = ["derive", "env"] }
|
clap = { version = "4.0.22", features = ["derive", "env"] }
|
||||||
dashmap = "5.2.0"
|
dashmap = "5.2.0"
|
||||||
|
fastrand = "1.9.0"
|
||||||
futures-util = { version = "0.3.21", features = ["sink"] }
|
futures-util = { version = "0.3.21", features = ["sink"] }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
@@ -28,10 +29,10 @@ sha2 = "0.10.2"
|
|||||||
tokio = { version = "1.17.0", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
|
tokio = { version = "1.17.0", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
|
||||||
tokio-util = { version = "0.7.1", features = ["codec"] }
|
tokio-util = { version = "0.7.1", features = ["codec"] }
|
||||||
tracing = "0.1.32"
|
tracing = "0.1.32"
|
||||||
tracing-subscriber = "0.3.10"
|
tracing-subscriber = "0.3.18"
|
||||||
uuid = { version = "1.2.1", features = ["serde", "v4"] }
|
uuid = { version = "1.2.1", features = ["serde", "v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
rstest = "0.12.0"
|
rstest = "0.15.0"
|
||||||
tokio = { version = "1.17.0", features = ["sync"] }
|
tokio = { version = "1.17.0", features = ["sync"] }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:alpine as builder
|
FROM rust:alpine AS builder
|
||||||
WORKDIR /home/rust/src
|
WORKDIR /home/rust/src
|
||||||
RUN apk --no-cache add musl-dev
|
RUN apk --no-cache add musl-dev
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -1,6 +1,6 @@
|
|||||||
# bore
|
# bore
|
||||||
|
|
||||||
[](https://github.com/ekzhang/bore/actions)
|
[](https://github.com/ekzhang/bore/actions)
|
||||||
[](https://crates.io/crates/bore-cli)
|
[](https://crates.io/crates/bore-cli)
|
||||||
|
|
||||||
A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls. **That's all it does: no more, and no less.**
|
A modern, simple TCP tunnel in Rust that exposes local ports to a remote server, bypassing standard NAT connection firewalls. **That's all it does: no more, and no less.**
|
||||||
@@ -19,25 +19,53 @@ This will expose your local port at `localhost:8000` to the public internet at `
|
|||||||
|
|
||||||
Similar to [localtunnel](https://github.com/localtunnel/localtunnel) and [ngrok](https://ngrok.io/), except `bore` is intended to be a highly efficient, unopinionated tool for forwarding TCP traffic that is simple to install and easy to self-host, with no frills attached.
|
Similar to [localtunnel](https://github.com/localtunnel/localtunnel) and [ngrok](https://ngrok.io/), except `bore` is intended to be a highly efficient, unopinionated tool for forwarding TCP traffic that is simple to install and easy to self-host, with no frills attached.
|
||||||
|
|
||||||
(`bore` totals less than 400 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.)
|
(`bore` totals about 400 lines of safe, async Rust code and is trivial to set up — just run a single binary for the client and server.)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
If you're on macOS, `bore` is packaged as a Homebrew formula.
|
### macOS
|
||||||
|
|
||||||
|
`bore` is packaged as a Homebrew core formula.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
brew install ekzhang/bore/bore
|
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.
|
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.
|
You also can build `bore` from source using [Cargo](https://doc.rust-lang.org/cargo/), the Rust package manager. This command installs the `bore` binary at a user-accessible path.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cargo install bore-cli
|
cargo install bore-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
We also publish versioned Docker images for each release. Each image is built for AMD 64-bit and Arm 64-bit architectures. They're tagged with the specific version and allow you to run the statically-linked `bore` binary from a minimal "scratch" container.
|
### Docker
|
||||||
|
|
||||||
|
We also publish versioned Docker images for each release. The image is built for an AMD 64-bit architecture. They're tagged with the specific version and allow you to run the statically-linked `bore` binary from a minimal "scratch" container.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run -it --init --rm --network host ekzhang/bore <ARGS>
|
docker run -it --init --rm --network host ekzhang/bore <ARGS>
|
||||||
@@ -65,14 +93,14 @@ Starts a local proxy to the remote server
|
|||||||
Usage: bore local [OPTIONS] --to <TO> <LOCAL_PORT>
|
Usage: bore local [OPTIONS] --to <TO> <LOCAL_PORT>
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
<LOCAL_PORT> The local port to expose
|
<LOCAL_PORT> The local port to expose [env: BORE_LOCAL_PORT=]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-l, --local-host <HOST> The local host to expose [default: localhost]
|
-l, --local-host <HOST> The local host to expose [default: localhost]
|
||||||
-t, --to <TO> Address of the remote server to expose local ports to [env: BORE_SERVER=]
|
-t, --to <TO> Address of the remote server to expose local ports to [env: BORE_SERVER=]
|
||||||
-p, --port <PORT> Optional port on the remote server to select [default: 0]
|
-p, --port <PORT> Optional port on the remote server to select [default: 0]
|
||||||
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
|
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
|
||||||
-h, --help Print help information
|
-h, --help Print help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Self-Hosting
|
### Self-Hosting
|
||||||
@@ -85,6 +113,8 @@ bore server
|
|||||||
|
|
||||||
That's all it takes! After the server starts running at a given address, you can then update the `bore local` command with option `--to <ADDRESS>` to forward a local port to this remote server.
|
That's all it takes! After the server starts running at a given address, you can then update the `bore local` command with option `--to <ADDRESS>` to forward a local port to this remote server.
|
||||||
|
|
||||||
|
It's possible to specify different IP addresses for the control server and for the tunnels. This setup is useful for cases where you might want the control server to be on a private network while allowing tunnel connections over a public interface, or vice versa.
|
||||||
|
|
||||||
The full options for the `bore server` command are shown below.
|
The full options for the `bore server` command are shown below.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -93,9 +123,12 @@ Runs the remote proxy server
|
|||||||
Usage: bore server [OPTIONS]
|
Usage: bore server [OPTIONS]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--min-port <MIN_PORT> Minimum TCP port number to accept [default: 1024]
|
--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]
|
-s, --secret <SECRET> Optional secret for authentication [env: BORE_SECRET]
|
||||||
-h, --help Print help information
|
--bind-addr <BIND_ADDR> IP address to bind to, clients must reach this [default: 0.0.0.0]
|
||||||
|
--bind-tunnels <BIND_TUNNELS> IP address where tunnels will listen on, defaults to --bind-addr
|
||||||
|
-h, --help Print help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Protocol
|
## Protocol
|
||||||
|
|||||||
20
ci/test.bash
20
ci/test.bash
@@ -12,5 +12,21 @@ TARGET_TRIPLE=$2
|
|||||||
required_arg $CROSS 'CROSS'
|
required_arg $CROSS 'CROSS'
|
||||||
required_arg $TARGET_TRIPLE '<Target Triple>'
|
required_arg $TARGET_TRIPLE '<Target Triple>'
|
||||||
|
|
||||||
$CROSS test --target $TARGET_TRIPLE
|
max_attempts=3
|
||||||
$CROSS test --target $TARGET_TRIPLE --all-features
|
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
|
||||||
|
|||||||
@@ -3,16 +3,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use tokio::{io::AsyncWriteExt, net::TcpStream, time::timeout};
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::{net::TcpStream, time::timeout};
|
|
||||||
use tracing::{error, info, info_span, warn, Instrument};
|
use tracing::{error, info, info_span, warn, Instrument};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::Authenticator;
|
use crate::auth::Authenticator;
|
||||||
use crate::shared::{
|
use crate::shared::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT, NETWORK_TIMEOUT};
|
||||||
proxy, ClientMessage, Delimited, ServerMessage, CONTROL_PORT, NETWORK_TIMEOUT,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// State structure for the client.
|
/// State structure for the client.
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
@@ -114,10 +110,10 @@ impl Client {
|
|||||||
}
|
}
|
||||||
remote_conn.send(ClientMessage::Accept(id)).await?;
|
remote_conn.send(ClientMessage::Accept(id)).await?;
|
||||||
let mut local_conn = connect_with_timeout(&self.local_host, self.local_port).await?;
|
let mut local_conn = connect_with_timeout(&self.local_host, self.local_port).await?;
|
||||||
let parts = remote_conn.into_parts();
|
let mut parts = remote_conn.into_parts();
|
||||||
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
|
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
|
local_conn.write_all(&parts.read_buf).await?; // mostly of the cases, this will be empty
|
||||||
proxy(local_conn, parts.io).await?;
|
tokio::io::copy_bidirectional(&mut local_conn, &mut parts.io).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/main.rs
40
src/main.rs
@@ -1,6 +1,8 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bore_cli::{client::Client, server::Server};
|
use bore_cli::{client::Client, server::Server};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[clap(author, version, about)]
|
#[clap(author, version, about)]
|
||||||
@@ -14,6 +16,7 @@ enum Command {
|
|||||||
/// Starts a local proxy to the remote server.
|
/// Starts a local proxy to the remote server.
|
||||||
Local {
|
Local {
|
||||||
/// The local port to expose.
|
/// The local port to expose.
|
||||||
|
#[clap(env = "BORE_LOCAL_PORT")]
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
|
|
||||||
/// The local host to expose.
|
/// The local host to expose.
|
||||||
@@ -35,13 +38,25 @@ enum Command {
|
|||||||
|
|
||||||
/// Runs the remote proxy server.
|
/// Runs the remote proxy server.
|
||||||
Server {
|
Server {
|
||||||
/// Minimum TCP port number to accept.
|
/// Minimum accepted TCP port number.
|
||||||
#[clap(long, default_value_t = 1024)]
|
#[clap(long, default_value_t = 1024, env = "BORE_MIN_PORT")]
|
||||||
min_port: u16,
|
min_port: u16,
|
||||||
|
|
||||||
|
/// Maximum accepted TCP port number.
|
||||||
|
#[clap(long, default_value_t = 65535, env = "BORE_MAX_PORT")]
|
||||||
|
max_port: u16,
|
||||||
|
|
||||||
/// Optional secret for authentication.
|
/// Optional secret for authentication.
|
||||||
#[clap(short, long, env = "BORE_SECRET", hide_env_values = true)]
|
#[clap(short, long, env = "BORE_SECRET", hide_env_values = true)]
|
||||||
secret: Option<String>,
|
secret: Option<String>,
|
||||||
|
|
||||||
|
/// IP address to bind to, clients must reach this.
|
||||||
|
#[clap(long, default_value = "0.0.0.0")]
|
||||||
|
bind_addr: IpAddr,
|
||||||
|
|
||||||
|
/// IP address where tunnels will listen on, defaults to --bind-addr.
|
||||||
|
#[clap(long)]
|
||||||
|
bind_tunnels: Option<IpAddr>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +73,23 @@ async fn run(command: Command) -> Result<()> {
|
|||||||
let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?;
|
let client = Client::new(&local_host, local_port, &to, port, secret.as_deref()).await?;
|
||||||
client.listen().await?;
|
client.listen().await?;
|
||||||
}
|
}
|
||||||
Command::Server { min_port, secret } => {
|
Command::Server {
|
||||||
Server::new(min_port, secret.as_deref()).listen().await?;
|
min_port,
|
||||||
|
max_port,
|
||||||
|
secret,
|
||||||
|
bind_addr,
|
||||||
|
bind_tunnels,
|
||||||
|
} => {
|
||||||
|
let port_range = min_port..=max_port;
|
||||||
|
if port_range.is_empty() {
|
||||||
|
Args::command()
|
||||||
|
.error(ErrorKind::InvalidValue, "port range is empty")
|
||||||
|
.exit();
|
||||||
|
}
|
||||||
|
let mut server = Server::new(port_range, secret.as_deref());
|
||||||
|
server.set_bind_addr(bind_addr);
|
||||||
|
server.set_bind_tunnels(bind_tunnels.unwrap_or(bind_addr));
|
||||||
|
server.listen().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
src/server.rs
107
src/server.rs
@@ -1,8 +1,7 @@
|
|||||||
//! Server implementation for the `bore` service.
|
//! Server implementation for the `bore` service.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::sync::Arc;
|
use std::{io, ops::RangeInclusive, sync::Arc, time::Duration};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
@@ -13,36 +12,54 @@ use tracing::{info, info_span, warn, Instrument};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::Authenticator;
|
use crate::auth::Authenticator;
|
||||||
use crate::shared::{proxy, ClientMessage, Delimited, ServerMessage, CONTROL_PORT};
|
use crate::shared::{ClientMessage, Delimited, ServerMessage, CONTROL_PORT};
|
||||||
|
|
||||||
/// State structure for the server.
|
/// State structure for the server.
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
/// The minimum TCP port that can be forwarded.
|
/// Range of TCP ports that can be forwarded.
|
||||||
min_port: u16,
|
port_range: RangeInclusive<u16>,
|
||||||
|
|
||||||
/// Optional secret used to authenticate clients.
|
/// Optional secret used to authenticate clients.
|
||||||
auth: Option<Authenticator>,
|
auth: Option<Authenticator>,
|
||||||
|
|
||||||
/// Concurrent map of IDs to incoming connections.
|
/// Concurrent map of IDs to incoming connections.
|
||||||
conns: Arc<DashMap<Uuid, TcpStream>>,
|
conns: Arc<DashMap<Uuid, TcpStream>>,
|
||||||
|
|
||||||
|
/// IP address where the control server will bind to.
|
||||||
|
bind_addr: IpAddr,
|
||||||
|
|
||||||
|
/// IP address where tunnels will listen on.
|
||||||
|
bind_tunnels: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
/// Create a new server with a specified minimum port number.
|
/// Create a new server with a specified minimum port number.
|
||||||
pub fn new(min_port: u16, secret: Option<&str>) -> Self {
|
pub fn new(port_range: RangeInclusive<u16>, secret: Option<&str>) -> Self {
|
||||||
|
assert!(!port_range.is_empty(), "must provide at least one port");
|
||||||
Server {
|
Server {
|
||||||
min_port,
|
port_range,
|
||||||
conns: Arc::new(DashMap::new()),
|
conns: Arc::new(DashMap::new()),
|
||||||
auth: secret.map(Authenticator::new),
|
auth: secret.map(Authenticator::new),
|
||||||
|
bind_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
|
bind_tunnels: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the IP address where tunnels will listen on.
|
||||||
|
pub fn set_bind_addr(&mut self, bind_addr: IpAddr) {
|
||||||
|
self.bind_addr = bind_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the IP address where the control server will bind to.
|
||||||
|
pub fn set_bind_tunnels(&mut self, bind_tunnels: IpAddr) {
|
||||||
|
self.bind_tunnels = bind_tunnels;
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the server, listening for new connections.
|
/// Start the server, listening for new connections.
|
||||||
pub async fn listen(self) -> Result<()> {
|
pub async fn listen(self) -> Result<()> {
|
||||||
let this = Arc::new(self);
|
let this = Arc::new(self);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], CONTROL_PORT));
|
let listener = TcpListener::bind((this.bind_addr, CONTROL_PORT)).await?;
|
||||||
let listener = TcpListener::bind(&addr).await?;
|
info!(addr = ?this.bind_addr, "server listening");
|
||||||
info!(?addr, "server listening");
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, addr) = listener.accept().await?;
|
let (stream, addr) = listener.accept().await?;
|
||||||
@@ -61,6 +78,43 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_listener(&self, port: u16) -> Result<TcpListener, &'static str> {
|
||||||
|
let try_bind = |port: u16| async move {
|
||||||
|
TcpListener::bind((self.bind_tunnels, port))
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err.kind() {
|
||||||
|
io::ErrorKind::AddrInUse => "port already in use",
|
||||||
|
io::ErrorKind::PermissionDenied => "permission denied",
|
||||||
|
_ => "failed to bind to port",
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if port > 0 {
|
||||||
|
// Client requests a specific port number.
|
||||||
|
if !self.port_range.contains(&port) {
|
||||||
|
return Err("client port number not in allowed range");
|
||||||
|
}
|
||||||
|
try_bind(port).await
|
||||||
|
} else {
|
||||||
|
// Client requests any available port in range.
|
||||||
|
//
|
||||||
|
// In this case, we bind to 150 random port numbers. We choose this value because in
|
||||||
|
// order to find a free port with probability at least 1-δ, when ε proportion of the
|
||||||
|
// ports are currently available, it suffices to check approximately -2 ln(δ) / ε
|
||||||
|
// independently and uniformly chosen ports (up to a second-order term in ε).
|
||||||
|
//
|
||||||
|
// Checking 150 times gives us 99.999% success at utilizing 85% of ports under these
|
||||||
|
// conditions, when ε=0.15 and δ=0.00001.
|
||||||
|
for _ in 0..150 {
|
||||||
|
let port = fastrand::u16(self.port_range.clone());
|
||||||
|
match try_bind(port).await {
|
||||||
|
Ok(listener) => return Ok(listener),
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("failed to find an available port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_connection(&self, stream: TcpStream) -> Result<()> {
|
async fn handle_connection(&self, stream: TcpStream) -> Result<()> {
|
||||||
let mut stream = Delimited::new(stream);
|
let mut stream = Delimited::new(stream);
|
||||||
if let Some(auth) = &self.auth {
|
if let Some(auth) = &self.auth {
|
||||||
@@ -77,22 +131,16 @@ impl Server {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Some(ClientMessage::Hello(port)) => {
|
Some(ClientMessage::Hello(port)) => {
|
||||||
if port != 0 && port < self.min_port {
|
let listener = match self.create_listener(port).await {
|
||||||
warn!(?port, "client port number too low");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
info!(?port, "new client");
|
|
||||||
let listener = match TcpListener::bind(("0.0.0.0", port)).await {
|
|
||||||
Ok(listener) => listener,
|
Ok(listener) => listener,
|
||||||
Err(_) => {
|
Err(err) => {
|
||||||
warn!(?port, "could not bind to local port");
|
stream.send(ServerMessage::Error(err.into())).await?;
|
||||||
stream
|
|
||||||
.send(ServerMessage::Error("port already in use".into()))
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let host = listener.local_addr()?.ip();
|
||||||
let port = listener.local_addr()?.port();
|
let port = listener.local_addr()?.port();
|
||||||
|
info!(?host, ?port, "new client");
|
||||||
stream.send(ServerMessage::Hello(port)).await?;
|
stream.send(ServerMessage::Hello(port)).await?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -124,25 +172,16 @@ impl Server {
|
|||||||
info!(%id, "forwarding connection");
|
info!(%id, "forwarding connection");
|
||||||
match self.conns.remove(&id) {
|
match self.conns.remove(&id) {
|
||||||
Some((_, mut stream2)) => {
|
Some((_, mut stream2)) => {
|
||||||
let parts = stream.into_parts();
|
let mut parts = stream.into_parts();
|
||||||
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
|
debug_assert!(parts.write_buf.is_empty(), "framed write buffer not empty");
|
||||||
stream2.write_all(&parts.read_buf).await?;
|
stream2.write_all(&parts.read_buf).await?;
|
||||||
proxy(parts.io, stream2).await?
|
tokio::io::copy_bidirectional(&mut parts.io, &mut stream2).await?;
|
||||||
}
|
}
|
||||||
None => warn!(%id, "missing connection"),
|
None => warn!(%id, "missing connection"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
None => {
|
None => Ok(()),
|
||||||
warn!("unexpected EOF");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Server {
|
|
||||||
fn default() -> Self {
|
|
||||||
Server::new(1024, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use serde::{Deserialize, Serialize};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use tokio::io::{self, AsyncRead, AsyncWrite};
|
|
||||||
|
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_util::codec::{AnyDelimiterCodec, Framed, FramedParts};
|
use tokio_util::codec::{AnyDelimiterCodec, Framed, FramedParts};
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
@@ -16,7 +14,7 @@ use uuid::Uuid;
|
|||||||
/// TCP port used for control connections with the server.
|
/// TCP port used for control connections with the server.
|
||||||
pub const CONTROL_PORT: u16 = 7835;
|
pub const CONTROL_PORT: u16 = 7835;
|
||||||
|
|
||||||
/// Maxmium byte length for a JSON frame in the stream.
|
/// Maximum byte length for a JSON frame in the stream.
|
||||||
pub const MAX_FRAME_LENGTH: usize = 256;
|
pub const MAX_FRAME_LENGTH: usize = 256;
|
||||||
|
|
||||||
/// Timeout for network connections and initial protocol messages.
|
/// Timeout for network connections and initial protocol messages.
|
||||||
@@ -99,18 +97,3 @@ impl<U: AsyncRead + AsyncWrite + Unpin> Delimited<U> {
|
|||||||
self.0.into_parts()
|
self.0.into_parts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy data mutually between two read/write streams.
|
|
||||||
pub async fn proxy<S1, S2>(stream1: S1, stream2: S2) -> io::Result<()>
|
|
||||||
where
|
|
||||||
S1: AsyncRead + AsyncWrite + Unpin,
|
|
||||||
S2: AsyncRead + AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
let (mut s1_read, mut s1_write) = io::split(stream1);
|
|
||||||
let (mut s2_read, mut s2_write) = io::split(stream2);
|
|
||||||
tokio::select! {
|
|
||||||
res = io::copy(&mut s1_read, &mut s2_write) => res,
|
|
||||||
res = io::copy(&mut s2_read, &mut s1_write) => res,
|
|
||||||
}?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ lazy_static! {
|
|||||||
|
|
||||||
/// Spawn the server, giving some time for the control port TcpListener to start.
|
/// Spawn the server, giving some time for the control port TcpListener to start.
|
||||||
async fn spawn_server(secret: Option<&str>) {
|
async fn spawn_server(secret: Option<&str>) {
|
||||||
tokio::spawn(Server::new(1024, secret).listen());
|
tokio::spawn(Server::new(1024..=65535, secret).listen());
|
||||||
time::sleep(Duration::from_millis(50)).await;
|
time::sleep(Duration::from_millis(50)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,3 +117,48 @@ async fn very_long_frame() -> Result<()> {
|
|||||||
}
|
}
|
||||||
panic!("did not exit after a 1 MB frame");
|
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