diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh new file mode 100755 index 0000000..1d0196a --- /dev/null +++ b/.github/scripts/next-version.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Prints the next release version (without a leading "v"). +# +# next-version.sh BASE_VERSION BUMP +# +# BASE_VERSION is the latest released version, e.g. 0.2.0 (tag v0.2.0). +# BUMP is one of: auto, patch, minor, major. +# +# `auto` reads the Conventional Commits since the tag v (the +# whole history if that tag does not exist): +# +# breaking (`type!:` or a `BREAKING CHANGE` footer) -> major, or minor while +# the major version is 0 +# feat -> minor +# anything else -> patch +# +# It fails when there are no commits to release. The decision lives here rather +# than in git-cliff's own bump logic, whose handling of 0.x differs between +# versions; git-cliff only writes the changelog. +set -euo pipefail + +base="${1:?usage: next-version.sh BASE_VERSION BUMP}" +bump="${2:?usage: next-version.sh BASE_VERSION BUMP}" + +if ! [[ "$base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "next-version: '$base' is not MAJOR.MINOR.PATCH" >&2 + exit 2 +fi +major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]} + +level="$bump" +if [ "$bump" = auto ]; then + if git rev-parse -q --verify "refs/tags/v$base" >/dev/null; then + range="v$base..HEAD" + else + range="HEAD" + fi + + subjects=$(git log --format=%s "$range" | grep -v '^chore(release)' || true) + if [ -z "$subjects" ]; then + echo "next-version: no commits since v$base, nothing to release" >&2 + exit 1 + fi + + bodies=$(git log --format=%b "$range" | grep -v '^$' || true) + if grep -qE '^[a-zA-Z]+(\([^)]*\))?!:' <<<"$subjects" || + grep -qE '^BREAKING[ -]CHANGE' <<<"$bodies"; then + if [ "$major" -ge 1 ]; then level=major; else level=minor; fi + reason="a breaking change" + elif grep -qE '^feat(\([^)]*\))?:' <<<"$subjects"; then + level="minor" + reason="a new feature" + else + level="patch" + reason="no feature or breaking change" + fi + echo "next-version: auto -> $level ($reason since v$base)" >&2 +fi + +case "$level" in + major) echo "$((major + 1)).0.0" ;; + minor) echo "$major.$((minor + 1)).0" ;; + patch) echo "$major.$minor.$((patch + 1))" ;; + *) + echo "next-version: unknown bump '$bump' (auto, patch, minor, major)" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/test-next-version.sh b/.github/scripts/test-next-version.sh new file mode 100755 index 0000000..b17aba8 --- /dev/null +++ b/.github/scripts/test-next-version.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Tests next-version.sh against throwaway git repositories. +set -uo pipefail + +script="$(cd "$(dirname "$0")" && pwd)/next-version.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +failed=0 + +# repo NAME TAG COMMIT... (a commit message may contain a body after a blank line) +repo() { + local dir="$work/$1" tag="$2" + shift 2 + mkdir -p "$dir" && git -C "$dir" init -q + git -C "$dir" config user.email t@t && git -C "$dir" config user.name t + git -C "$dir" commit -q --allow-empty -m "chore: init" + [ -n "$tag" ] && git -C "$dir" tag "$tag" + local msg + for msg in "$@"; do git -C "$dir" commit -q --allow-empty -m "$msg"; done + echo "$dir" +} + +# expect DESCRIPTION WANT DIR BASE BUMP (WANT "FAIL" = non-zero exit) +expect() { + local desc="$1" want="$2" dir="$3" got rc + got=$(cd "$dir" && bash "$script" "$4" "$5" 2>/dev/null) + rc=$? + if [ "$want" = FAIL ]; then + if [ "$rc" -ne 0 ]; then + echo " ok $desc" + else + echo " FAIL $desc: expected failure, got '$got'" + failed=1 + fi + elif [ "$rc" -eq 0 ] && [ "$got" = "$want" ]; then + echo " ok $desc -> $got" + else + echo " FAIL $desc: want $want, got '$got' (exit $rc)" + failed=1 + fi +} + +echo "auto, 0.x" +expect "docs only" 0.2.1 "$(repo a v0.2.0 'docs: tweak')" 0.2.0 auto +expect "fix" 0.2.1 "$(repo b v0.2.0 'fix: bug')" 0.2.0 auto +expect "feat" 0.3.0 "$(repo c v0.2.0 'fix: bug' 'feat: thing')" 0.2.0 auto +expect "scoped feat" 0.3.0 "$(repo d v0.2.0 'feat(core): thing')" 0.2.0 auto +expect "feat!, stays in 0.x" 0.3.0 "$(repo e v0.2.0 'feat!: drop it')" 0.2.0 auto +expect "fix!, stays in 0.x" 0.3.0 "$(repo f v0.2.0 'fix(api)!: change')" 0.2.0 auto +expect "BREAKING CHANGE body" 0.3.0 "$(repo g v0.2.0 $'refactor: x\n\nBREAKING CHANGE: y')" 0.2.0 auto +expect "release commit only" FAIL "$(repo h v0.2.0 'chore(release): v0.2.0')" 0.2.0 auto +expect "no commits" FAIL "$(repo i v0.2.0)" 0.2.0 auto +expect "feat inside a word" 0.2.1 "$(repo j v0.2.0 'docs: feature list')" 0.2.0 auto + +echo "auto, 1.x" +expect "fix" 1.2.4 "$(repo k v1.2.3 'fix: bug')" 1.2.3 auto +expect "feat" 1.3.0 "$(repo l v1.2.3 'feat: thing')" 1.2.3 auto +expect "feat!" 2.0.0 "$(repo m v1.2.3 'feat!: drop')" 1.2.3 auto +expect "BREAKING-CHANGE body" 2.0.0 "$(repo n v1.2.3 $'fix: x\n\nBREAKING-CHANGE: y')" 1.2.3 auto + +echo "history before the base tag is ignored" +expect "old feat! not counted" 0.2.1 "$(repo o v0.2.0 'fix: later')" 0.2.0 auto +dir="$(repo p '' 'feat!: ancient')"; git -C "$dir" tag v0.2.0; git -C "$dir" commit -q --allow-empty -m 'fix: later' +expect "breaking before tag" 0.2.1 "$dir" 0.2.0 auto + +echo "no tag yet: whole history" +expect "untagged" 0.3.0 "$(repo q '' 'feat: first')" 0.2.0 auto + +echo "manual" +dir="$(repo r v0.2.0)" +expect "patch" 0.2.1 "$dir" 0.2.0 patch +expect "minor" 0.3.0 "$dir" 0.2.0 minor +expect "major" 1.0.0 "$dir" 0.2.0 major +expect "bad bump" FAIL "$dir" 0.2.0 huge +expect "bad version" FAIL "$dir" v0.2 auto + +if [ "$failed" -eq 0 ]; then + echo "all passed" +else + echo "FAILED" + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8561603 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + components: rustfmt + - run: cargo fmt --all --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + - run: cargo test --workspace + + # The script that picks the next release version from the commit history. + release-scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: shellcheck .github/scripts/*.sh + - name: Check the workflow files + run: | + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/03d0035246f3e81f36aed592ffb4bebf33a03106/scripts/download-actionlint.bash) 1.7.7 + ./actionlint -color + - run: bash .github/scripts/test-next-version.sh + + # The minimum Rust version declared in Cargo.toml (`rust-version`). + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@599c45df1a24c1c63ae1129a7e59f46654b8fb66 # 1.89.0 + with: + toolchain: 1.89.0 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + - run: cargo check --workspace --all-targets + + # Everything `cargo publish` would do, minus the upload: package both crates, + # verify them, and build `erbridge` against the packaged `erbridge-core`. + publish-dry-run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + - run: cargo publish --workspace --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bcec6a..0dce04a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,14 @@ on: workflow_dispatch: inputs: bump: - description: "Version bump" + description: "Version bump (auto reads the Conventional Commits since the last release)" type: choice - options: [patch, minor, major] - default: patch + options: [auto, patch, minor, major] + default: auto + publish: + description: "Publish to crates.io (untick for a dry run that skips the upload)" + type: boolean + default: true permissions: contents: read @@ -21,16 +25,18 @@ jobs: version: ${{ steps.bump.outputs.version }} tag: ${{ steps.bump.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: taiki-e/install-action@v2 + - uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2 with: tool: git-cliff - name: Bump version, changelog, and tag id: bump + env: + BUMP: ${{ inputs.bump }} run: | set -euo pipefail @@ -39,20 +45,18 @@ jobs: BASE_VERSION="${LATEST_TAG#v}" BASE_VERSION="${BASE_VERSION:-$CARGO_VERSION}" - IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" - case "${{ inputs.bump }}" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - NEW_VERSION="$MAJOR.$MINOR.$PATCH" + # Fails, and so stops the release, when `auto` finds nothing to release. + NEW_VERSION=$(bash .github/scripts/next-version.sh "$BASE_VERSION" "$BUMP") NEW_TAG="v$NEW_VERSION" sed -i -E "0,/^version = \".*\"/s//version = \"$NEW_VERSION\"/" Cargo.toml + # The erbridge-core path dependency carries its own version for crates.io; keep it equal. + sed -i -E "s|^(erbridge-core = \{ path = \"crates/erbridge-core\", version = \")[^\"]*(\" \})|\1$NEW_VERSION\2|" Cargo.toml + grep -q "version = \"$NEW_VERSION\" }" Cargo.toml - # Cargo.lock's erbridge entry: update the version line right after the matching name line. + # Cargo.lock's erbridge and erbridge-core entries: update the version line right after the matching name line. awk -v ver="$NEW_VERSION" ' - /^name = "erbridge"$/ { print; getline; sub(/version = ".*"/, "version = \"" ver "\""); print; next } + /^name = "erbridge(-core)?"$/ { print; getline; sub(/version = ".*"/, "version = \"" ver "\""); print; next } { print } ' Cargo.lock > Cargo.lock.new && mv Cargo.lock.new Cargo.lock @@ -72,7 +76,7 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "tag=$NEW_TAG" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: release-notes path: RELEASE_NOTES.md @@ -115,12 +119,13 @@ jobs: dist-dir: dist/osx runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.version.outputs.tag }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: + toolchain: stable targets: ${{ matrix.targets }} - name: Install mingw-w64 (windows target) @@ -133,7 +138,7 @@ jobs: - name: Install cross (linux-arm64 target, cross-compiles via Docker) if: matrix.name == 'linux-arm64' - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2 with: tool: cross @@ -152,7 +157,7 @@ jobs: echo "path=$NAME.tar.gz" >> "$GITHUB_OUTPUT" fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: erbridge-${{ matrix.name }} path: ${{ steps.package.outputs.path }} @@ -164,7 +169,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts merge-multiple: true @@ -174,9 +179,9 @@ jobs: - name: Checksum archives working-directory: artifacts - run: sha256sum * > SHA256SUMS + run: sha256sum -- * > SHA256SUMS - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Sign the checksum manifest (keyless) working-directory: artifacts @@ -197,3 +202,37 @@ jobs: --title "${{ needs.version.outputs.tag }}" \ --notes-file RELEASE_NOTES.md \ artifacts/* + + # Runs only after every binary built and the GitHub release exists, so a broken + # build never reaches crates.io. Publishing cannot be undone (a version can only + # be yanked), and a failure here does not undo the GitHub release; rerun by + # publishing the tag by hand (see README, "Publishing to crates.io"). + publish-crates: + needs: [version, sign-and-release] + runs-on: ubuntu-latest + environment: crates-io + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.version.outputs.tag }} + + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable + + # Publishes erbridge-core first, waits for the index, then erbridge. + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + if [ "$PUBLISH" = "true" ]; then + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo "CARGO_REGISTRY_TOKEN is not set (see README, Publishing to crates.io)" >&2 + exit 1 + fi + cargo publish --workspace + else + cargo publish --workspace --dry-run + fi diff --git a/Cargo.lock b/Cargo.lock index c63ee75..fadc9da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,11 +788,23 @@ version = "0.2.0" dependencies = [ "anyhow", "clap", - "criterion", "crossterm", + "erbridge-core", + "ratatui", + "rustls-pki-types", + "tempfile", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "erbridge-core" +version = "0.2.0" +dependencies = [ + "anyhow", + "criterion", "futures", "rand 0.10.2", - "ratatui", "rcgen", "rustls", "rustls-pki-types", @@ -800,6 +812,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "snow", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-rustls", @@ -837,6 +850,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2258,6 +2277,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termina" version = "0.3.3" @@ -2464,6 +2496,7 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 4f79dc5..6f6a1b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,37 +1,22 @@ -[package] -name = "erbridge" +[workspace] +resolver = "3" +members = ["crates/erbridge-core", "crates/erbridge"] + +[workspace.package] version = "0.2.0" edition = "2024" authors = ["Duye Chen "] license = "MIT" +repository = "https://github.com/single9/erbridge" +homepage = "https://github.com/single9/erbridge" +# `File::lock` (tenant file locking) needs 1.89. +rust-version = "1.89" -[dependencies] -anyhow = "1.0.104" -clap = { version = "4.6.6", features = ["derive"] } -crossterm = "0.29.0" -futures = "0.3.34" -rand = "0.10.2" -ratatui = "0.30.2" -rcgen = "0.14.10" -rustls = { version = "0.23.43", default-features = false, features = ["ring"] } -rustls-pki-types = "1.15.1" -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" -sha2 = "0.11.0" -snow = { version = "0.10.0", features = ["ring-accelerated"] } -thiserror = "2.0.20" -tokio = { version = "1.53.1", features = ["full"] } -tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } -tokio-util = { version = "0.7.19", features = ["compat"] } -toml = "1.1.5" -yamux = "0.14.0" - -[dev-dependencies] -criterion = { version = "0.7", features = ["async_tokio", "html_reports"] } - -[[bench]] -name = "latency" -harness = false +# The version here is bumped together with `[workspace.package]` by the release +# workflow; the two must stay equal for `cargo publish` to resolve the path +# dependency against crates.io. +[workspace.dependencies] +erbridge-core = { path = "crates/erbridge-core", version = "0.2.0" } [profile.release] lto = "thin" diff --git a/Makefile b/Makefile index de28cc2..f24b993 100644 --- a/Makefile +++ b/Makefile @@ -158,7 +158,7 @@ test: ## Latency benchmark: baseline vs forward vs serve/connect roundtrip, HTML report in target/criterion/ bench: - cargo bench --bench latency + cargo bench -p erbridge-core --bench latency ## Same ping-pong methodology, but against external tunnels (frp/rathole/bore) for comparison. ## Needs frpc/frps/rathole/bore-cli on PATH (brew install frpc frps rathole bore-cli), or point @@ -171,7 +171,7 @@ bench: ## make compare-tunnels # one round ## make compare-tunnels COMPARE_ROUNDS=10 # ten rounds, settling between each compare-tunnels: - cargo build --release --example compare_tunnels + cargo build --release -p erbridge-core --example compare_tunnels @echo "settling $(COMPARE_SETTLE)s before measuring..." @sleep $(COMPARE_SETTLE) @for i in $$(seq 1 $(COMPARE_ROUNDS)); do \ diff --git a/README.md b/README.md index c283253..f27024b 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,11 @@ its own independent stream. ## Build +The repository is a Cargo workspace with two crates: `erbridge-core` (the library) and +`erbridge` (the command-line binary, with the terminal UI). + ```sh -cargo build --release +cargo build --release # -> target/release/erbridge ``` Cross-compilation (mirrors the other Rust sub-projects in this repo): @@ -133,12 +136,12 @@ sha256sum -c SHA256SUMS --ignore-missing Install the `erbridge` binary onto your system's `PATH` via `cargo install`: ```sh -cargo install --path . +cargo install --path crates/erbridge ``` This builds a release binary and copies it to `~/.cargo/bin/erbridge` (make sure that directory is on your `PATH`; `cargo install` prints a warning if it isn't). Run -`cargo install --path . --force` to reinstall after pulling new changes. +`cargo install --path crates/erbridge --force` to reinstall after pulling new changes. ### Run as a systemd service (Linux) @@ -166,8 +169,8 @@ sudo systemctl enable --now erbridge Prebuilt binaries for Windows (x86_64), Linux (x86_64 and arm64), and macOS (universal, plus separate arm64/x86_64-only archives) are published from the [Release workflow](.github/workflows/release.yml). Trigger it manually from the Actions tab -("Run workflow") and pick a version bump (`patch`/`minor`/`major`); it advances the version -tag, regenerates [`CHANGELOG.md`](CHANGELOG.md) from [Conventional Commits](https://www.conventionalcommits.org/) +("Run workflow") and pick a version bump (`auto` by default, or `patch`/`minor`/`major`); it +advances the version tag, regenerates [`CHANGELOG.md`](CHANGELOG.md) from [Conventional Commits](https://www.conventionalcommits.org/) with [git-cliff](https://github.com/orhun/git-cliff) (config: [`cliff.toml`](cliff.toml)), builds every platform, and publishes a GitHub release with the archives, that release's changelog section as the release notes, plus a signed `SHA256SUMS` manifest. @@ -187,6 +190,79 @@ cosign verify-blob \ sha256sum -c SHA256SUMS --ignore-missing ``` +### Choosing the version + +`auto` reads the [Conventional Commits](https://www.conventionalcommits.org/) since the last +release tag (rules in [`.github/scripts/next-version.sh`](.github/scripts/next-version.sh)): + +| Commits since the last release | Next version | +| --- | --- | +| a breaking change (`type!:` or a `BREAKING CHANGE:` footer) | major, or **minor while the version is 0.x** | +| a `feat:` | minor | +| anything else (`fix:`, `docs:`, `build:`, ...) | patch | +| none | the release stops with an error | + +Mark breaking changes, including API changes to `erbridge-core`, with `!`: once the project is +at 1.0 an unmarked one would be released as a minor version. Choose `patch`/`minor`/`major` +explicitly to override `auto`. The rules are tested in CI +(`.github/scripts/test-next-version.sh`). + +### Publishing to crates.io + +`erbridge-core` and `erbridge` are published by the Release workflow, core first. The last job +(`publish-crates`) runs only after every binary built and the GitHub release exists. + +One-time setup: + +1. On crates.io, create an API token with the `publish-new` and `publish-update` scopes + (restrict it to the `erbridge` and `erbridge-core` crates once they exist). +2. In the GitHub repo, add it as the secret `CARGO_REGISTRY_TOKEN` (repository secret, or a + secret of the `crates-io` environment, which the job uses; add required reviewers to that + environment if you want a manual approval before anything is published). + +The first release has to create both crate names, so the token needs `publish-new` at that point. + +Run the workflow with **publish** unticked for a dry run: it builds and packages everything and +skips only the upload. The CI workflow does the same check on every push +(`cargo publish --workspace --dry-run`). Publishing is permanent, so a bad release is fixed by +yanking the version and releasing the next patch. + +If only `publish-crates` failed (for example an expired token), fix the secret and publish the +tag by hand: + +```sh +git checkout vX.Y.Z && cargo publish --workspace +``` + +## Upgrading + +### From 0.2 to 0.3 + +**Using the `erbridge` command: nothing to change.** The commands, flags and config files are +the same, and the wire protocol is unchanged, so old and new builds interoperate: a 0.2 +`connect` works against a 0.3 `serve` (including its multi-tenant mode) and the other way +round, and `forward`/`client` with `noise` also mix. You can upgrade one side at a time. + +One thing differs if you build from source: the binary crate moved, so install with + +```sh +cargo install --path crates/erbridge # was: cargo install --path . +``` + +(`cargo build --release` at the repository root still produces `target/release/erbridge`.) + +**Using erbridge as a library: three changes.** Nothing was ever published to crates.io, so this +only affects programs that depend on it by `path` or `git`. + +- The library is now the `erbridge-core` crate, imported as `erbridge_core` (it was `erbridge`). + The `cli` and `tui` modules are no longer part of it. +- `config::ServeConfig` gained two fields, `tenants_file` and `sni_listen`, and implements + `Default`. A struct literal needs `..Default::default()` added. +- New and optional: `run_serve_with_shutdown`, `run_connect_with_shutdown`, + `Registry::subscribe`, and the multi-tenant API (`tenant`, `tenant_store`, + `reverse::tenant_serve`, `reverse::enroll`). The existing `run_serve` and `run_connect` are + unchanged. + ## Quick start ### forward: direct forwarding @@ -270,6 +346,42 @@ Both sides use the same `NAME` in `--tunnel NAME=...` to correspond to the same After B disconnects, A's external listening port does not close; new connections wait for B to reconnect. B automatically retries connecting back to A with exponential backoff (`reconnect_min_secs` ~ `reconnect_max_secs`). +### Multi-tenant serve: many clients, one server + +A single `serve` can host many independent users, each with their own secret and their own +hostname. Clients are told apart by which token they hold, and TLS visitors are routed by the +SNI in their ClientHello. `serve` never decrypts the TLS traffic; it relays the bytes. + +```sh +# On the server. The tenant file starts empty (or missing). +erbridge serve --listen 0.0.0.0:9000 --tenants tenants.toml --sni-listen 0.0.0.0:443 + +# Hand a user a single-use code for a hostname (works while serve is running): +erbridge serve-admin --tenants tenants.toml issue --sni abc123.example.com --expires-in 86400 + +# On the user's machine: trade the code for credentials, then connect. +erbridge enroll --server A_IP:9000 --code --out credentials.toml +erbridge connect --server A_IP:9000 --credentials credentials.toml --target 127.0.0.1:8080 +``` + +`serve-admin` also has `list`, `disable NAME`, `enable NAME` and `revoke NAME`. Revoking or +disabling a tenant ends its session and leaves everyone else's untouched. + +- A code works once and may expire. It is stored only as a hash, and a newly issued code is + usable within a second (the file is re-read every second). +- Enrolling creates the tenant with a server-chosen name and a random token. The token is shown + once, in `credentials.toml` (mode 0600, never overwritten). +- A tenant's tunnel name is decided by `serve`, so a client cannot steer traffic at someone + else's target. A wrong token and an unknown tenant look identical: the connection closes. +- `max_conns` (default 64) caps a tenant's concurrent connections; extra ones are closed. +- Unknown hosts, missing SNI, non-TLS bytes and tenants that are offline get a closed + connection and no reply. The ClientHello must fit in one TLS record, which every mainstream + client satisfies. +- `serve --tenants` cannot be combined with `--token` or `--tunnel`. + +Embedders use `run_tenant_serve_with_store` with their own `TenantStore` to keep tenants +somewhere other than a file. + ## Observing traffic By default an interactive TUI (ratatui) opens, showing aggregate traffic, a per-connection list (source/destination/protocol/bytes/lifetime), and an event log; it is read-only and cannot control connections. Press `q` / `Esc` / `Ctrl+C` to exit. @@ -322,21 +434,57 @@ before changing that feature or the `snow` dependency. See [`config.example.toml`](config.example.toml) for a complete example. The sections are independent of each other; the same config file can fill in just one section or all of them. CLI arguments (`--listen`/`--token`/`--server`/`--map`/`--tunnel`) can override or supplement the config file's contents. +## Use as a library + +`erbridge-core` has no command-line or terminal-UI dependencies, so another program can +embed a tunnel directly. This runs the `connect` role, reports its events, and stops it on +demand: + +```rust +use erbridge_core::config::{ConnectConfig, ConnectTunnel}; +use erbridge_core::reverse::connect::run_connect_with_shutdown; +use erbridge_core::stats::{Event, Registry}; +use tokio_util::sync::CancellationToken; + +let cfg = ConnectConfig { + server: "relay.example.com:9000".into(), + token: "secret".into(), + tunnel: vec![ConnectTunnel { name: "web".into(), target: "127.0.0.1:8080".into() }], + reconnect_min_secs: 1, + reconnect_max_secs: 30, +}; + +let registry = Registry::new(); +let mut events = registry.subscribe(); // structured `Event`s, not log lines +let shutdown = CancellationToken::new(); +let task = tokio::spawn(run_connect_with_shutdown(cfg, registry, shutdown.clone())); + +// ... later: stops the session, in-flight connections and reconnect loop +shutdown.cancel(); +task.await??; +``` + +`run_serve_with_shutdown` works the same way for the `serve` role, and returns once every +listener and tunnelled connection is closed and its port released. `run_serve` and +`run_connect` are the same without a shutdown handle. The crate is not published to +crates.io yet; depend on it with a `git` or `path` dependency. + ## Tests ```sh make test # equivalent to cargo test: covers forward's TCP/UDP forwarding, UDP idle timeout, # TLS- and Noise-secured forward mappings, client mode against both, the Noise - # transport itself, and serve/connect's multiplexed forwarding and PSK authentication + # transport itself, serve/connect's multiplexed forwarding and PSK authentication, and + # shutdown of both roles ``` ## Latency benchmark ```sh -make bench # equivalent to: cargo bench --bench latency +make bench # equivalent to: cargo bench -p erbridge-core --bench latency ``` -`benches/latency.rs` measures steady-state TCP round-trip latency (a 64-byte +`crates/erbridge-core/benches/latency.rs` measures steady-state TCP round-trip latency (a 64-byte ping over an already-open connection, not connection setup) across the three data paths, all on loopback: @@ -355,10 +503,10 @@ throughput/jitter) instead. ### Comparing against frp / rathole / bore ```sh -make compare-tunnels # equivalent to: cargo run --release --example compare_tunnels +make compare-tunnels # equivalent to: cargo run --release -p erbridge-core --example compare_tunnels ``` -`examples/compare_tunnels.rs` runs the same persistent-connection ping-pong +`crates/erbridge-core/examples/compare_tunnels.rs` runs the same persistent-connection ping-pong against erbridge's `serve`/`connect` reverse tunnel and three other NAT-traversal tools — [frp](https://github.com/fatedier/frp), [rathole](https://github.com/rathole-org/rathole), and [bore](https://github.com/ekzhang/bore) — all on the same machine, same diff --git a/cliff.toml b/cliff.toml index c496c61..133db94 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,3 +1,7 @@ +# git-cliff only writes the changelog. The next version number is decided by +# .github/scripts/next-version.sh, not by git-cliff's own `--bump`, whose +# handling of 0.x versions differs between releases. + [changelog] header = """ # Changelog\n diff --git a/config.example.toml b/config.example.toml index 68c1b9e..8b20dee 100644 --- a/config.example.toml +++ b/config.example.toml @@ -69,6 +69,14 @@ token = "change-me" # shared secret B must provide name = "web" external = "0.0.0.0:8081" +# ---- serve, multi-tenant mode: many connect clients, each with its own token and host ---- +# Replaces `token` and [[serve.tunnel]] above (mixing them is an error). Manage the tenant file +# with `erbridge serve-admin`; serve re-reads it every second. +# [serve] +# listen = "0.0.0.0:9000" # control port tunnel clients connect to +# tenants_file = "tenants.toml" +# sni_listen = "0.0.0.0:443" # accepts TLS and routes by SNI (never decrypts) + # ---- connect: role B, connects to A ---- [connect] server = "1.2.3.4:9000" # A's control address @@ -78,3 +86,5 @@ reconnect_max_secs = 30 # max interval for reconnect retries (exponential b [[connect.tunnel]] name = "web" # must match the tunnel name on serve's side target = "127.0.0.1:80" # final target on B's local machine (or internal network B can reach) +# Against a multi-tenant serve, `erbridge enroll` gives you the token and the tunnel name (the +# tenant name) to use here, or pass `--credentials FILE --target ADDR` to `connect` instead. diff --git a/crates/erbridge-core/Cargo.toml b/crates/erbridge-core/Cargo.toml new file mode 100644 index 0000000..5fe30bb --- /dev/null +++ b/crates/erbridge-core/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "erbridge-core" +description = "TCP/UDP forwarding and reverse tunnels over Noise, with a multi-tenant SNI-routed server, as a library" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +rust-version.workspace = true +readme = "README.md" +keywords = ["tunnel", "port-forwarding", "reverse-tunnel", "noise-protocol", "sni"] +categories = ["network-programming", "asynchronous"] + +[dependencies] +anyhow = "1.0.104" +futures = "0.3.34" +rand = "0.10.2" +rcgen = "0.14.10" +rustls = { version = "0.23.43", default-features = false, features = ["ring"] } +rustls-pki-types = "1.15.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +snow = { version = "0.10.0", features = ["ring-accelerated"] } +thiserror = "2.0.20" +tokio = { version = "1.53.1", features = ["full"] } +tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } +tokio-util = { version = "0.7.19", features = ["compat", "rt"] } +toml = "1.1.5" +yamux = "0.14.0" + +[dev-dependencies] +criterion = { version = "0.7", features = ["async_tokio", "html_reports"] } +tempfile = "3" + +[[bench]] +name = "latency" +harness = false diff --git a/crates/erbridge-core/LICENSE b/crates/erbridge-core/LICENSE new file mode 100644 index 0000000..6ca7d08 --- /dev/null +++ b/crates/erbridge-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Duye Chen + +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. diff --git a/crates/erbridge-core/README.md b/crates/erbridge-core/README.md new file mode 100644 index 0000000..f9b846f --- /dev/null +++ b/crates/erbridge-core/README.md @@ -0,0 +1,26 @@ +# erbridge-core + +The library behind [`erbridge`](https://github.com/single9/erbridge): TCP/UDP +forwarding and reverse (NAT-traversal) tunnels secured with the Noise protocol, +plus a multi-tenant server that routes TLS connections to tenants by SNI without +decrypting them. + +- `reverse::serve` / `reverse::connect`: the two roles of a reverse tunnel, each + with a `*_with_shutdown` variant that stops cleanly on a `CancellationToken`. +- `reverse::tenant_serve`: many tenants on one `serve`, each with its own token + and host, identified by which PSK completes the Noise handshake. +- `reverse::enroll`: trade a single-use code for a new tenant's credentials. +- `tenant`, `tenant_store`: tenants, enrollment codes and the `TenantStore` trait + (in-memory and file-backed implementations). +- `stats::Registry`: live connections and a structured event stream + (`Registry::subscribe`). + +The command-line tool and terminal UI are in the separate +[`erbridge`](https://crates.io/crates/erbridge) crate, so depending on this one +pulls in neither. + +See the [project README](https://github.com/single9/erbridge#readme) for usage +and an embedding example, and `docs/multi-tenant/spec.md` for the multi-tenant +design. + +MIT licensed. diff --git a/benches/latency.rs b/crates/erbridge-core/benches/latency.rs similarity index 96% rename from benches/latency.rs rename to crates/erbridge-core/benches/latency.rs index ed13b7d..5ab22d4 100644 --- a/benches/latency.rs +++ b/crates/erbridge-core/benches/latency.rs @@ -21,13 +21,13 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::runtime::Runtime; use tokio::sync::Mutex; -use erbridge::config::{ +use erbridge_core::config::{ ConnectConfig, ConnectTunnel, ForwardRule, ProtocolKind, ServeConfig, ServeTunnel, }; -use erbridge::forward::run_forward; -use erbridge::reverse::connect::run_connect; -use erbridge::reverse::serve::run_serve; -use erbridge::stats::Registry; +use erbridge_core::forward::run_forward; +use erbridge_core::reverse::connect::run_connect; +use erbridge_core::reverse::serve::run_serve; +use erbridge_core::stats::Registry; /// Fixed-size ping payload; big enough to be a realistic small message, /// small enough that framing/mux overhead (not memcpy) dominates the timing. @@ -135,6 +135,7 @@ fn bench_reverse(c: &mut Criterion, rt: &Runtime) { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), diff --git a/examples/compare_tunnels.rs b/crates/erbridge-core/examples/compare_tunnels.rs similarity index 98% rename from examples/compare_tunnels.rs rename to crates/erbridge-core/examples/compare_tunnels.rs index 32cf843..ea761f0 100644 --- a/examples/compare_tunnels.rs +++ b/crates/erbridge-core/examples/compare_tunnels.rs @@ -19,10 +19,10 @@ use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; -use erbridge::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; -use erbridge::reverse::connect::run_connect; -use erbridge::reverse::serve::run_serve; -use erbridge::stats::Registry; +use erbridge_core::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; +use erbridge_core::reverse::connect::run_connect; +use erbridge_core::reverse::serve::run_serve; +use erbridge_core::stats::Registry; const PAYLOAD: &[u8; 64] = &[0x42; 64]; const WARMUP: usize = 200; @@ -176,6 +176,7 @@ async fn scenario_erbridge_reverse(target: SocketAddr) -> Outcome { name: "bench".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), diff --git a/examples/conn_setup.rs b/crates/erbridge-core/examples/conn_setup.rs similarity index 94% rename from examples/conn_setup.rs rename to crates/erbridge-core/examples/conn_setup.rs index a8b9bb9..375d86e 100644 --- a/examples/conn_setup.rs +++ b/crates/erbridge-core/examples/conn_setup.rs @@ -12,9 +12,9 @@ //! tail all agree, and it is large. use std::time::{Duration, Instant}; -use erbridge::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; -use erbridge::reverse::{connect::run_connect, serve::run_serve}; -use erbridge::stats::Registry; +use erbridge_core::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; +use erbridge_core::reverse::{connect::run_connect, serve::run_serve}; +use erbridge_core::stats::Registry; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -57,6 +57,7 @@ async fn main() { name: "w".into(), external: format!("127.0.0.1:{ep}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{cp}"), diff --git a/examples/mux_overhead.rs b/crates/erbridge-core/examples/mux_overhead.rs similarity index 99% rename from examples/mux_overhead.rs rename to crates/erbridge-core/examples/mux_overhead.rs index 57b25b6..3de504d 100644 --- a/examples/mux_overhead.rs +++ b/crates/erbridge-core/examples/mux_overhead.rs @@ -48,9 +48,9 @@ use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::{TlsAcceptor, TlsConnector}; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; -use erbridge::mux; -use erbridge::proxy::pipe_bidirectional_tracked; -use erbridge::tls; +use erbridge_core::mux; +use erbridge_core::proxy::pipe_bidirectional_tracked; +use erbridge_core::tls; const PAYLOAD: &[u8; 64] = &[0x42; 64]; const WARMUP: usize = 200; diff --git a/src/client.rs b/crates/erbridge-core/src/client.rs similarity index 100% rename from src/client.rs rename to crates/erbridge-core/src/client.rs diff --git a/src/config.rs b/crates/erbridge-core/src/config.rs similarity index 89% rename from src/config.rs rename to crates/erbridge-core/src/config.rs index b45a2c9..486841c 100644 --- a/src/config.rs +++ b/crates/erbridge-core/src/config.rs @@ -92,13 +92,46 @@ pub struct ServeTunnel { pub external: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct ServeConfig { /// Control address A listens on, waiting for B (`connect`) to dial in. pub listen: String, + /// Shared secret for the single-tenant mode. Must be empty when + /// `tenants_file` is set. + #[serde(default)] pub token: String, #[serde(default)] pub tunnel: Vec, + /// Multi-tenant mode: path of the tenant file. Each tenant has its own + /// token and is reached by its own SNI host. + #[serde(default)] + pub tenants_file: Option, + /// Multi-tenant mode: address that accepts TLS connections and routes them + /// by SNI. Required with `tenants_file`, rejected without it. + #[serde(default)] + pub sni_listen: Option, +} + +impl ServeConfig { + /// Whether this is the multi-tenant mode, after checking that the options + /// of the two modes are not mixed. + pub fn is_multi_tenant(&self) -> Result { + match (&self.tenants_file, &self.sni_listen) { + (Some(_), sni) => { + if !self.token.is_empty() || !self.tunnel.is_empty() { + bail!( + "serve: `tenants_file` cannot be combined with `token` or `[[serve.tunnel]]`" + ); + } + if sni.is_none() { + bail!("serve: `tenants_file` requires `sni_listen`"); + } + Ok(true) + } + (None, Some(_)) => bail!("serve: `sni_listen` requires `tenants_file`"), + (None, None) => Ok(false), + } + } } #[derive(Debug, Clone, Deserialize)] diff --git a/src/forward.rs b/crates/erbridge-core/src/forward.rs similarity index 99% rename from src/forward.rs rename to crates/erbridge-core/src/forward.rs index 3ebf80b..b13c1f2 100644 --- a/src/forward.rs +++ b/crates/erbridge-core/src/forward.rs @@ -73,7 +73,7 @@ pub async fn run_forward(rules: Vec, registry: Registry) -> Result< enum ConnSecurity { Plain, Tls(TlsAcceptor), - Noise([u8; 32]), + Noise(noise::Psk), } async fn run_tcp_forward(rule: ForwardRule, registry: Registry) -> Result<()> { diff --git a/crates/erbridge-core/src/lib.rs b/crates/erbridge-core/src/lib.rs new file mode 100644 index 0000000..d291b55 --- /dev/null +++ b/crates/erbridge-core/src/lib.rs @@ -0,0 +1,59 @@ +//! TCP/UDP port forwarding and reverse tunnels, usable as a library. +//! +//! Embedding the `connect` role of a reverse tunnel, with a way to stop it +//! and to observe what it is doing: +//! +//! ```no_run +//! use erbridge_core::config::{ConnectConfig, ConnectTunnel}; +//! use erbridge_core::reverse::connect::run_connect_with_shutdown; +//! use erbridge_core::stats::{Event, Registry}; +//! use tokio_util::sync::CancellationToken; +//! +//! # async fn demo() -> anyhow::Result<()> { +//! let cfg = ConnectConfig { +//! server: "relay.example.com:9000".into(), +//! token: "secret".into(), +//! tunnel: vec![ConnectTunnel { +//! name: "web".into(), +//! target: "127.0.0.1:8080".into(), +//! }], +//! reconnect_min_secs: 1, +//! reconnect_max_secs: 30, +//! }; +//! +//! let registry = Registry::new(); +//! let mut events = registry.subscribe(); +//! let shutdown = CancellationToken::new(); +//! let task = tokio::spawn(run_connect_with_shutdown(cfg, registry, shutdown.clone())); +//! +//! while let Ok(event) = events.recv().await { +//! if let Event::Error { message } = event { +//! eprintln!("tunnel: {message}"); +//! } +//! } +//! +//! shutdown.cancel(); +//! task.await??; +//! # Ok(()) +//! # } +//! ``` +//! +//! The command-line interface and terminal UI are in the separate `erbridge` +//! crate, so depending on this one pulls in neither. + +pub mod client; +pub mod config; +pub mod forward; +#[doc(hidden)] +pub mod mux; +#[doc(hidden)] +pub mod noise; +#[doc(hidden)] +pub mod proxy; +pub mod reverse; +pub mod sni; +pub mod stats; +pub mod tenant; +pub mod tenant_store; +#[doc(hidden)] +pub mod tls; diff --git a/src/mux.rs b/crates/erbridge-core/src/mux.rs similarity index 91% rename from src/mux.rs rename to crates/erbridge-core/src/mux.rs index 215149e..0983821 100644 --- a/src/mux.rs +++ b/crates/erbridge-core/src/mux.rs @@ -12,6 +12,7 @@ use futures::StreamExt; use futures::channel::{mpsc, oneshot}; use futures::io::{AsyncRead, AsyncWrite}; use tokio::sync::mpsc as tmpsc; +use tokio_util::sync::CancellationToken; use yamux::{Connection, ConnectionError, Stream}; pub use yamux::{Config as MuxConfig, Mode}; @@ -66,6 +67,19 @@ impl MuxControl { /// opening outbound streams plus a channel of inbound streams accepted from /// the peer. The driver task exits once the underlying connection closes. pub fn spawn(socket: T, mode: Mode) -> (MuxControl, tmpsc::UnboundedReceiver) +where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + spawn_with_shutdown(socket, mode, CancellationToken::new()) +} + +/// Like [`spawn`], but the driver also stops, dropping `socket`, once +/// `shutdown` is cancelled. +pub fn spawn_with_shutdown( + socket: T, + mode: Mode, + shutdown: CancellationToken, +) -> (MuxControl, tmpsc::UnboundedReceiver) where T: AsyncRead + AsyncWrite + Unpin + Send + 'static, { @@ -92,7 +106,9 @@ where Stop, } - let action = poll_fn(|cx| { + let action = tokio::select! { + _ = shutdown.cancelled() => Action::Stop, + action = poll_fn(|cx| { // Service one pending "open outbound stream" request, if any. if pending.front().is_some() && let Poll::Ready(result) = conn.poll_new_outbound(cx) @@ -128,8 +144,8 @@ where } Poll::Pending - }) - .await; + }) => action, + }; match action { Action::Continue => continue, diff --git a/src/noise.rs b/crates/erbridge-core/src/noise.rs similarity index 72% rename from src/noise.rs rename to crates/erbridge-core/src/noise.rs index 289d1d0..3e1addc 100644 --- a/src/noise.rs +++ b/crates/erbridge-core/src/noise.rs @@ -23,7 +23,7 @@ use std::task::{Context, Poll}; use anyhow::{Context as _, Result}; use sha2::{Digest, Sha256}; -use snow::{Builder, TransportState}; +use snow::{Builder, HandshakeState, TransportState}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use crate::reverse::{read_frame, write_frame}; @@ -35,11 +35,76 @@ const NOISE_PATTERN: &str = "Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s"; const MAX_PLAINTEXT: usize = 65535 - 16; const MAX_MESSAGE: usize = 65535; -/// Derives a 32-byte Noise PSK from an operator-facing token of any length. -pub fn derive_psk(token: &str) -> [u8; 32] { +/// A 32-byte Noise pre-shared key. +/// +/// A distinct type so a PSK cannot be mistaken for any other 32 bytes, cannot +/// be printed by accident ([`Debug`] redacts it), and is always compared in +/// constant time. +#[derive(Clone, Copy)] +pub struct Psk([u8; 32]); + +impl Psk { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl PartialEq for Psk { + fn eq(&self, other: &Self) -> bool { + crate::reverse::constant_time_eq(&self.0, &other.0) + } +} + +impl Eq for Psk {} + +impl std::hash::Hash for Psk { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +impl std::fmt::Debug for Psk { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Psk()") + } +} + +/// Stored as 64 lowercase hex characters, the form of the tenant file. +impl serde::Serialize for Psk { + fn serialize(&self, s: S) -> Result { + let mut out = String::with_capacity(64); + for b in &self.0 { + out.push_str(&format!("{b:02x}")); + } + s.serialize_str(&out) + } +} + +impl<'de> serde::Deserialize<'de> for Psk { + fn deserialize>(d: D) -> Result { + use serde::de::Error; + let text = String::deserialize(d)?; + if text.len() != 64 || !text.is_ascii() { + return Err(D::Error::custom("psk must be 64 hex characters")); + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[i * 2..i * 2 + 2], 16) + .map_err(|_| D::Error::custom("psk must be 64 hex characters"))?; + } + Ok(Self(out)) + } +} + +/// Derives a Noise PSK from an operator-facing token of any length. +pub fn derive_psk(token: &str) -> Psk { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); - hasher.finalize().into() + Psk(hasher.finalize().into()) } fn to_io_err(e: snow::Error) -> io::Error { @@ -49,13 +114,13 @@ fn to_io_err(e: snow::Error) -> io::Error { /// Runs the initiator side of the handshake over `io` and returns a /// transport-mode stream. Used by `connect` (dialling `serve`) and `client` /// (dialling a `transport = "noise"` forward mapping). -pub async fn connect(mut io: T, psk: &[u8; 32]) -> Result> +pub async fn connect(mut io: T, psk: &Psk) -> Result> where T: AsyncRead + AsyncWrite + Unpin, { let params = NOISE_PATTERN.parse().expect("valid static Noise params"); let mut hs = Builder::new(params) - .psk(0, psk) + .psk(0, psk.as_bytes()) .context("configuring Noise PSK")? .build_initiator() .context("building Noise initiator")?; @@ -83,23 +148,44 @@ where /// Runs the responder side of the handshake over `io` and returns a /// transport-mode stream. Used by `serve` (accepting `connect`) and `forward` /// (accepting a client on a `transport = "noise"` mapping). -pub async fn accept(mut io: T, psk: &[u8; 32]) -> Result> +pub async fn accept(io: T, psk: &Psk) -> Result> where T: AsyncRead + AsyncWrite + Unpin, { - let params = NOISE_PATTERN.parse().expect("valid static Noise params"); - let mut hs = Builder::new(params) - .psk(0, psk) - .context("configuring Noise PSK")? - .build_responder() - .context("building Noise responder")?; + let (stream, _) = accept_any(io, std::slice::from_ref(psk)).await?; + Ok(stream) +} - let mut buf = vec![0u8; MAX_MESSAGE]; +/// Like [`accept`], but for a responder that does not know in advance which of +/// several PSKs the initiator holds. Reads handshake message 1 once, then tries +/// each candidate on a fresh responder; the one whose AEAD tag verifies wins and +/// its index is returned alongside the stream. +/// +/// Every candidate is tried even after a match, so the time taken does not +/// reveal the matching position. On no match the connection is dropped without +/// a reply, exactly as for a single wrong PSK. +pub async fn accept_any(mut io: T, psks: &[Psk]) -> Result<(NoiseStream, usize)> +where + T: AsyncRead + AsyncWrite + Unpin, +{ let msg = read_frame(&mut io, MAX_MESSAGE) .await .context("reading Noise handshake message 1")?; - hs.read_message(&msg, &mut buf) - .context("processing Noise handshake message 1")?; + + let mut buf = vec![0u8; MAX_MESSAGE]; + let mut matched: Option<(usize, HandshakeState)> = None; + for (index, psk) in psks.iter().enumerate() { + let params = NOISE_PATTERN.parse().expect("valid static Noise params"); + let mut hs = Builder::new(params) + .psk(0, psk.as_bytes()) + .context("configuring Noise PSK")? + .build_responder() + .context("building Noise responder")?; + if hs.read_message(&msg, &mut buf).is_ok() && matched.is_none() { + matched = Some((index, hs)); + } + } + let (index, mut hs) = matched.context("processing Noise handshake message 1")?; let len = hs .write_message(&[], &mut buf) @@ -111,7 +197,7 @@ where let transport = hs .into_transport_mode() .context("entering Noise transport mode")?; - Ok(NoiseStream::new(io, transport)) + Ok((NoiseStream::new(io, transport), index)) } /// Accumulates an incoming length-prefixed ciphertext frame across however @@ -408,6 +494,62 @@ mod tests { assert_eq!(&buf, b"hello back"); } + #[tokio::test] + async fn accept_any_identifies_which_psk_matched() { + let psks = [derive_psk("one"), derive_psk("two"), derive_psk("three")]; + for (expected, token) in ["one", "two", "three"].iter().enumerate() { + let (a, b) = tokio::io::duplex(4096); + let client_psk = derive_psk(token); + let ((mut initiator, _), (mut responder, index)) = tokio::try_join!( + async { Ok::<_, anyhow::Error>((connect(a, &client_psk).await?, ())) }, + async { accept_any(b, &psks).await.map(|r| (r.0, r.1)) }, + ) + .unwrap(); + assert_eq!(index, expected); + + initiator.write_all(b"ping").await.unwrap(); + let mut buf = [0u8; 4]; + responder.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping"); + } + } + + #[tokio::test] + async fn accept_any_with_no_match_fails_and_writes_nothing() { + let (mut a, b) = tokio::io::duplex(4096); + let psks = [derive_psk("one"), derive_psk("two")]; + let wrong = derive_psk("nope"); + + // Drive the initiator's first message by hand so the socket can be + // inspected afterwards. + let params = NOISE_PATTERN.parse().unwrap(); + let mut hs = Builder::new(params) + .psk(0, wrong.as_bytes()) + .unwrap() + .build_initiator() + .unwrap(); + let mut buf = vec![0u8; MAX_MESSAGE]; + let len = hs.write_message(&[], &mut buf).unwrap(); + write_frame(&mut a, &buf[..len]).await.unwrap(); + + assert!(accept_any(b, &psks).await.is_err()); + + // `b` was consumed and dropped by the failed accept; the peer must see + // a plain close, not a reply frame. + let mut rest = Vec::new(); + a.read_to_end(&mut rest).await.unwrap(); + assert!(rest.is_empty(), "server replied to an unknown PSK"); + } + + #[tokio::test] + async fn accept_any_with_empty_candidates_fails() { + let (a, b) = tokio::io::duplex(4096); + let psk = derive_psk("x"); + let (client, server) = tokio::join!(connect(a, &psk), accept_any(b, &[])); + assert!(server.is_err()); + assert!(client.is_err()); + } + #[tokio::test] async fn large_write_is_chunked_and_reassembled() { let (a, b) = tokio::io::duplex(1 << 20); diff --git a/src/proxy.rs b/crates/erbridge-core/src/proxy.rs similarity index 100% rename from src/proxy.rs rename to crates/erbridge-core/src/proxy.rs diff --git a/src/reverse/connect.rs b/crates/erbridge-core/src/reverse/connect.rs similarity index 79% rename from src/reverse/connect.rs rename to crates/erbridge-core/src/reverse/connect.rs index cff0d67..8c21adc 100644 --- a/src/reverse/connect.rs +++ b/crates/erbridge-core/src/reverse/connect.rs @@ -5,6 +5,7 @@ use anyhow::{Context, Result, bail}; use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use tokio_util::sync::CancellationToken; use yamux::Stream as MuxStream; use crate::config::ConnectConfig; @@ -19,6 +20,17 @@ use crate::stats::{Protocol, Registry}; /// the stream's header frame. Reconnects with exponential backoff whenever /// the session ends, whether cleanly or on error. pub async fn run_connect(cfg: ConnectConfig, registry: Registry) -> Result<()> { + run_connect_with_shutdown(cfg, registry, CancellationToken::new()).await +} + +/// Like [`run_connect`], but returns `Ok(())` once `shutdown` is cancelled, +/// after the session and every in-flight tunnelled connection has been +/// stopped and its socket dropped. +pub async fn run_connect_with_shutdown( + cfg: ConnectConfig, + registry: Registry, + shutdown: CancellationToken, +) -> Result<()> { if cfg.tunnel.is_empty() { bail!("connect needs at least one [[connect.tunnel]] entry"); } @@ -36,7 +48,11 @@ pub async fn run_connect(cfg: ConnectConfig, registry: Registry) -> Result<()> { let mut backoff = min_backoff; loop { - match run_one_session(&cfg, &psk, &targets, ®istry).await { + let session = run_one_session(&cfg, &psk, &targets, ®istry, &shutdown); + let Some(outcome) = shutdown.run_until_cancelled(session).await else { + return Ok(()); + }; + match outcome { Ok(()) => { registry.info("connect: session ended, reconnecting".to_string()); backoff = min_backoff; @@ -47,15 +63,19 @@ pub async fn run_connect(cfg: ConnectConfig, registry: Registry) -> Result<()> { } } registry.info(format!("connect: retrying in {backoff}s")); - tokio::time::sleep(Duration::from_secs(backoff)).await; + let retry_wait = tokio::time::sleep(Duration::from_secs(backoff)); + if shutdown.run_until_cancelled(retry_wait).await.is_none() { + return Ok(()); + } } } async fn run_one_session( cfg: &ConnectConfig, - psk: &[u8; 32], + psk: &noise::Psk, targets: &HashMap, registry: &Registry, + shutdown: &CancellationToken, ) -> Result<()> { let tcp = TcpStream::connect(&cfg.server) .await @@ -67,12 +87,15 @@ async fn run_one_session( .context("Noise handshake with A failed")?; registry.info(format!("connect: connected to {}", cfg.server)); - let (_control, mut inbound_rx) = mux::spawn(noise_stream.compat(), mux::Mode::Server); + let (_control, mut inbound_rx) = + mux::spawn_with_shutdown(noise_stream.compat(), mux::Mode::Server, shutdown.clone()); while let Some(stream) = inbound_rx.recv().await { let targets = targets.clone(); let registry = registry.clone(); + let shutdown = shutdown.clone(); tokio::spawn(async move { - if let Err(e) = handle_inbound_stream(stream, targets, registry.clone()).await { + let inbound = handle_inbound_stream(stream, targets, registry.clone()); + if let Some(Err(e)) = shutdown.run_until_cancelled(inbound).await { registry.error(format!("connect: inbound stream error: {e:#}")); } }); diff --git a/crates/erbridge-core/src/reverse/enroll.rs b/crates/erbridge-core/src/reverse/enroll.rs new file mode 100644 index 0000000..9d8b609 --- /dev/null +++ b/crates/erbridge-core/src/reverse/enroll.rs @@ -0,0 +1,80 @@ +//! Client side of enrollment: trade a single-use code for the credentials of a +//! new tenant. +//! +//! The code is used as the Noise PSK, so the exchange is as private as any +//! other tunnel connection. `serve` burns the code, creates the tenant, and +//! sends the credentials as one frame inside the encrypted channel. If that +//! frame is lost in transit the code is already spent; ask for a new one. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::net::TcpStream; + +use crate::noise; +use crate::reverse::read_frame; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_CREDENTIALS_LEN: usize = 4096; + +/// What an enrolled client needs to run `connect`: the token to authenticate +/// with and the tunnel name `serve` will address it by. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credentials { + /// Tenant name; use it as the `[[connect.tunnel]]` name. + pub name: String, + /// Long-term secret; use it as the `connect` token. Shown once. + pub token: String, + /// The host this tenant is reached at. + pub sni: String, +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("name", &self.name) + .field("token", &"") + .field("sni", &self.sni) + .finish() + } +} + +impl Credentials { + pub fn to_toml(&self) -> Result { + toml::to_string_pretty(self).context("serialising credentials") + } + + pub fn from_toml(text: &str) -> Result { + toml::from_str(text).context("parsing credentials") + } +} + +/// Redeems `code` at the control address `server`. +pub async fn enroll(server: &str, code: &str) -> Result { + enroll_with_timeout(server, code, DEFAULT_TIMEOUT).await +} + +/// [`enroll`] with an explicit overall time limit. +pub async fn enroll_with_timeout( + server: &str, + code: &str, + timeout: Duration, +) -> Result { + let attempt = async { + let tcp = TcpStream::connect(server) + .await + .with_context(|| format!("connecting to {server}"))?; + let _ = tcp.set_nodelay(true); + let mut stream = noise::connect(tcp, &noise::derive_psk(code)) + .await + .context("enrollment handshake failed (is the code right?)")?; + let frame = read_frame(&mut stream, MAX_CREDENTIALS_LEN) + .await + .context("server did not return credentials")?; + serde_json::from_slice(&frame).context("server sent malformed credentials") + }; + tokio::time::timeout(timeout, attempt) + .await + .context("timed out waiting for enrollment")? +} diff --git a/src/reverse/mod.rs b/crates/erbridge-core/src/reverse/mod.rs similarity index 98% rename from src/reverse/mod.rs rename to crates/erbridge-core/src/reverse/mod.rs index 17ea5a5..e333e27 100644 --- a/src/reverse/mod.rs +++ b/crates/erbridge-core/src/reverse/mod.rs @@ -25,7 +25,9 @@ //! a full A<->B round trip in front of every new connection. pub mod connect; +pub mod enroll; pub mod serve; +pub mod tenant_serve; use std::io; diff --git a/src/reverse/serve.rs b/crates/erbridge-core/src/reverse/serve.rs similarity index 61% rename from src/reverse/serve.rs rename to crates/erbridge-core/src/reverse/serve.rs index 6bc122b..cbba898 100644 --- a/src/reverse/serve.rs +++ b/crates/erbridge-core/src/reverse/serve.rs @@ -1,10 +1,14 @@ use std::net::SocketAddr; use anyhow::{Context, Result, bail}; -use tokio::io::AsyncReadExt; +use std::sync::atomic::Ordering; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use crate::config::{ServeConfig, ServeTunnel}; use crate::mux::{self, MuxControl}; @@ -18,6 +22,20 @@ use crate::stats::{Protocol, Registry}; /// immediately and stay up across B reconnects; new external connections /// simply wait until a B session is active. pub async fn run_serve(cfg: ServeConfig, registry: Registry) -> Result<()> { + run_serve_with_shutdown(cfg, registry, CancellationToken::new()).await +} + +/// Like [`run_serve`], but returns `Ok(())` once `shutdown` is cancelled, +/// after every listener, control session and tunnelled connection has been +/// stopped and its socket dropped. +pub async fn run_serve_with_shutdown( + cfg: ServeConfig, + registry: Registry, + shutdown: CancellationToken, +) -> Result<()> { + if cfg.is_multi_tenant()? { + return crate::reverse::tenant_serve::run_tenant_serve(cfg, registry, shutdown).await; + } if cfg.tunnel.is_empty() { bail!("serve needs at least one [[serve.tunnel]] entry"); } @@ -30,10 +48,15 @@ pub async fn run_serve(cfg: ServeConfig, registry: Registry) -> Result<()> { for tunnel in cfg.tunnel.clone() { let current_rx = current_rx.clone(); let registry = registry.clone(); + let shutdown = shutdown.clone(); handles.push(tokio::spawn(async move { - if let Err(e) = - run_external_listener(tunnel.clone(), current_rx, registry.clone()).await - { + let listener = run_external_listener( + tunnel.clone(), + current_rx, + registry.clone(), + shutdown.clone(), + ); + if let Some(Err(e)) = shutdown.run_until_cancelled(listener).await { registry.error(format!( "serve: external listener '{}' stopped: {e:#}", tunnel.name @@ -51,24 +74,34 @@ pub async fn run_serve(cfg: ServeConfig, registry: Registry) -> Result<()> { )); handles.push(tokio::spawn(async move { - loop { - let (sock, peer) = match listener.accept().await { - Ok(v) => v, - Err(e) => { - registry.error(format!("serve: accept failed: {e:#}")); - continue; - } - }; - let registry = registry.clone(); - let current_tx = current_tx.clone(); - tokio::spawn(async move { - if let Err(e) = - handle_control_conn(sock, peer, psk, registry.clone(), current_tx).await - { - registry.error(format!("serve: control connection {peer}: {e:#}")); - } - }); - } + let accept_loop = async { + loop { + let (sock, peer) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + registry.error(format!("serve: accept failed: {e:#}")); + continue; + } + }; + let registry = registry.clone(); + let current_tx = current_tx.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + let conn = handle_control_conn( + sock, + peer, + psk, + registry.clone(), + current_tx, + shutdown.clone(), + ); + if let Some(Err(e)) = shutdown.run_until_cancelled(conn).await { + registry.error(format!("serve: control connection {peer}: {e:#}")); + } + }); + } + }; + shutdown.run_until_cancelled(accept_loop).await; })); for handle in handles { @@ -80,9 +113,10 @@ pub async fn run_serve(cfg: ServeConfig, registry: Registry) -> Result<()> { async fn handle_control_conn( sock: TcpStream, peer: SocketAddr, - psk: [u8; 32], + psk: noise::Psk, registry: Registry, current_tx: watch::Sender>, + shutdown: CancellationToken, ) -> Result<()> { if current_tx.borrow().is_some() { bail!("rejecting {peer}: a tunnel client is already connected"); @@ -94,7 +128,8 @@ async fn handle_control_conn( .context("Noise handshake with tunnel client failed")?; registry.info(format!("serve: tunnel client connected from {peer}")); - let (control, mut inbound_rx) = mux::spawn(noise_stream.compat(), mux::Mode::Client); + let (control, mut inbound_rx) = + mux::spawn_with_shutdown(noise_stream.compat(), mux::Mode::Client, shutdown); let _ = current_tx.send(Some(control)); // A never expects B to open streams back to it in this design; drain and @@ -111,6 +146,7 @@ async fn run_external_listener( tunnel: ServeTunnel, current_rx: watch::Receiver>, registry: Registry, + shutdown: CancellationToken, ) -> Result<()> { let listener = TcpListener::bind(&tunnel.external) .await @@ -125,11 +161,11 @@ async fn run_external_listener( let tunnel = tunnel.clone(); let mut current_rx = current_rx.clone(); let registry = registry.clone(); + let shutdown = shutdown.clone(); tokio::spawn(async move { - if let Err(e) = - handle_external_client(client, peer, tunnel.clone(), &mut current_rx, ®istry) - .await - { + let conn = + handle_external_client(client, peer, tunnel.clone(), &mut current_rx, ®istry); + if let Some(Err(e)) = shutdown.run_until_cancelled(conn).await { registry.error(format!("serve: tunnel '{}' {peer}: {e:#}", tunnel.name)); } }); @@ -143,8 +179,6 @@ async fn handle_external_client( current_rx: &mut watch::Receiver>, registry: &Registry, ) -> Result<()> { - let _ = client.set_nodelay(true); - let control = loop { if let Some(c) = current_rx.borrow().clone() { break c; @@ -154,33 +188,56 @@ async fn handle_external_client( .await .context("tunnel client control channel closed")?; }; + relay(client, peer, &control, &tunnel.name, &[], registry).await +} + +/// Relays one external connection through a tunnel session: opens a stream, +/// names the tunnel for B, replays `prefix` (bytes already read from `client`), +/// then pipes both directions until either side finishes. +pub(crate) async fn relay( + client: TcpStream, + peer: SocketAddr, + control: &MuxControl, + tunnel_name: &str, + prefix: &[u8], + registry: &Registry, +) -> Result<()> { + let _ = client.set_nodelay(true); let stream = control .open_stream() .await .context("opening mux stream to tunnel client")?; let mut compat = stream.compat(); - write_frame(&mut compat, tunnel.name.as_bytes()) + write_frame(&mut compat, tunnel_name.as_bytes()) .await .context("sending tunnel header")?; - let label = format!("reverse:{}", tunnel.name); + let label = format!("reverse:{tunnel_name}"); let info = registry.open( label, Protocol::Tcp, peer.to_string(), - format!("B:{}", tunnel.name), + format!("B:{tunnel_name}"), ); let (client_r, client_w) = tokio::io::split(client); - let (mut mux_r, mux_w) = tokio::io::split(compat); + let (mut mux_r, mut mux_w) = tokio::io::split(compat); // Start forwarding the client's request straight away instead of waiting // for B's reply first. The two directions are otherwise symmetric, but // gating this one would put a full A<->B round trip in front of every new // connection -- barely visible on loopback, and an entire link RTT on the // wide-area links this tunnel exists for. - let to_b = tokio::spawn(pump(client_r, mux_w, info.bytes_in.clone())); + let bytes_in = info.bytes_in.clone(); + let prefix = prefix.to_vec(); + let to_b = AbortOnDropHandle::new(tokio::spawn(async move { + if !prefix.is_empty() { + mux_w.write_all(&prefix).await?; + bytes_in.fetch_add(prefix.len() as u64, Ordering::Relaxed); + } + pump(client_r, mux_w, bytes_in).await + })); // B answers with one byte once it has dialled the target (see the note in // `connect::handle_inbound_stream`). It is consumed here, before anything @@ -193,7 +250,7 @@ async fn handle_external_client( .await .context("waiting for B to open the target connection")?; if ready[0] != 1 { - bail!("B could not reach the target for tunnel '{}'", tunnel.name); + bail!("B could not reach the target for tunnel '{tunnel_name}'"); } Ok::<(), anyhow::Error>(()) } @@ -208,7 +265,8 @@ async fn handle_external_client( return Err(e); } - let from_b = tokio::spawn(pump(mux_r, client_w, info.bytes_out.clone())); + let from_b = + AbortOnDropHandle::new(tokio::spawn(pump(mux_r, client_w, info.bytes_out.clone()))); let (to_b, from_b) = tokio::join!(to_b, from_b); registry.close(&info); to_b.context("client -> B copy task")??; diff --git a/crates/erbridge-core/src/reverse/tenant_serve.rs b/crates/erbridge-core/src/reverse/tenant_serve.rs new file mode 100644 index 0000000..3c752c6 --- /dev/null +++ b/crates/erbridge-core/src/reverse/tenant_serve.rs @@ -0,0 +1,547 @@ +//! Multi-tenant `serve`: many `connect` clients, each authenticated by its own +//! token and reached through its own SNI host. +//! +//! Two listeners run side by side: +//! +//! * the control listener accepts tunnel clients. The Noise handshake tries +//! every known PSK ([`noise::accept_any`]); whichever verifies names the +//! tenant, so a client never states who it is. +//! * the SNI listener accepts TLS connections, reads the ClientHello's SNI +//! without decrypting anything, and relays the connection through the +//! matching tenant's session. +//! +//! The tunnel name `connect` receives is always the tenant's own, decided +//! here, so a client cannot steer traffic at another tenant's target. + +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; +use tokio::io::AsyncWriteExt; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; +use tokio_util::compat::TokioAsyncReadCompatExt; +use tokio_util::sync::CancellationToken; + +use crate::config::ServeConfig; +use crate::mux::{self, MuxControl}; +use crate::noise; +use crate::noise::Psk; +use crate::reverse::enroll::Credentials; +use crate::reverse::serve::relay; +use crate::reverse::write_frame; +use crate::sni; +use crate::stats::Registry; +use crate::tenant::{self, EnrollCode, Identity, Tenant, TenantSet}; +use crate::tenant_store::{FileStore, TenantStore}; + +/// How long a TLS client gets to send its ClientHello. +const SNI_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long a control connection gets to complete the Noise handshake. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How many control connections may be mid-handshake at once. Each one costs a +/// trial decryption per known PSK, so the number is bounded. +const MAX_PENDING_HANDSHAKES: usize = 256; + +/// How many of those one address may hold, so a single host cannot use up +/// every slot. +const MAX_PENDING_PER_IP: usize = 16; + +/// How often the store is re-read for changes. +const DEFAULT_RELOAD_INTERVAL: Duration = Duration::from_secs(1); + +/// Where a store-backed multi-tenant `serve` listens, and how often it checks +/// the store for changes. +#[derive(Debug, Clone)] +pub struct TenantServeConfig { + /// Control address tunnel clients dial. + pub listen: String, + /// Address that accepts TLS connections and routes them by SNI. + pub sni_listen: String, + pub reload_interval: Duration, +} + +struct Session { + id: u64, + /// The PSK this session authenticated with, to notice a rotation. + psk: Psk, + control: MuxControl, + cancel: CancellationToken, +} + +/// Locks `mutex`, carrying on if another task panicked while holding it. The +/// guarded maps are only ever mutated by single calls, so they are never left +/// half-updated, and one panic must not take every later connection down. +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +struct Hub { + store: Arc, + set: watch::Sender>, + sessions: Mutex>, + next_session: AtomicU64, + /// Per-tenant connection budgets, as (capacity, semaphore). + budgets: Mutex)>>, + /// Slots for connections that have not finished authenticating. + handshakes: Arc, + /// Connections turned away because `handshakes` was exhausted. + refused: AtomicU64, + /// Connections mid-handshake, per source address. + pending_by_ip: Mutex>, +} + +/// Holds one handshake slot, global and per address, until dropped. +struct PendingSlot<'a> { + hub: &'a Hub, + ip: IpAddr, + _global: OwnedSemaphorePermit, +} + +impl Drop for PendingSlot<'_> { + fn drop(&mut self) { + let mut by_ip = lock(&self.hub.pending_by_ip); + if let Some(n) = by_ip.get_mut(&self.ip) { + *n -= 1; + if *n == 0 { + by_ip.remove(&self.ip); + } + } + } +} + +impl Hub { + /// Takes a handshake slot for `ip`, or `None` if the server or that + /// address is at its limit. + fn try_pending(&self, ip: IpAddr) -> Option> { + let global = self.handshakes.clone().try_acquire_owned().ok()?; + let mut by_ip = lock(&self.pending_by_ip); + let n = by_ip.entry(ip).or_insert(0); + if *n >= MAX_PENDING_PER_IP { + return None; + } + *n += 1; + drop(by_ip); + Some(PendingSlot { + hub: self, + ip, + _global: global, + }) + } +} + +impl Hub { + /// Takes one connection slot for `tenant`, or `None` if it is at its limit. + /// + /// A changed `max_conns` gets a fresh budget. Connections already open keep + /// holding slots of the old one, so during the switch a tenant can briefly + /// have a few more than either limit; that is the cost of not tracking + /// individual connections. + fn try_acquire(&self, tenant: &Tenant) -> Option { + let semaphore = { + let mut budgets = lock(&self.budgets); + let entry = budgets + .entry(tenant.name.clone()) + .or_insert_with(|| (tenant.max_conns, Arc::new(Semaphore::new(tenant.max_conns)))); + if entry.0 != tenant.max_conns { + *entry = (tenant.max_conns, Arc::new(Semaphore::new(tenant.max_conns))); + } + entry.1.clone() + }; + semaphore.try_acquire_owned().ok() + } + + fn snapshot(&self) -> Arc { + self.set.borrow().clone() + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(crate) async fn run_tenant_serve( + cfg: ServeConfig, + registry: Registry, + shutdown: CancellationToken, +) -> Result<()> { + let path = cfg + .tenants_file + .as_deref() + .context("tenants_file is not set")?; + let sni_listen = cfg.sni_listen.clone().context("sni_listen is not set")?; + let tcfg = TenantServeConfig { + listen: cfg.listen.clone(), + sni_listen, + reload_interval: DEFAULT_RELOAD_INTERVAL, + }; + run_tenant_serve_with_store(tcfg, Arc::new(FileStore::new(path)), registry, shutdown).await +} + +/// Runs multi-tenant `serve` against any [`TenantStore`]. Returns `Ok(())` once +/// `shutdown` is cancelled and every listener and session has been stopped. +pub async fn run_tenant_serve_with_store( + cfg: TenantServeConfig, + store: Arc, + registry: Registry, + shutdown: CancellationToken, +) -> Result<()> { + let initial = store.snapshot()?; + + let control_listener = TcpListener::bind(&cfg.listen) + .await + .with_context(|| format!("binding control listener on {}", cfg.listen))?; + let sni_listener = TcpListener::bind(&cfg.sni_listen) + .await + .with_context(|| format!("binding SNI listener on {}", cfg.sni_listen))?; + registry.info(format!( + "serve: multi-tenant, {} tenant(s); tunnel clients on {}, TLS by SNI on {}", + initial.tenants().len(), + cfg.listen, + cfg.sni_listen + )); + + let hub = Arc::new(Hub { + store, + set: watch::channel(Arc::new(initial)).0, + sessions: Mutex::new(HashMap::new()), + next_session: AtomicU64::new(1), + budgets: Mutex::new(HashMap::new()), + handshakes: Arc::new(Semaphore::new(MAX_PENDING_HANDSHAKES)), + refused: AtomicU64::new(0), + pending_by_ip: Mutex::new(HashMap::new()), + }); + + let control_loop = accept_loop(control_listener, &shutdown, ®istry, "control", { + let hub = hub.clone(); + let registry = registry.clone(); + let shutdown = shutdown.clone(); + move |sock, peer| { + let (hub, registry, shutdown) = (hub.clone(), registry.clone(), shutdown.clone()); + async move { handle_control(hub, sock, peer, ®istry, shutdown).await } + } + }); + let sni_loop = accept_loop(sni_listener, &shutdown, ®istry, "sni", { + let hub = hub.clone(); + let registry = registry.clone(); + move |sock, peer| { + let (hub, registry) = (hub.clone(), registry.clone()); + async move { handle_sni(hub, sock, peer, ®istry).await } + } + }); + let reload = reload_loop( + hub.clone(), + cfg.reload_interval, + registry.clone(), + shutdown.clone(), + ); + tokio::join!(control_loop, sni_loop, reload); + Ok(()) +} + +/// Accepts connections until `shutdown`, running `handler` for each in its own +/// task that also stops on shutdown. +async fn accept_loop( + listener: TcpListener, + shutdown: &CancellationToken, + registry: &Registry, + what: &'static str, + handler: F, +) where + F: Fn(TcpStream, SocketAddr) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let accepting = async { + loop { + let (sock, peer) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + registry.error(format!("serve: {what} accept failed: {e:#}")); + continue; + } + }; + let registry = registry.clone(); + let shutdown = shutdown.clone(); + let conn = handler(sock, peer); + tokio::spawn(async move { + if let Some(Err(e)) = shutdown.run_until_cancelled(conn).await { + registry.error(format!("serve: {what} connection {peer}: {e:#}")); + } + }); + } + }; + shutdown.run_until_cancelled(accepting).await; +} + +async fn handle_control( + hub: Arc, + sock: TcpStream, + peer: SocketAddr, + registry: &Registry, + shutdown: CancellationToken, +) -> Result<()> { + let _ = sock.set_nodelay(true); + let Some(pending) = hub.try_pending(peer.ip()) else { + // Under a flood this would be one log line per connection, so report + // the first and then every thousandth. + let n = hub.refused.fetch_add(1, Ordering::Relaxed); + if n == 0 || n % 1000 == 999 { + registry.error(format!( + "serve: {peer}: too many unauthenticated connections (server or per-address limit); refused {} so far", + n + 1 + )); + } + return Ok(()); + }; + let candidates = hub.snapshot().candidates(now_unix()); + let handshake = + tokio::time::timeout(HANDSHAKE_TIMEOUT, noise::accept_any(sock, &candidates.psks)) + .await + .context("Noise handshake timed out")?; + let (stream, index) = handshake.context("Noise handshake with tunnel client failed")?; + drop(pending); + + match candidates.identity(index).clone() { + Identity::Tenant(tenant) => { + run_session(&hub, tenant, stream, peer, registry, shutdown).await + } + Identity::Code(code) => run_enrollment(&hub, code, stream, peer, registry).await, + } +} + +async fn run_session( + hub: &Hub, + tenant: Tenant, + stream: noise::NoiseStream, + peer: SocketAddr, + registry: &Registry, + shutdown: CancellationToken, +) -> Result<()> { + let cancel = shutdown.child_token(); + let (control, mut inbound_rx) = + mux::spawn_with_shutdown(stream.compat(), mux::Mode::Client, cancel.clone()); + let own_cancel = cancel.clone(); + let id = hub.next_session.fetch_add(1, Ordering::Relaxed); + + // A new session takes over from any older one for the same tenant, so a + // client reconnecting after a network drop is not locked out until the + // dead connection times out. + let replaced = lock(&hub.sessions).insert( + tenant.name.clone(), + Session { + id, + psk: tenant.psk, + control, + cancel, + }, + ); + // `apply` publishes the new snapshot before it sweeps the sessions, so a + // tenant removed while this handshake was running is caught by one of the + // two: either the sweep sees this session, or this check sees the snapshot. + let still_allowed = hub + .snapshot() + .tenant(&tenant.name) + .is_some_and(|t| !t.disabled && t.psk == tenant.psk); + if !still_allowed { + own_cancel.cancel(); + } + if let Some(old) = replaced { + old.cancel.cancel(); + registry.info(format!( + "serve: tenant '{}' reconnected from {peer}, replacing its previous session", + tenant.name + )); + } else { + registry.info(format!( + "serve: tenant '{}' connected from {peer}", + tenant.name + )); + } + + // B never opens streams back to A; drain so the mux driver keeps making + // progress, ending once the connection (or this session) goes away. + while inbound_rx.recv().await.is_some() {} + + let mut sessions = lock(&hub.sessions); + if sessions.get(&tenant.name).is_some_and(|s| s.id == id) { + sessions.remove(&tenant.name); + } + drop(sessions); + registry.info(format!( + "serve: tenant '{}' disconnected ({peer})", + tenant.name + )); + Ok(()) +} + +async fn handle_sni( + hub: Arc, + mut client: TcpStream, + peer: SocketAddr, + registry: &Registry, +) -> Result<()> { + let peeked = sni::peek_sni(&mut client, SNI_TIMEOUT).await?; + let host = peeked.sni.ok_or_else(|| anyhow!("no SNI in ClientHello"))?; + + let tenant = hub + .snapshot() + .tenant_by_sni(&host) + .cloned() + .ok_or_else(|| anyhow!("no tenant for host '{host}'"))?; + let control = lock(&hub.sessions) + .get(&tenant.name) + .map(|s| s.control.clone()) + .ok_or_else(|| anyhow!("tenant '{}' is offline", tenant.name))?; + + // Held until the relay ends, which frees the slot. + let _slot = hub.try_acquire(&tenant).ok_or_else(|| { + anyhow!( + "tenant '{}' is at its limit of {} connections", + tenant.name, + tenant.max_conns + ) + })?; + + relay( + client, + peer, + &control, + &tenant.name, + &peeked.buffered, + registry, + ) + .await +} + +/// Re-reads the store every `interval` and applies what changed. A store that +/// cannot be read or parsed leaves the previous tenants in force; the error is +/// logged once per distinct message rather than on every tick. +async fn reload_loop( + hub: Arc, + interval: Duration, + registry: Registry, + shutdown: CancellationToken, +) { + let watching = async { + let mut last_error: Option = None; + loop { + tokio::time::sleep(interval).await; + let store = hub.store.clone(); + let read = tokio::task::spawn_blocking(move || store.snapshot()).await; + match read { + Ok(Ok(next)) => { + last_error = None; + if *hub.snapshot() != next { + apply(&hub, next, ®istry); + } + } + Ok(Err(e)) => { + let message = format!("{e:#}"); + if last_error.as_deref() != Some(message.as_str()) { + registry.error(format!( + "serve: keeping the previous tenants, could not reload: {message}" + )); + last_error = Some(message); + } + } + Err(e) => registry.error(format!("serve: tenant reload task failed: {e}")), + } + } + }; + shutdown.run_until_cancelled(watching).await; +} + +/// Publishes a new snapshot and ends the session of every tenant that is gone, +/// disabled or holds a different PSK now. Other sessions are not touched. +fn apply(hub: &Hub, next: TenantSet, registry: &Registry) { + let next = Arc::new(next); + hub.set.send_replace(next.clone()); + + let mut sessions = lock(&hub.sessions); + sessions.retain(|name, session| { + let still_valid = next + .tenant(name) + .is_some_and(|t| !t.disabled && t.psk == session.psk); + if !still_valid { + session.cancel.cancel(); + registry.info(format!( + "serve: tenant '{name}' no longer allowed, session ended" + )); + } + still_valid + }); + drop(sessions); + lock(&hub.budgets).retain(|name, _| next.tenant(name).is_some()); + registry.info(format!( + "serve: reloaded tenants, {} now configured", + next.tenants().len() + )); +} + +/// Redeems an enrollment code: creates the tenant, persists it, and sends the +/// credentials back inside the encrypted channel. The code is burned before the +/// credentials are sent, so a client that never receives them needs a new code; +/// the tenant created for it is withdrawn again. +async fn run_enrollment( + hub: &Hub, + code: EnrollCode, + mut stream: noise::NoiseStream, + peer: SocketAddr, + registry: &Registry, +) -> Result<()> { + let name = tenant::new_tenant_name(); + let token = tenant::new_token(); + let psk = noise::derive_psk(&token); + + let store = hub.store.clone(); + let (code_psk, now, new_name) = (code.psk, now_unix(), name.clone()); + let created = tokio::task::spawn_blocking(move || store.redeem(&code_psk, now, new_name, psk)) + .await + .context("enrollment task failed")??; + + // Let the new tenant connect right away instead of after the next reload. + let store = hub.store.clone(); + if let Ok(Ok(next)) = tokio::task::spawn_blocking(move || store.snapshot()).await { + apply(hub, next, registry); + } + + let credentials = Credentials { + name, + token, + sni: created.sni.clone(), + }; + let sent = async { + write_frame(&mut stream, &serde_json::to_vec(&credentials)?).await?; + stream.shutdown().await?; + anyhow::Ok(()) + } + .await; + if let Err(e) = sent { + // The client never got the token, so nobody can use this tenant: take + // it back rather than leave a live entry with no owner. + let store = hub.store.clone(); + let orphan = created.name.clone(); + if let Ok(Ok(())) = tokio::task::spawn_blocking(move || store.revoke(&orphan)).await { + let store = hub.store.clone(); + if let Ok(Ok(next)) = tokio::task::spawn_blocking(move || store.snapshot()).await { + apply(hub, next, registry); + } + } + return Err(e.context("sending credentials; the new tenant was withdrawn")); + } + registry.info(format!( + "serve: enrolled tenant '{}' for {} from {peer}", + created.name, created.sni + )); + Ok(()) +} diff --git a/crates/erbridge-core/src/sni.rs b/crates/erbridge-core/src/sni.rs new file mode 100644 index 0000000..9db632b --- /dev/null +++ b/crates/erbridge-core/src/sni.rs @@ -0,0 +1,273 @@ +//! Reads the SNI out of a TLS ClientHello without terminating TLS, so a router +//! can pick a destination and then replay the exact bytes it consumed. +//! +//! Only the first TLS record is examined, and the whole ClientHello must fit in +//! it. That holds for every mainstream client (the record limit is 16 KiB and +//! even post-quantum hellos are around 1.5 KiB); a hello fragmented across +//! records is rejected rather than reassembled. + +use std::net::IpAddr; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use tokio::io::{AsyncRead, AsyncReadExt}; + +const RECORD_HEADER_LEN: usize = 5; +/// TLS plaintext records are at most 2^14 bytes. +const MAX_RECORD_BODY: usize = 16 * 1024; +const HANDSHAKE_RECORD: u8 = 0x16; +const CLIENT_HELLO: u8 = 0x01; +const EXT_SERVER_NAME: u16 = 0; +const NAME_TYPE_HOST: u8 = 0; + +/// What was read from the stream, and the SNI found in it. +#[derive(Debug, PartialEq, Eq)] +pub struct Peeked { + /// `None` when the hello carries no (usable) server name. + pub sni: Option, + /// Every byte consumed from the stream, to be replayed to the destination. + pub buffered: Vec, +} + +/// Reads one TLS record from `r` and extracts the SNI. Gives up after +/// `timeout`, so a peer that stalls cannot hold the connection open. +pub async fn peek_sni(r: &mut R, timeout: Duration) -> Result { + tokio::time::timeout(timeout, read_record(r)) + .await + .context("timed out waiting for a TLS ClientHello")? + .and_then(|buffered| { + let sni = parse_record(&buffered)?; + Ok(Peeked { sni, buffered }) + }) +} + +async fn read_record(r: &mut R) -> Result> { + let mut buf = vec![0u8; RECORD_HEADER_LEN]; + r.read_exact(&mut buf) + .await + .context("reading TLS record header")?; + let body_len = check_header(&buf)?; + buf.resize(RECORD_HEADER_LEN + body_len, 0); + r.read_exact(&mut buf[RECORD_HEADER_LEN..]) + .await + .context("reading TLS record body")?; + Ok(buf) +} + +/// Validates a record header and returns the body length it announces. +fn check_header(header: &[u8]) -> Result { + if header[0] != HANDSHAKE_RECORD || header[1] != 3 { + bail!("not a TLS handshake record"); + } + let len = u16::from_be_bytes([header[3], header[4]]) as usize; + if len == 0 || len > MAX_RECORD_BODY { + bail!("implausible TLS record length {len}"); + } + Ok(len) +} + +/// Extracts the SNI from one complete TLS record (header included). Returns +/// `Ok(None)` for a well-formed hello without a usable host name. +pub fn parse_record(record: &[u8]) -> Result> { + if record.len() < RECORD_HEADER_LEN { + bail!("truncated TLS record header"); + } + let body_len = check_header(&record[..RECORD_HEADER_LEN])?; + let body = record + .get(RECORD_HEADER_LEN..RECORD_HEADER_LEN + body_len) + .context("truncated TLS record")?; + parse_handshake(body) +} + +fn parse_handshake(body: &[u8]) -> Result> { + let mut r = Reader(body); + if r.u8()? != CLIENT_HELLO { + bail!("first handshake message is not a ClientHello"); + } + let hello_len = r.u24()?; + let mut hello = Reader( + r.take(hello_len) + .context("ClientHello spans several records")?, + ); + + hello.take(2)?; // legacy_version + hello.take(32)?; // random + let session_id = hello.u8()? as usize; + hello.take(session_id)?; + let suites = hello.u16()? as usize; + hello.take(suites)?; + let compression = hello.u8()? as usize; + hello.take(compression)?; + + if hello.is_empty() { + return Ok(None); // a hello with no extensions at all + } + let ext_len = hello.u16()? as usize; + let mut exts = Reader(hello.take(ext_len)?); + while !exts.is_empty() { + let kind = exts.u16()?; + let len = exts.u16()? as usize; + let data = exts.take(len)?; + if kind == EXT_SERVER_NAME { + return parse_server_name(data); + } + } + Ok(None) +} + +fn parse_server_name(data: &[u8]) -> Result> { + let mut r = Reader(data); + let list_len = r.u16()? as usize; + let mut list = Reader(r.take(list_len)?); + while !list.is_empty() { + let name_type = list.u8()?; + let len = list.u16()? as usize; + let name = list.take(len)?; + if name_type == NAME_TYPE_HOST { + return Ok(host_name(name)); + } + } + Ok(None) +} + +/// A usable name is a DNS-style ASCII host; IP literals are not valid SNI. +fn host_name(raw: &[u8]) -> Option { + let name = std::str::from_utf8(raw).ok()?; + let valid = !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')); + (valid && name.parse::().is_err()).then(|| name.to_string()) +} + +/// Bounds-checked cursor; every read errors instead of panicking. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.0.len() < n { + bail!("truncated or malformed ClientHello"); + } + let (head, tail) = self.0.split_at(n); + self.0 = tail; + Ok(head) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + + fn u24(&mut self) -> Result { + let b = self.take(3)?; + Ok(((b[0] as usize) << 16) | ((b[1] as usize) << 8) | b[2] as usize) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustls::ClientConnection; + use rustls_pki_types::ServerName; + use tokio::io::AsyncWriteExt; + + /// The ClientHello record a real rustls client would send for `name`. + fn client_hello(name: &str) -> Vec { + crate::tls::install_crypto_provider(); + let config = crate::tls::client_tls_config().unwrap(); + let server_name = ServerName::try_from(name.to_string()).unwrap(); + let mut conn = ClientConnection::new(config, server_name).unwrap(); + let mut out = Vec::new(); + conn.write_tls(&mut out).unwrap(); + out + } + + #[test] + fn extracts_the_sni_from_a_real_client_hello() { + let hello = client_hello("abc123.example.com"); + assert_eq!( + parse_record(&hello).unwrap().as_deref(), + Some("abc123.example.com") + ); + } + + #[test] + fn an_ip_literal_yields_no_sni() { + let hello = client_hello("192.0.2.7"); + assert_eq!(parse_record(&hello).unwrap(), None); + } + + #[test] + fn non_tls_and_non_hello_input_is_rejected() { + assert!(parse_record(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n").is_err()); + let mut not_hello = client_hello("a.example.com"); + not_hello[RECORD_HEADER_LEN] = 0x02; // ServerHello + assert!(parse_record(¬_hello).is_err()); + let mut wrong_type = client_hello("a.example.com"); + wrong_type[0] = 0x17; // application data + assert!(parse_record(&wrong_type).is_err()); + } + + #[test] + fn every_truncation_is_an_error_not_a_panic() { + let hello = client_hello("abc123.example.com"); + for n in 0..hello.len() { + assert!(parse_record(&hello[..n]).is_err(), "prefix of {n} bytes"); + } + } + + #[test] + fn corrupting_any_single_byte_never_panics() { + let hello = client_hello("abc123.example.com"); + for i in 0..hello.len() { + for flip in [0x00, 0xff, 0x80, 0x01] { + let mut bad = hello.clone(); + bad[i] ^= flip; + let _ = parse_record(&bad); + } + } + } + + #[tokio::test] + async fn peek_returns_the_consumed_bytes_and_leaves_the_rest() { + let hello = client_hello("abc123.example.com"); + let (mut tx, mut rx) = tokio::io::duplex(65536); + tx.write_all(&hello).await.unwrap(); + tx.write_all(b"after the hello").await.unwrap(); + drop(tx); + + let peeked = peek_sni(&mut rx, Duration::from_secs(1)).await.unwrap(); + assert_eq!(peeked.sni.as_deref(), Some("abc123.example.com")); + assert_eq!(peeked.buffered, hello); + + let mut rest = Vec::new(); + rx.read_to_end(&mut rest).await.unwrap(); + assert_eq!(rest, b"after the hello"); + } + + #[tokio::test] + async fn an_oversized_record_is_rejected_before_its_body_is_read() { + let (mut tx, mut rx) = tokio::io::duplex(64); + // Header announcing 65535 bytes; nothing follows. + tx.write_all(&[0x16, 0x03, 0x01, 0xff, 0xff]).await.unwrap(); + let err = peek_sni(&mut rx, Duration::from_secs(1)).await.unwrap_err(); + assert!(err.to_string().contains("implausible"), "{err:#}"); + } + + #[tokio::test] + async fn a_silent_peer_times_out() { + let (_tx, mut rx) = tokio::io::duplex(64); + let err = peek_sni(&mut rx, Duration::from_millis(50)) + .await + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "{err:#}"); + } +} diff --git a/src/stats.rs b/crates/erbridge-core/src/stats.rs similarity index 82% rename from src/stats.rs rename to crates/erbridge-core/src/stats.rs index 8665b85..b062923 100644 --- a/src/stats.rs +++ b/crates/erbridge-core/src/stats.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::Serialize; +use tokio::sync::broadcast; pub type ConnId = u64; @@ -47,7 +48,7 @@ impl ConnectionInfo { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Event { ConnectionOpened { @@ -113,6 +114,10 @@ struct JsonLogLine<'a> { const MAX_LOG_LINES: usize = 500; +/// Events a slow subscriber may fall behind by before it starts losing the +/// oldest ones. +const EVENT_CHANNEL_CAPACITY: usize = 256; + struct Inner { next_id: AtomicU64, total_bytes_in: AtomicU64, @@ -121,6 +126,7 @@ struct Inner { live: Mutex>, log: Mutex>, log_file: Mutex>, + events: broadcast::Sender, } /// Shared registry of live connections and recent events, read by the TUI and, @@ -138,9 +144,18 @@ impl Registry { live: Mutex::new(Vec::new()), log: Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES)), log_file: Mutex::new(None), + events: broadcast::channel(EVENT_CHANNEL_CAPACITY).0, })) } + /// Subscribes to every event from now on, as structured values rather than + /// the formatted lines of [`Registry::recent_log`]. A subscriber that falls + /// more than a few hundred events behind gets `RecvError::Lagged` and + /// skips ahead. + pub fn subscribe(&self) -> broadcast::Receiver { + self.0.events.subscribe() + } + pub fn set_log_file(&self, path: PathBuf) -> anyhow::Result<()> { let file = OpenOptions::new().create(true).append(true).open(path)?; *self.0.log_file.lock().unwrap() = Some(file); @@ -223,6 +238,8 @@ impl Registry { } log.push_back(line); } + // `send` errs only when nobody is subscribed, which is fine. + let _ = self.0.events.send(event.clone()); if let Some(file) = self.0.log_file.lock().unwrap().as_mut() { let ts_unix_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -278,3 +295,35 @@ pub struct Totals { pub bytes_in: u64, pub bytes_out: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subscribers_receive_events_as_they_are_emitted() { + let registry = Registry::new(); + let mut events = registry.subscribe(); + + registry.info("hello"); + let conn = registry.open("web", Protocol::Tcp, "1.2.3.4:5", "B:web"); + registry.close(&conn); + + assert!(matches!(events.try_recv(), Ok(Event::Info { message }) if message == "hello")); + assert!(matches!( + events.try_recv(), + Ok(Event::ConnectionOpened { .. }) + )); + assert!(matches!( + events.try_recv(), + Ok(Event::ConnectionClosed { .. }) + )); + } + + #[test] + fn events_without_subscribers_are_not_an_error() { + let registry = Registry::new(); + registry.error("nobody is listening"); + assert_eq!(registry.recent_log().len(), 1); + } +} diff --git a/crates/erbridge-core/src/tenant.rs b/crates/erbridge-core/src/tenant.rs new file mode 100644 index 0000000..2ac0419 --- /dev/null +++ b/crates/erbridge-core/src/tenant.rs @@ -0,0 +1,514 @@ +//! Tenants and enrollment codes for a multi-tenant `serve`, and the snapshot +//! type the handshake and router consult. Nothing here touches a socket. +//! +//! A tenant is identified during the Noise handshake by which of the known +//! PSKs verifies (see [`crate::noise::accept_any`]); its `sni` is the routing +//! key for inbound TLS connections. Only the PSK (a hash of the token) is +//! stored, never the token itself. + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::noise::Psk; + +pub const DEFAULT_MAX_CONNS: usize = 64; + +fn default_max_conns() -> usize { + DEFAULT_MAX_CONNS +} + +/// Trims a host, lowercases it and drops one trailing dot: the form used for +/// matching. +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + host.strip_suffix('.').unwrap_or(host).to_ascii_lowercase() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tenant { + pub name: String, + pub psk: Psk, + pub sni: String, + #[serde(default = "default_max_conns")] + pub max_conns: usize, + #[serde(default)] + pub disabled: bool, +} + +/// A single-use PSK that can be redeemed for a new tenant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnrollCode { + pub psk: Psk, + pub sni: String, + #[serde(default = "default_max_conns")] + pub max_conns: usize, + /// Unix seconds after which the code no longer works. + #[serde(default)] + pub expires_at: Option, +} + +/// What a successful PSK match turned out to be. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Identity { + Tenant(Tenant), + Code(EnrollCode), +} + +/// The PSKs to try for one handshake, and what each one stands for. +#[derive(Debug, Clone, Default)] +pub struct Candidates { + pub psks: Vec, + ids: Vec, +} + +impl Candidates { + pub fn identity(&self, index: usize) -> &Identity { + &self.ids[index] + } +} + +/// An immutable, validated snapshot of every tenant and outstanding code. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TenantSet { + tenants: Vec, + codes: Vec, +} + +impl TenantSet { + /// Builds a snapshot, rejecting anything that would make identification or + /// routing ambiguous: duplicate names, duplicate hosts, a PSK shared by two + /// entries, an empty host, or a zero connection limit. + pub fn new(tenants: Vec, codes: Vec) -> Result { + let mut names = HashSet::new(); + let mut hosts = HashSet::new(); + let mut psks = HashSet::new(); + for t in &tenants { + if t.name.is_empty() { + bail!("tenant has an empty name"); + } + if !names.insert(t.name.clone()) { + bail!("duplicate tenant name '{}'", t.name); + } + check_entry(&t.sni, t.max_conns, &t.name)?; + if !hosts.insert(normalize_host(&t.sni)) { + bail!("duplicate sni '{}' (tenant '{}')", t.sni, t.name); + } + if !psks.insert(t.psk) { + bail!("tenant '{}' shares a PSK with another entry", t.name); + } + } + for c in &codes { + check_entry(&c.sni, c.max_conns, "enrollment code")?; + if !psks.insert(c.psk) { + bail!("an enrollment code shares a PSK with another entry"); + } + } + Ok(Self { tenants, codes }) + } + + pub fn tenants(&self) -> &[Tenant] { + &self.tenants + } + + pub fn codes(&self) -> &[EnrollCode] { + &self.codes + } + + pub fn tenant(&self, name: &str) -> Option<&Tenant> { + self.tenants.iter().find(|t| t.name == name) + } + + /// The enabled tenant routed by `sni`, if any. + pub fn tenant_by_sni(&self, sni: &str) -> Option<&Tenant> { + let wanted = normalize_host(sni); + self.tenants + .iter() + .find(|t| !t.disabled && normalize_host(&t.sni) == wanted) + } + + /// PSKs worth trying at `now_unix` seconds: enabled tenants and codes that + /// have not expired. + pub fn candidates(&self, now_unix: u64) -> Candidates { + let mut out = Candidates::default(); + for t in self.tenants.iter().filter(|t| !t.disabled) { + out.psks.push(t.psk); + out.ids.push(Identity::Tenant(t.clone())); + } + for c in self + .codes + .iter() + .filter(|c| c.expires_at.is_none_or(|at| now_unix < at)) + { + out.psks.push(c.psk); + out.ids.push(Identity::Code(c.clone())); + } + out + } +} + +fn check_entry(sni: &str, max_conns: usize, what: &str) -> Result<()> { + if normalize_host(sni).is_empty() { + bail!("{what} has an empty sni"); + } + if max_conns == 0 { + bail!("{what} has max_conns = 0"); + } + Ok(()) +} + +/// `bytes` random bytes from the OS-seeded CSPRNG, as lowercase hex. +fn random_hex(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::fill(&mut buf[..]); + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +/// A fresh tenant name, `t-` and 12 hex characters. Chosen by the server, never +/// by a client. +pub fn new_tenant_name() -> String { + format!("t-{}", random_hex(6)) +} + +/// A fresh long-term tenant token: 256 random bits as hex. +pub fn new_token() -> String { + random_hex(32) +} + +/// A fresh enrollment code (128 random bits as hex) and the entry that +/// redeems it, bound to `sni`. The code itself is shown to whoever enrolls and +/// is not stored. +pub fn new_enroll_code( + sni: &str, + max_conns: usize, + expires_at: Option, +) -> (String, EnrollCode) { + let code = random_hex(16); + let entry = EnrollCode { + psk: crate::noise::derive_psk(&code), + sni: sni.to_string(), + max_conns, + expires_at, + }; + (code, entry) +} + +/// Edits. Each returns a new validated set and leaves `self` untouched. +impl TenantSet { + pub fn with_tenant(&self, tenant: Tenant) -> Result { + let mut tenants = self.tenants.clone(); + tenants.push(tenant); + Self::new(tenants, self.codes.clone()) + } + + pub fn with_code(&self, code: EnrollCode) -> Result { + // A code for a host a tenant already holds could never be redeemed. + let host = normalize_host(&code.sni); + if let Some(t) = self.tenants.iter().find(|t| normalize_host(&t.sni) == host) { + bail!("sni '{}' already belongs to tenant '{}'", code.sni, t.name); + } + let mut codes = self.codes.clone(); + codes.push(code); + Self::new(self.tenants.clone(), codes) + } + + pub fn without_tenant(&self, name: &str) -> Result { + if self.tenant(name).is_none() { + bail!("no tenant named '{name}'"); + } + let tenants = self + .tenants + .iter() + .filter(|t| t.name != name) + .cloned() + .collect(); + Self::new(tenants, self.codes.clone()) + } + + pub fn with_disabled(&self, name: &str, disabled: bool) -> Result { + if self.tenant(name).is_none() { + bail!("no tenant named '{name}'"); + } + let mut tenants = self.tenants.clone(); + for t in tenants.iter_mut().filter(|t| t.name == name) { + t.disabled = disabled; + } + Self::new(tenants, self.codes.clone()) + } + + /// Burns the code whose PSK is `code_psk` and creates a tenant from it + /// under `name` with PSK `psk`. Fails, changing nothing, if there is no such + /// code or it has expired at `now_unix`. + pub fn redeem( + &self, + code_psk: &Psk, + now_unix: u64, + name: String, + psk: Psk, + ) -> Result<(Self, Tenant)> { + let code = self + .codes + .iter() + .find(|c| c.psk == *code_psk && c.expires_at.is_none_or(|at| now_unix < at)) + .context("unknown, used or expired enrollment code")?; + let tenant = Tenant { + name, + psk, + sni: code.sni.clone(), + max_conns: code.max_conns, + disabled: false, + }; + let codes = self + .codes + .iter() + // Expired codes can never be redeemed; drop them while here. + .filter(|c| c.psk != *code_psk && c.expires_at.is_none_or(|at| now_unix < at)) + .cloned() + .collect(); + let mut tenants = self.tenants.clone(); + tenants.push(tenant.clone()); + Ok((Self::new(tenants, codes)?, tenant)) + } +} + +/// On-disk shape of the tenant file. +#[derive(Debug, Default, Serialize, Deserialize)] +struct TenantFile { + #[serde(default)] + tenant: Vec, + #[serde(default)] + code: Vec, +} + +impl TenantSet { + pub fn from_toml(text: &str) -> Result { + let file: TenantFile = toml::from_str(text).context("parsing tenant file")?; + Self::new(file.tenant, file.code) + } + + pub fn to_toml(&self) -> Result { + let file = TenantFile { + tenant: self.tenants.clone(), + code: self.codes.clone(), + }; + toml::to_string_pretty(&file).context("serialising tenant file") + } + + /// Reads a tenant file. A missing file is an empty set, so a fresh + /// deployment can start `serve` before issuing anything. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => Self::from_toml(&text) + .with_context(|| format!("loading tenant file {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } +} + +/// Serde adapter storing a 32-byte PSK as 64 hex characters. +#[cfg(test)] +mod tests { + use super::*; + use crate::noise::derive_psk; + + fn tenant(name: &str, token: &str, sni: &str) -> Tenant { + Tenant { + name: name.into(), + psk: derive_psk(token), + sni: sni.into(), + max_conns: 8, + disabled: false, + } + } + + fn code(token: &str, sni: &str, expires_at: Option) -> EnrollCode { + EnrollCode { + psk: derive_psk(token), + sni: sni.into(), + max_conns: 8, + expires_at, + } + } + + #[test] + fn sni_lookup_ignores_case_and_trailing_dot() { + let set = TenantSet::new(vec![tenant("a", "ta", "Abc.Example.com")], vec![]).unwrap(); + assert_eq!(set.tenant_by_sni("abc.example.COM.").unwrap().name, "a"); + assert!(set.tenant_by_sni("other.example.com").is_none()); + } + + #[test] + fn disabled_tenants_are_neither_routed_nor_candidates() { + let mut t = tenant("a", "ta", "a.example.com"); + t.disabled = true; + let set = TenantSet::new(vec![t], vec![]).unwrap(); + assert!(set.tenant_by_sni("a.example.com").is_none()); + assert!(set.candidates(0).psks.is_empty()); + } + + #[test] + fn expired_codes_are_not_candidates() { + let set = TenantSet::new( + vec![], + vec![ + code("fresh", "a.x", Some(200)), + code("stale", "b.x", Some(100)), + ], + ) + .unwrap(); + let c = set.candidates(150); + assert_eq!(c.psks, vec![derive_psk("fresh")]); + assert!(matches!(c.identity(0), Identity::Code(_))); + assert!(set.candidates(100).psks.len() == 1, "expiry is exclusive"); + } + + #[test] + fn candidates_map_back_to_their_identity() { + let set = TenantSet::new( + vec![tenant("a", "ta", "a.x"), tenant("b", "tb", "b.x")], + vec![code("c1", "c.x", None)], + ) + .unwrap(); + let c = set.candidates(0); + let at = |token: &str| c.psks.iter().position(|p| *p == derive_psk(token)).unwrap(); + assert!(matches!(c.identity(at("tb")), Identity::Tenant(t) if t.name == "b")); + assert!(matches!(c.identity(at("c1")), Identity::Code(_))); + } + + #[test] + fn ambiguous_sets_are_rejected() { + let dup_name = TenantSet::new( + vec![tenant("a", "t1", "a.x"), tenant("a", "t2", "b.x")], + vec![], + ); + let dup_host = TenantSet::new( + vec![tenant("a", "t1", "A.x"), tenant("b", "t2", "a.X")], + vec![], + ); + let dup_psk = TenantSet::new( + vec![tenant("a", "same", "a.x"), tenant("b", "same", "b.x")], + vec![], + ); + let code_psk = TenantSet::new( + vec![tenant("a", "same", "a.x")], + vec![code("same", "z.x", None)], + ); + let empty_sni = TenantSet::new(vec![tenant("a", "t", " ")], vec![]); + let zero = { + let mut t = tenant("a", "t", "a.x"); + t.max_conns = 0; + TenantSet::new(vec![t], vec![]) + }; + for (what, r) in [ + ("duplicate name", dup_name), + ("duplicate host", dup_host), + ("duplicate psk", dup_psk), + ("code psk clash", code_psk), + ("empty sni", empty_sni), + ("zero max_conns", zero), + ] { + assert!(r.is_err(), "{what} should be rejected"); + } + } + + #[test] + fn toml_round_trip_stores_the_psk_as_hex() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct File { + tenant: Vec, + } + let original = File { + tenant: vec![tenant("a", "ta", "a.x")], + }; + let text = toml::to_string(&original).unwrap(); + assert!(text.contains(&format!( + "psk = \"{}\"", + hex_of(derive_psk("ta").as_bytes()) + ))); + assert!(!text.contains("\"ta\""), "the token must never be stored"); + assert_eq!(toml::from_str::(&text).unwrap(), original); + } + + #[test] + fn generated_names_tokens_and_codes_are_well_formed_and_unique() { + let mut seen = HashSet::new(); + for _ in 0..1000 { + let name = new_tenant_name(); + assert!(name.starts_with("t-") && name.len() == 14, "{name}"); + let token = new_token(); + assert_eq!(token.len(), 64); + let (code, entry) = new_enroll_code("a.x", 4, None); + assert_eq!(code.len(), 32); + assert_eq!(entry.psk, derive_psk(&code)); + assert!(seen.insert(name) && seen.insert(token) && seen.insert(code)); + } + } + + #[test] + fn malformed_psk_is_rejected_on_load() { + let bad = "name = \"a\"\npsk = \"zz\"\nsni = \"a.x\"\n"; + assert!(toml::from_str::(bad).is_err()); + } + + fn hex_of(b: &[u8; 32]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } +} + +#[cfg(test)] +mod redaction_tests { + use super::*; + + #[test] + fn debug_output_never_contains_the_psk() { + let tenant = Tenant { + name: "t".into(), + psk: Psk::from_bytes([0xab; 32]), + sni: "a.example.com".into(), + max_conns: 1, + disabled: false, + }; + let code = EnrollCode { + psk: Psk::from_bytes([0xcd; 32]), + sni: "a.example.com".into(), + max_conns: 1, + expires_at: None, + }; + let shown = format!("{tenant:?} {code:?}"); + assert!(shown.contains("")); + assert!(!shown.contains("171") && !shown.contains("205"), "{shown}"); + } +} + +#[cfg(test)] +mod prune_tests { + use super::*; + + #[test] + fn redeeming_drops_other_expired_codes() { + let code = |b: u8, expires_at| EnrollCode { + psk: Psk::from_bytes([b; 32]), + sni: format!("h{b}.example.com"), + max_conns: 1, + expires_at, + }; + let set = TenantSet::new( + vec![], + vec![code(1, None), code(2, Some(50)), code(3, Some(500))], + ) + .unwrap(); + let (next, _) = set + .redeem( + &Psk::from_bytes([1; 32]), + 100, + "t-x".into(), + Psk::from_bytes([9; 32]), + ) + .unwrap(); + let left: Vec = next.codes().iter().map(|c| c.psk.as_bytes()[0]).collect(); + assert_eq!(left, vec![3]); + } +} diff --git a/crates/erbridge-core/src/tenant_store.rs b/crates/erbridge-core/src/tenant_store.rs new file mode 100644 index 0000000..7ca1fe1 --- /dev/null +++ b/crates/erbridge-core/src/tenant_store.rs @@ -0,0 +1,326 @@ +//! Where tenants live. [`TenantStore`] is the seam a host can implement to keep +//! them somewhere other than a file (a database, say); `MemoryStore` and +//! `FileStore` are the two provided implementations. +//! +//! Every change is a read-modify-write of the whole [`TenantSet`], applied +//! atomically, so concurrent writers (`serve` redeeming a code while an +//! operator issues another) cannot lose each other's entries. + +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::noise::Psk; +use anyhow::{Context, Result}; + +use crate::tenant::{EnrollCode, Tenant, TenantSet}; + +/// The type of the closure [`TenantStore::replace_with`] applies. +pub type Edit<'a> = dyn FnMut(&TenantSet) -> Result + 'a; + +pub trait TenantStore: Send + Sync { + /// The current contents. + fn snapshot(&self) -> Result; + + /// Atomically replaces the contents with `edit(current)` and returns the + /// new contents. If `edit` fails nothing is written. + fn replace_with(&self, edit: &mut Edit<'_>) -> Result; + + fn add_tenant(&self, tenant: Tenant) -> Result<()> { + self.replace_with(&mut |s| s.with_tenant(tenant.clone())) + .map(drop) + } + + fn issue_code(&self, code: EnrollCode) -> Result<()> { + self.replace_with(&mut |s| s.with_code(code.clone())) + .map(drop) + } + + /// Removes a tenant outright; its PSK stops working and its session ends. + fn revoke(&self, name: &str) -> Result<()> { + self.replace_with(&mut |s| s.without_tenant(name)).map(drop) + } + + /// Suspends or resumes a tenant without forgetting it. + fn set_disabled(&self, name: &str, disabled: bool) -> Result<()> { + self.replace_with(&mut |s| s.with_disabled(name, disabled)) + .map(drop) + } + + /// Burns an enrollment code and creates the tenant it entitles. Exactly one + /// of several concurrent redemptions of the same code succeeds. + fn redeem(&self, code_psk: &Psk, now_unix: u64, name: String, psk: Psk) -> Result { + let mut created = None; + self.replace_with(&mut |s| { + let (next, tenant) = s.redeem(code_psk, now_unix, name.clone(), psk)?; + created = Some(tenant); + Ok(next) + })?; + Ok(created.expect("redeem succeeded without producing a tenant")) + } +} + +/// Keeps everything in memory; for tests and hosts that manage persistence +/// themselves. +#[derive(Default)] +pub struct MemoryStore(Mutex); + +impl MemoryStore { + pub fn new(initial: TenantSet) -> Self { + Self(Mutex::new(initial)) + } +} + +impl TenantStore for MemoryStore { + fn snapshot(&self) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + + fn replace_with(&self, edit: &mut Edit<'_>) -> Result { + let mut guard = self.0.lock().unwrap(); + let next = edit(&guard)?; + *guard = next.clone(); + Ok(next) + } +} + +/// A TOML file, safe to edit from several processes: writers take an exclusive +/// lock on a sidecar `.lock`, and replace the file by atomic rename so a +/// reader never sees a half-written one. +pub struct FileStore { + path: PathBuf, +} + +impl FileStore { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn sidecar(&self, suffix: &str) -> PathBuf { + let mut name = self.path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) + } +} + +impl TenantStore for FileStore { + fn snapshot(&self) -> Result { + TenantSet::load(&self.path) + } + + fn replace_with(&self, edit: &mut Edit<'_>) -> Result { + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(self.sidecar(".lock")) + .with_context(|| format!("opening lock file for {}", self.path.display()))?; + lock.lock().context("locking the tenant file")?; + + let current = TenantSet::load(&self.path)?; + let next = edit(¤t)?; + let text = next.to_toml()?; + + // Unique per writer, so a crashed writer's leftover cannot be mistaken + // for a live one's. + let tmp = self.sidecar(&format!(".tmp-{}", std::process::id())); + let written = write_private(&tmp, text.as_bytes()) + .with_context(|| format!("writing {}", tmp.display())) + .and_then(|()| { + std::fs::rename(&tmp, &self.path) + .with_context(|| format!("replacing {}", self.path.display())) + }); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + } + written?; + // The lock is released when `lock` drops. + Ok(next) + } +} + +/// Writes `bytes` to `path` readable by its owner only: the file holds PSKs. +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + // A leftover from a crashed writer may have looser permissions, and the + // mode below only applies to a file this call creates. + match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file: File = options.open(path)?; + file.write_all(bytes)?; + file.sync_all() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::noise::derive_psk; + use std::sync::Arc; + + fn tenant(name: &str, token: &str, sni: &str) -> Tenant { + Tenant { + name: name.into(), + psk: derive_psk(token), + sni: sni.into(), + max_conns: 8, + disabled: false, + } + } + + fn code(token: &str, sni: &str, expires_at: Option) -> EnrollCode { + EnrollCode { + psk: derive_psk(token), + sni: sni.into(), + max_conns: 5, + expires_at, + } + } + + /// The behaviour every implementation must share. + fn conformance(store: &dyn TenantStore) { + assert_eq!(store.snapshot().unwrap(), TenantSet::default()); + + store.add_tenant(tenant("a", "ta", "a.x")).unwrap(); + store.add_tenant(tenant("b", "tb", "b.x")).unwrap(); + assert!( + store.add_tenant(tenant("a", "other", "z.x")).is_err(), + "duplicate name" + ); + assert_eq!(store.snapshot().unwrap().tenants().len(), 2); + + store.set_disabled("a", true).unwrap(); + assert!(store.snapshot().unwrap().tenant("a").unwrap().disabled); + assert!(store.set_disabled("nope", true).is_err()); + + store.revoke("a").unwrap(); + assert!(store.snapshot().unwrap().tenant("a").is_none()); + assert!(store.snapshot().unwrap().tenant("b").is_some()); + assert!(store.revoke("a").is_err()); + + // Redeeming creates a tenant from the code's host and limit, and burns it. + store.issue_code(code("c1", "c.x", None)).unwrap(); + let made = store + .redeem(&derive_psk("c1"), 0, "t-new".into(), derive_psk("fresh")) + .unwrap(); + assert_eq!((made.sni.as_str(), made.max_conns), ("c.x", 5)); + let snap = store.snapshot().unwrap(); + assert!(snap.codes().is_empty()); + assert_eq!(snap.tenant("t-new").unwrap().psk, derive_psk("fresh")); + assert!( + store + .redeem(&derive_psk("c1"), 0, "t-again".into(), derive_psk("x")) + .is_err(), + "a code works once" + ); + + // Expired codes cannot be redeemed, and the failure changes nothing. + store.issue_code(code("old", "o.x", Some(100))).unwrap(); + let before = store.snapshot().unwrap(); + assert!( + store + .redeem(&derive_psk("old"), 100, "t-old".into(), derive_psk("y")) + .is_err() + ); + assert_eq!(store.snapshot().unwrap(), before); + } + + fn concurrent_writers_lose_nothing(store: Arc) { + let threads: Vec<_> = (0..8) + .map(|t| { + let store = store.clone(); + std::thread::spawn(move || { + for i in 0..10 { + let n = t * 10 + i; + store + .issue_code(code(&format!("tok-{n}"), &format!("h{n}.x"), None)) + .unwrap(); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + assert_eq!(store.snapshot().unwrap().codes().len(), 80); + } + + #[test] + fn memory_store_conforms() { + conformance(&MemoryStore::default()); + concurrent_writers_lose_nothing(Arc::new(MemoryStore::default())); + } + + #[test] + fn file_store_conforms() { + let dir = tempfile::tempdir().unwrap(); + conformance(&FileStore::new(dir.path().join("tenants.toml"))); + let dir = tempfile::tempdir().unwrap(); + concurrent_writers_lose_nothing(Arc::new(FileStore::new(dir.path().join("t.toml")))); + } + + #[test] + fn two_file_stores_on_one_file_do_not_lose_each_others_writes() { + // Stands in for `serve` and `serve-admin` writing from two processes. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.toml"); + let (a, b) = (FileStore::new(&path), FileStore::new(&path)); + let ta = std::thread::spawn(move || { + for i in 0..20 { + a.issue_code(code(&format!("a{i}"), &format!("a{i}.x"), None)) + .unwrap(); + } + }); + let tb = std::thread::spawn(move || { + for i in 0..20 { + b.issue_code(code(&format!("b{i}"), &format!("b{i}.x"), None)) + .unwrap(); + } + }); + ta.join().unwrap(); + tb.join().unwrap(); + assert_eq!(FileStore::new(&path).snapshot().unwrap().codes().len(), 40); + } + + #[test] + fn a_missing_file_is_an_empty_set_and_a_corrupt_one_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.toml"); + let store = FileStore::new(&path); + assert_eq!(store.snapshot().unwrap(), TenantSet::default()); + + std::fs::write(&path, "this is [not valid").unwrap(); + assert!(store.snapshot().is_err()); + assert!(store.add_tenant(tenant("a", "t", "a.x")).is_err()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "this is [not valid", + "a failed edit must not overwrite the file" + ); + } + + #[cfg(unix)] + #[test] + fn the_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.toml"); + FileStore::new(&path) + .add_tenant(tenant("a", "t", "a.x")) + .unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } +} diff --git a/src/tls.rs b/crates/erbridge-core/src/tls.rs similarity index 100% rename from src/tls.rs rename to crates/erbridge-core/src/tls.rs diff --git a/tests/client.rs b/crates/erbridge-core/tests/client.rs similarity index 97% rename from tests/client.rs rename to crates/erbridge-core/tests/client.rs index 5abe7cd..bbabd76 100644 --- a/tests/client.rs +++ b/crates/erbridge-core/tests/client.rs @@ -2,10 +2,10 @@ mod common; use std::time::Duration; -use erbridge::client::run_client; -use erbridge::config::{ClientRule, ForwardRule, ProtocolKind, Transport}; -use erbridge::forward::run_forward; -use erbridge::stats::Registry; +use erbridge_core::client::run_client; +use erbridge_core::config::{ClientRule, ForwardRule, ProtocolKind, Transport}; +use erbridge_core::forward::run_forward; +use erbridge_core::stats::Registry; #[tokio::test] async fn client_reaches_token_secured_forward_mapping() { diff --git a/crates/erbridge-core/tests/common/mod.rs b/crates/erbridge-core/tests/common/mod.rs new file mode 100644 index 0000000..0f2767e --- /dev/null +++ b/crates/erbridge-core/tests/common/mod.rs @@ -0,0 +1,204 @@ +#![allow(dead_code)] // each test binary only exercises a subset of these helpers + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, UdpSocket}; +use tokio_rustls::TlsConnector; + +/// Binds an ephemeral port, reads its number, then releases it. There's an +/// inherent (tiny) race between releasing and the caller rebinding, but it's +/// the standard way to get a free port for a test without threading the +/// bound listener itself through async task boundaries. +pub fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Accepts TCP connections forever, echoing every byte back until the peer +/// half-closes, then closing its own side too. +pub async fn run_tcp_echo_server(addr: SocketAddr) { + let listener = TcpListener::bind(addr).await.expect("bind echo server"); + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => continue, + }; + tokio::spawn(async move { + let (mut r, mut w) = tokio::io::split(stream); + let _ = tokio::io::copy(&mut r, &mut w).await; + }); + } +} + +/// Receives UDP datagrams forever and echoes each one back upper-cased, so +/// tests can distinguish "the target answered" from "the client's own bytes +/// looped back somewhere". +pub async fn run_udp_echo_server(addr: SocketAddr) { + let socket = UdpSocket::bind(addr).await.expect("bind udp echo server"); + let mut buf = vec![0u8; 4096]; + loop { + let Ok((n, peer)) = socket.recv_from(&mut buf).await else { + continue; + }; + let reply: Vec = buf[..n].to_ascii_uppercase(); + let _ = socket.send_to(&reply, peer).await; + } +} + +pub async fn tcp_roundtrip(addr: SocketAddr, payload: &[u8]) -> Vec { + let mut stream = tokio::net::TcpStream::connect(addr) + .await + .expect("connect to forwarded port"); + stream.write_all(payload).await.expect("write payload"); + stream.shutdown().await.expect("shutdown write half"); + let mut out = Vec::new(); + stream + .read_to_end(&mut out) + .await + .expect("read echoed payload"); + out +} + +/// Same as [`tcp_roundtrip`] but over TLS, accepting the forward listener's +/// self-signed cert the same way `connect` accepts `serve`'s (see `tls` +/// module docs: confidentiality, not peer identity). +pub async fn tls_roundtrip(addr: SocketAddr, payload: &[u8]) -> Vec { + erbridge_core::tls::install_crypto_provider(); + let connector = TlsConnector::from(erbridge_core::tls::client_tls_config().unwrap()); + let tcp = tokio::net::TcpStream::connect(addr) + .await + .expect("connect to forwarded port"); + let server_name = rustls_pki_types::ServerName::try_from("erbridge").unwrap(); + let mut stream = connector + .connect(server_name, tcp) + .await + .expect("tls handshake"); + stream.write_all(payload).await.expect("write payload"); + stream.shutdown().await.expect("shutdown write half"); + let mut out = Vec::new(); + stream + .read_to_end(&mut out) + .await + .expect("read echoed payload"); + out +} + +/// Accepts TCP connections forever, counting them, and holds each one open +/// without ever writing a byte back. Models a server that waits for the client +/// to speak first and has not answered yet -- the case where nothing on the +/// target side would prompt the tunnel to acknowledge its streams. +pub async fn run_silent_tcp_server(addr: SocketAddr, accepted: Arc) { + let listener = TcpListener::bind(addr).await.expect("bind silent server"); + let mut held = Vec::new(); + loop { + let Ok((stream, _)) = listener.accept().await else { + continue; + }; + accepted.fetch_add(1, Ordering::SeqCst); + held.push(stream); + } +} + +/// The TLS ClientHello a real rustls client would send for `name`. Enough to +/// exercise SNI routing without completing a handshake. +pub fn client_hello(name: &str) -> Vec { + use rustls_pki_types::ServerName; + erbridge_core::tls::install_crypto_provider(); + let config = erbridge_core::tls::client_tls_config().unwrap(); + let server_name = ServerName::try_from(name.to_string()).unwrap(); + let mut conn = rustls::ClientConnection::new(config, server_name).unwrap(); + let mut out = Vec::new(); + conn.write_tls(&mut out).unwrap(); + out +} + +/// Accepts TCP connections forever; for each, answers every read with `tag` +/// followed by the bytes read, so a test can tell which target it reached. +/// Counts accepted connections in `hits`. +pub async fn run_tagged_echo_server(addr: SocketAddr, tag: &'static str, hits: Arc) { + let listener = TcpListener::bind(addr).await.expect("bind tagged echo"); + loop { + let Ok((mut stream, _)) = listener.accept().await else { + continue; + }; + hits.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut reply = tag.as_bytes().to_vec(); + reply.extend_from_slice(&buf[..n]); + if stream.write_all(&reply).await.is_err() { + break; + } + } + } + } + }); + } +} + +/// A TLS server (self-signed) that answers each read with `tag` followed by the +/// bytes read, so a test can tell which target a real TLS client reached. +pub async fn run_tls_tagged_echo_server(addr: SocketAddr, tag: &'static str) { + erbridge_core::tls::install_crypto_provider(); + let cert = erbridge_core::tls::generate_self_signed().unwrap(); + let acceptor = + tokio_rustls::TlsAcceptor::from(erbridge_core::tls::server_tls_config(&cert).unwrap()); + let listener = TcpListener::bind(addr).await.expect("bind tls echo"); + loop { + let Ok((tcp, _)) = listener.accept().await else { + continue; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(tcp).await else { + return; + }; + let mut buf = vec![0u8; 8192]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut reply = tag.as_bytes().to_vec(); + reply.extend_from_slice(&buf[..n]); + if stream.write_all(&reply).await.is_err() { + break; + } + } + } + } + }); + } +} + +/// A TLS client for `sni` (connecting to `addr`) that completes the handshake +/// and returns the first reply to `payload`. Any failure is an `Err`, so a test +/// can assert that a connection is refused. +pub async fn tls_exchange(addr: SocketAddr, sni: &str, payload: &[u8]) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + erbridge_core::tls::install_crypto_provider(); + let attempt = async { + let connector = TlsConnector::from(erbridge_core::tls::client_tls_config().unwrap()); + let tcp = tokio::net::TcpStream::connect(addr).await?; + let name = rustls_pki_types::ServerName::try_from(sni.to_string()) + .map_err(|e| Error::new(ErrorKind::InvalidInput, e))?; + let mut stream = connector.connect(name, tcp).await?; + stream.write_all(payload).await?; + let mut buf = vec![0u8; 8192]; + let n = stream.read(&mut buf).await?; + buf.truncate(n); + Ok(buf) + }; + tokio::time::timeout(std::time::Duration::from_secs(3), attempt) + .await + .map_err(|_| Error::new(ErrorKind::TimedOut, "tls exchange timed out"))? +} diff --git a/tests/forward_tcp.rs b/crates/erbridge-core/tests/forward_tcp.rs similarity index 94% rename from tests/forward_tcp.rs rename to crates/erbridge-core/tests/forward_tcp.rs index c196146..14ba350 100644 --- a/tests/forward_tcp.rs +++ b/crates/erbridge-core/tests/forward_tcp.rs @@ -2,9 +2,9 @@ mod common; use std::time::Duration; -use erbridge::config::{ForwardRule, ProtocolKind}; -use erbridge::forward::run_forward; -use erbridge::stats::Registry; +use erbridge_core::config::{ForwardRule, ProtocolKind}; +use erbridge_core::forward::run_forward; +use erbridge_core::stats::Registry; #[tokio::test] async fn forwards_tcp_traffic_to_target() { @@ -52,7 +52,7 @@ async fn forwards_tls_tcp_traffic_to_plaintext_target() { target: format!("127.0.0.1:{target_port}"), protocol: ProtocolKind::Tcp, udp_idle_secs: 60, - transport: Some(erbridge::config::Transport::Tls), + transport: Some(erbridge_core::config::Transport::Tls), token: None, }; let registry = Registry::new(); diff --git a/tests/forward_udp.rs b/crates/erbridge-core/tests/forward_udp.rs similarity index 95% rename from tests/forward_udp.rs rename to crates/erbridge-core/tests/forward_udp.rs index 0b7daa2..522780e 100644 --- a/tests/forward_udp.rs +++ b/crates/erbridge-core/tests/forward_udp.rs @@ -2,9 +2,9 @@ mod common; use std::time::Duration; -use erbridge::config::{ForwardRule, ProtocolKind}; -use erbridge::forward::run_forward; -use erbridge::stats::Registry; +use erbridge_core::config::{ForwardRule, ProtocolKind}; +use erbridge_core::forward::run_forward; +use erbridge_core::stats::Registry; use tokio::net::UdpSocket; #[tokio::test] diff --git a/crates/erbridge-core/tests/multi_tenant.rs b/crates/erbridge-core/tests/multi_tenant.rs new file mode 100644 index 0000000..054feab --- /dev/null +++ b/crates/erbridge-core/tests/multi_tenant.rs @@ -0,0 +1,919 @@ +mod common; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use erbridge_core::config::{ConnectConfig, ConnectTunnel, ServeConfig}; +use erbridge_core::noise; +use erbridge_core::reverse::connect::run_connect_with_shutdown; +use erbridge_core::reverse::enroll::{Credentials, enroll_with_timeout}; +use erbridge_core::reverse::serve::run_serve_with_shutdown; +use erbridge_core::reverse::tenant_serve::{TenantServeConfig, run_tenant_serve_with_store}; +use erbridge_core::stats::Registry; +use erbridge_core::tenant::new_enroll_code; +use erbridge_core::tenant::{Tenant, TenantSet}; +use erbridge_core::tenant_store::{FileStore, TenantStore}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_util::sync::CancellationToken; + +struct Serve { + control: String, + sni: SocketAddr, + #[allow(dead_code)] + file: PathBuf, + shutdown: CancellationToken, + _dir: tempfile::TempDir, +} + +fn tenant(name: &str, token: &str, sni: &str) -> Tenant { + Tenant { + name: name.into(), + psk: noise::derive_psk(token), + sni: sni.into(), + max_conns: 8, + disabled: false, + } +} + +async fn start_serve(tenants: Vec) -> Serve { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("tenants.toml"); + std::fs::write( + &file, + TenantSet::new(tenants, vec![]).unwrap().to_toml().unwrap(), + ) + .unwrap(); + + let control_port = common::free_port(); + let sni_port = common::free_port(); + let shutdown = CancellationToken::new(); + let cfg = ServeConfig { + listen: format!("127.0.0.1:{control_port}"), + tenants_file: Some(file.display().to_string()), + sni_listen: Some(format!("127.0.0.1:{sni_port}")), + ..Default::default() + }; + tokio::spawn(run_serve_with_shutdown( + cfg, + Registry::new(), + shutdown.clone(), + )); + tokio::time::sleep(Duration::from_millis(100)).await; + Serve { + control: format!("127.0.0.1:{control_port}"), + sni: format!("127.0.0.1:{sni_port}").parse().unwrap(), + file, + shutdown, + _dir: dir, + } +} + +fn spawn_connect(serve: &Serve, token: &str, tunnels: &[(&str, SocketAddr)]) -> CancellationToken { + let stop = CancellationToken::new(); + let cfg = ConnectConfig { + server: serve.control.clone(), + token: token.into(), + tunnel: tunnels + .iter() + .map(|(name, target)| ConnectTunnel { + name: (*name).into(), + target: target.to_string(), + }) + .collect(), + reconnect_min_secs: 1, + reconnect_max_secs: 1, + }; + tokio::spawn(run_connect_with_shutdown( + cfg, + Registry::new(), + stop.clone(), + )); + stop +} + +async fn target(tag: &'static str) -> (SocketAddr, Arc) { + let addr: SocketAddr = format!("127.0.0.1:{}", common::free_port()) + .parse() + .unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + tokio::spawn(common::run_tagged_echo_server(addr, tag, hits.clone())); + tokio::time::sleep(Duration::from_millis(30)).await; + (addr, hits) +} + +/// Opens a connection to the SNI listener, sends a hello for `host`, and +/// returns whatever comes back until the far side closes or goes quiet. +async fn hello_and_read(serve: &Serve, host: &str, expect_at_least: usize) -> Vec { + let hello = common::client_hello(host); + let mut conn = TcpStream::connect(serve.sni).await.unwrap(); + conn.write_all(&hello).await.unwrap(); + let mut got = Vec::new(); + let mut buf = [0u8; 4096]; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + while got.len() < expect_at_least.max(1) { + match tokio::time::timeout_at(deadline, conn.read(&mut buf)).await { + Ok(Ok(0)) | Ok(Err(_)) | Err(_) => break, + Ok(Ok(n)) => got.extend_from_slice(&buf[..n]), + } + } + got +} + +async fn wait_until_online(serve: &Serve, host: &str, tag: &str) { + for _ in 0..50 { + let got = hello_and_read(serve, host, tag.len()).await; + if got.starts_with(tag.as_bytes()) { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("tenant at {host} never came online"); +} + +#[tokio::test] +async fn two_tenants_reach_only_their_own_targets() { + let serve = start_serve(vec![ + tenant("ta", "token-a", "a.example.com"), + tenant("tb", "token-b", "b.example.com"), + ]) + .await; + let (addr_a, hits_a) = target("A:").await; + let (addr_b, hits_b) = target("B:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr_a)]); + let _b = spawn_connect(&serve, "token-b", &[("tb", addr_b)]); + wait_until_online(&serve, "a.example.com", "A:").await; + wait_until_online(&serve, "b.example.com", "B:").await; + + let (a0, b0) = (hits_a.load(Ordering::SeqCst), hits_b.load(Ordering::SeqCst)); + assert!( + hello_and_read(&serve, "a.example.com", 2) + .await + .starts_with(b"A:") + ); + assert!( + hello_and_read(&serve, "b.example.com", 2) + .await + .starts_with(b"B:") + ); + assert!( + hello_and_read(&serve, "A.EXAMPLE.COM", 2) + .await + .starts_with(b"A:") + ); + assert_eq!(hits_a.load(Ordering::SeqCst) - a0, 2); + assert_eq!(hits_b.load(Ordering::SeqCst) - b0, 1); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_connect_cannot_pick_another_tenants_tunnel() { + let serve = start_serve(vec![ + tenant("ta", "token-a", "a.example.com"), + tenant("tb", "token-b", "b.example.com"), + ]) + .await; + let (real_a, _) = target("A:").await; + let (real_b, _) = target("B:").await; + let (decoy, decoy_hits) = target("DECOY:").await; + + // A's connect also declares a tunnel named after tenant B, pointing at a + // decoy. It must never be reached, because serve names the tunnel itself. + let _a = spawn_connect(&serve, "token-a", &[("ta", real_a), ("tb", decoy)]); + let _b = spawn_connect(&serve, "token-b", &[("tb", real_b)]); + wait_until_online(&serve, "a.example.com", "A:").await; + wait_until_online(&serve, "b.example.com", "B:").await; + + for _ in 0..3 { + assert!( + hello_and_read(&serve, "a.example.com", 2) + .await + .starts_with(b"A:") + ); + assert!( + hello_and_read(&serve, "b.example.com", 2) + .await + .starts_with(b"B:") + ); + } + assert_eq!(decoy_hits.load(Ordering::SeqCst), 0); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn wrong_tokens_are_closed_without_a_reply() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + + for bad in ["token-b", "", "token-a "] { + let mut sock = TcpStream::connect(&serve.control).await.unwrap(); + let psk = noise::derive_psk(bad); + // The initiator's first message goes out, then the server must just + // close: no reply frame that could confirm or deny a guess. + let attempt = noise::connect(&mut sock, &psk).await; + assert!(attempt.is_err(), "token {bad:?} was accepted"); + let mut rest = Vec::new(); + sock.read_to_end(&mut rest).await.unwrap(); + assert!(rest.is_empty()); + } + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_stalled_handshake_is_dropped() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + + // Connect and say nothing: the server must give up on its own. + let mut sock = TcpStream::connect(&serve.control).await.unwrap(); + let mut rest = Vec::new(); + let closed = tokio::time::timeout(Duration::from_secs(10), sock.read_to_end(&mut rest)).await; + assert!(closed.is_ok(), "server kept an idle handshake open"); + assert!(rest.is_empty()); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn one_address_cannot_hold_every_handshake_slot() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + + // 16 idle connections from one address fill its share... + let mut idle = Vec::new(); + for _ in 0..16 { + idle.push(TcpStream::connect(&serve.control).await.unwrap()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + + // ...so the next one is turned away at once, not after the timeout. + let mut extra = TcpStream::connect(&serve.control).await.unwrap(); + let mut rest = Vec::new(); + let closed = tokio::time::timeout(Duration::from_secs(2), extra.read_to_end(&mut rest)).await; + assert!(closed.is_ok(), "the 17th connection was kept waiting"); + drop(idle); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn unroutable_connections_are_closed_without_a_reply() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + + // Known tenant, but nobody has connected for it. + assert!(hello_and_read(&serve, "a.example.com", 1).await.is_empty()); + // Unknown host. + assert!( + hello_and_read(&serve, "nobody.example.com", 1) + .await + .is_empty() + ); + // Not TLS at all. + let mut conn = TcpStream::connect(serve.sni).await.unwrap(); + conn.write_all(b"GET / HTTP/1.1\r\nHost: a.example.com\r\n\r\n") + .await + .unwrap(); + let mut rest = Vec::new(); + // Unread request bytes make the server's close an RST, which is as good as + // a clean close here: what matters is that nothing was sent back. + let _ = tokio::time::timeout(Duration::from_secs(3), conn.read_to_end(&mut rest)) + .await + .expect("connection left open"); + assert!(rest.is_empty()); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn disabled_tenants_cannot_connect_or_be_reached() { + let mut disabled = tenant("td", "token-d", "d.example.com"); + disabled.disabled = true; + let serve = start_serve(vec![disabled]).await; + + let mut sock = TcpStream::connect(&serve.control).await.unwrap(); + assert!( + noise::connect(&mut sock, &noise::derive_psk("token-d")) + .await + .is_err() + ); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_second_session_replaces_the_first() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + let psk = noise::derive_psk("token-a"); + + let first = TcpStream::connect(&serve.control).await.unwrap(); + let mut first = noise::connect(first, &psk).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let second = TcpStream::connect(&serve.control).await.unwrap(); + let _second = noise::connect(second, &psk).await.unwrap(); + + // The first session is dropped by the server rather than the newcomer + // being refused. + let mut buf = [0u8; 16]; + let ended = tokio::time::timeout(Duration::from_secs(3), async { + loop { + match first.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(_) => continue, // yamux housekeeping frames + } + } + }) + .await; + assert!(ended.is_ok(), "old session stayed up"); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn mixing_single_and_multi_tenant_options_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("t.toml").display().to_string(); + let base = || ServeConfig { + listen: "127.0.0.1:0".into(), + ..Default::default() + }; + + let with_token = ServeConfig { + token: "x".into(), + tenants_file: Some(file.clone()), + sni_listen: Some("127.0.0.1:0".into()), + ..base() + }; + let no_sni = ServeConfig { + tenants_file: Some(file.clone()), + ..base() + }; + let sni_alone = ServeConfig { + sni_listen: Some("127.0.0.1:0".into()), + ..base() + }; + for cfg in [with_token, no_sni, sni_alone] { + let result = run_serve_with_shutdown(cfg, Registry::new(), CancellationToken::new()).await; + assert!(result.is_err()); + } +} + +#[tokio::test] +async fn shutdown_ends_sessions_and_frees_both_ports() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + let (addr_a, _) = target("A:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr_a)]); + wait_until_online(&serve, "a.example.com", "A:").await; + + serve.shutdown.cancel(); + tokio::time::sleep(Duration::from_millis(300)).await; + + let control_port: u16 = serve.control.rsplit(':').next().unwrap().parse().unwrap(); + std::net::TcpListener::bind(("127.0.0.1", control_port)).expect("control port still bound"); + std::net::TcpListener::bind(serve.sni).expect("sni port still bound"); +} + +/// Like `start_serve`, but through the store-backed entry point with a fast +/// reload interval, so tests of live changes do not wait a second for each. +async fn start_serve_fast_reload(tenants: Vec) -> (Serve, FileStore) { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("tenants.toml"); + let store = FileStore::new(&file); + for t in tenants { + store.add_tenant(t).unwrap(); + } + + let control_port = common::free_port(); + let sni_port = common::free_port(); + let shutdown = CancellationToken::new(); + let cfg = TenantServeConfig { + listen: format!("127.0.0.1:{control_port}"), + sni_listen: format!("127.0.0.1:{sni_port}"), + reload_interval: Duration::from_millis(50), + }; + tokio::spawn(run_tenant_serve_with_store( + cfg, + Arc::new(FileStore::new(&file)), + Registry::new(), + shutdown.clone(), + )); + tokio::time::sleep(Duration::from_millis(100)).await; + let serve = Serve { + control: format!("127.0.0.1:{control_port}"), + sni: format!("127.0.0.1:{sni_port}").parse().unwrap(), + file, + shutdown, + _dir: dir, + }; + (serve, store) +} + +async fn wait_until_offline(serve: &Serve, host: &str) { + for _ in 0..50 { + if hello_and_read(serve, host, 1).await.is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("tenant at {host} never went offline"); +} + +#[tokio::test] +async fn adding_a_tenant_while_serving_needs_no_restart() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let (addr, _) = target("N:").await; + let _c = spawn_connect(&serve, "token-n", &[("tn", addr)]); + + // Nobody by that name yet: the connect keeps retrying and is refused. + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(hello_and_read(&serve, "n.example.com", 1).await.is_empty()); + + store + .add_tenant(tenant("tn", "token-n", "n.example.com")) + .unwrap(); + wait_until_online(&serve, "n.example.com", "N:").await; + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn revoking_one_tenant_leaves_the_others_connections_alone() { + let (serve, store) = start_serve_fast_reload(vec![ + tenant("ta", "token-a", "a.example.com"), + tenant("tb", "token-b", "b.example.com"), + ]) + .await; + let (addr_a, _) = target("A:").await; + let (addr_b, _) = target("B:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr_a)]); + let _b = spawn_connect(&serve, "token-b", &[("tb", addr_b)]); + wait_until_online(&serve, "a.example.com", "A:").await; + wait_until_online(&serve, "b.example.com", "B:").await; + + // A connection to B that stays open across the revocation. + let mut held = TcpStream::connect(serve.sni).await.unwrap(); + held.write_all(&common::client_hello("b.example.com")) + .await + .unwrap(); + let mut buf = vec![0u8; 4096]; + assert!(held.read(&mut buf).await.unwrap() > 0); + + store.revoke("ta").unwrap(); + wait_until_offline(&serve, "a.example.com").await; + + held.write_all(b"still there").await.unwrap(); + let n = tokio::time::timeout(Duration::from_secs(3), held.read(&mut buf)) + .await + .expect("B's connection stalled") + .unwrap(); + assert!(buf[..n].starts_with(b"B:still there")); + assert!( + hello_and_read(&serve, "b.example.com", 2) + .await + .starts_with(b"B:") + ); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn disabling_ends_the_session_and_enabling_brings_it_back() { + let (serve, store) = + start_serve_fast_reload(vec![tenant("ta", "token-a", "a.example.com")]).await; + let (addr, _) = target("A:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr)]); + wait_until_online(&serve, "a.example.com", "A:").await; + + store.set_disabled("ta", true).unwrap(); + wait_until_offline(&serve, "a.example.com").await; + + store.set_disabled("ta", false).unwrap(); + wait_until_online(&serve, "a.example.com", "A:").await; + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn rotating_a_tenants_token_ends_the_old_session() { + let (serve, store) = + start_serve_fast_reload(vec![tenant("ta", "old-token", "a.example.com")]).await; + let (addr, _) = target("A:").await; + let _old = spawn_connect(&serve, "old-token", &[("ta", addr)]); + wait_until_online(&serve, "a.example.com", "A:").await; + + store + .replace_with(&mut |set| { + set.without_tenant("ta")? + .with_tenant(tenant("ta", "new-token", "a.example.com")) + }) + .unwrap(); + wait_until_offline(&serve, "a.example.com").await; + + let _new = spawn_connect(&serve, "new-token", &[("ta", addr)]); + wait_until_online(&serve, "a.example.com", "A:").await; + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_corrupt_file_keeps_the_previous_tenants_until_it_is_fixed() { + let (serve, store) = + start_serve_fast_reload(vec![tenant("tb", "token-b", "b.example.com")]).await; + let (addr_b, _) = target("B:").await; + let _b = spawn_connect(&serve, "token-b", &[("tb", addr_b)]); + wait_until_online(&serve, "b.example.com", "B:").await; + + std::fs::write(&serve.file, "[[tenant]\nbroken").unwrap(); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + hello_and_read(&serve, "b.example.com", 2) + .await + .starts_with(b"B:") + ); + + // Repairing the file with a new tenant added is picked up. + std::fs::write( + &serve.file, + TenantSet::new( + vec![ + tenant("tb", "token-b", "b.example.com"), + tenant("tc", "token-c", "c.example.com"), + ], + vec![], + ) + .unwrap() + .to_toml() + .unwrap(), + ) + .unwrap(); + let (addr_c, _) = target("C:").await; + let _c = spawn_connect(&serve, "token-c", &[("tc", addr_c)]); + wait_until_online(&serve, "c.example.com", "C:").await; + let _ = store; + serve.shutdown.cancel(); +} + +fn limited(name: &str, token: &str, sni: &str, max_conns: usize) -> Tenant { + Tenant { + max_conns, + ..tenant(name, token, sni) + } +} + +/// Opens a connection to `host` and waits for the first reply, so the relay is +/// known to be established. `None` if the server closed it instead. +async fn open_held(serve: &Serve, host: &str) -> Option { + let mut conn = TcpStream::connect(serve.sni).await.unwrap(); + conn.write_all(&common::client_hello(host)).await.unwrap(); + let mut buf = [0u8; 4096]; + match tokio::time::timeout(Duration::from_secs(2), conn.read(&mut buf)).await { + Ok(Ok(n)) if n > 0 => Some(conn), + _ => None, + } +} + +/// `open_held`, retried while earlier probe connections finish closing and +/// give their slots back. +async fn open_held_when_free(serve: &Serve, host: &str) -> TcpStream { + for _ in 0..30 { + if let Some(conn) = open_held(serve, host).await { + return conn; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("no connection slot became free for {host}"); +} + +#[tokio::test] +async fn connections_beyond_max_conns_are_closed_and_a_freed_slot_is_reusable() { + let (serve, _store) = start_serve_fast_reload(vec![ + limited("ta", "token-a", "a.example.com", 2), + limited("tb", "token-b", "b.example.com", 2), + ]) + .await; + let (addr_a, _) = target("A:").await; + let (addr_b, _) = target("B:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr_a)]); + let _b = spawn_connect(&serve, "token-b", &[("tb", addr_b)]); + wait_until_online(&serve, "a.example.com", "A:").await; + wait_until_online(&serve, "b.example.com", "B:").await; + + let first = open_held_when_free(&serve, "a.example.com").await; + let _second = open_held_when_free(&serve, "a.example.com").await; + assert!( + open_held(&serve, "a.example.com").await.is_none(), + "third connection should be refused" + ); + + // Another tenant has its own budget. + assert!(open_held(&serve, "b.example.com").await.is_some()); + + // Freeing a slot makes room again. + drop(first); + let mut reused = None; + for _ in 0..30 { + reused = open_held(&serve, "a.example.com").await; + if reused.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(reused.is_some(), "slot was never freed"); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_changed_limit_applies_to_new_connections_after_reload() { + let (serve, store) = + start_serve_fast_reload(vec![limited("ta", "token-a", "a.example.com", 1)]).await; + let (addr, _) = target("A:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", addr)]); + wait_until_online(&serve, "a.example.com", "A:").await; + + let _held = open_held_when_free(&serve, "a.example.com").await; + assert!(open_held(&serve, "a.example.com").await.is_none()); + + store + .replace_with(&mut |set| { + set.without_tenant("ta")? + .with_tenant(limited("ta", "token-a", "a.example.com", 3)) + }) + .unwrap(); + // Same name and PSK, so the session survives; give the reload a moment. + tokio::time::sleep(Duration::from_millis(300)).await; + let _one = open_held_when_free(&serve, "a.example.com").await; + let _two = open_held_when_free(&serve, "a.example.com").await; + let _three = open_held_when_free(&serve, "a.example.com").await; + assert!(open_held(&serve, "a.example.com").await.is_none()); + serve.shutdown.cancel(); +} + +/// Issues a code and waits out a few reload intervals, the way a real code is +/// only used some seconds after it is issued. +async fn issue_and_settle(store: &FileStore, entry: erbridge_core::tenant::EnrollCode) { + store.issue_code(entry).unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; +} + +async fn enroll_now(serve: &Serve, code: &str) -> anyhow::Result { + enroll_with_timeout(&serve.control, code, Duration::from_secs(3)).await +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[tokio::test] +async fn a_valid_code_yields_credentials_that_then_connect_as_the_new_tenant() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let (code, entry) = new_enroll_code("new.example.com", 4, Some(unix_now() + 60)); + issue_and_settle(&store, entry).await; + + let creds = enroll_now(&serve, &code).await.unwrap(); + assert!(creds.name.starts_with("t-")); + assert_eq!(creds.sni, "new.example.com"); + + // Persisted: the tenant exists and the code is gone. + let snap = store.snapshot().unwrap(); + assert_eq!(snap.tenant(&creds.name).unwrap().max_conns, 4); + assert!(snap.codes().is_empty()); + + let (addr, _) = target("NEW:").await; + let _c = spawn_connect(&serve, &creds.token, &[(&creds.name, addr)]); + wait_until_online(&serve, "new.example.com", "NEW:").await; + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn a_code_works_once_and_not_after_it_expires() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let (code, entry) = new_enroll_code("once.example.com", 4, None); + issue_and_settle(&store, entry).await; + assert!(enroll_now(&serve, &code).await.is_ok()); + assert!(enroll_now(&serve, &code).await.is_err(), "second use"); + + let (expired, entry) = new_enroll_code("late.example.com", 4, Some(1)); + issue_and_settle(&store, entry).await; + assert!(enroll_now(&serve, &expired).await.is_err(), "expired"); + + assert_eq!(store.snapshot().unwrap().tenants().len(), 1); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn racing_redemptions_of_one_code_produce_exactly_one_tenant() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let (code, entry) = new_enroll_code("race.example.com", 4, None); + issue_and_settle(&store, entry).await; + + let attempts: Vec<_> = (0..6) + .map(|_| { + let (control, code) = (serve.control.clone(), code.clone()); + tokio::spawn(async move { + enroll_with_timeout(&control, &code, Duration::from_secs(3)).await + }) + }) + .collect(); + let mut wins = 0; + for a in attempts { + if a.await.unwrap().is_ok() { + wins += 1; + } + } + assert_eq!(wins, 1); + assert_eq!(store.snapshot().unwrap().tenants().len(), 1); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn codes_and_tenant_tokens_are_not_interchangeable() { + let (serve, store) = + start_serve_fast_reload(vec![tenant("ta", "token-a", "a.example.com")]).await; + let (code, entry) = new_enroll_code("fresh.example.com", 4, None); + issue_and_settle(&store, entry).await; + + // A tenant's token does not enroll anyone. + assert!(enroll_now(&serve, "token-a").await.is_err()); + assert_eq!(store.snapshot().unwrap().tenants().len(), 1); + + // A code never opens a session: nothing becomes routable for its host. + let (addr, _) = target("X:").await; + let _c = spawn_connect(&serve, &code, &[("anything", addr)]); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + hello_and_read(&serve, "fresh.example.com", 1) + .await + .is_empty() + ); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn many_enrollments_get_distinct_names_and_tokens() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let mut names = std::collections::HashSet::new(); + let mut tokens = std::collections::HashSet::new(); + for i in 0..15 { + let (code, entry) = new_enroll_code(&format!("h{i}.example.com"), 4, None); + issue_and_settle(&store, entry).await; + let creds = enroll_now(&serve, &code).await.unwrap(); + assert!(names.insert(creds.name)); + assert!(tokens.insert(creds.token)); + } + assert_eq!(store.snapshot().unwrap().tenants().len(), 15); + serve.shutdown.cancel(); +} + +// ---- end to end with real TLS clients and servers ---- + +async fn tls_target(tag: &'static str) -> SocketAddr { + let addr: SocketAddr = format!("127.0.0.1:{}", common::free_port()) + .parse() + .unwrap(); + tokio::spawn(common::run_tls_tagged_echo_server(addr, tag)); + tokio::time::sleep(Duration::from_millis(50)).await; + addr +} + +/// Retries a TLS exchange until the tenant is reachable. +async fn tls_when_online(serve: &Serve, sni: &str, payload: &[u8]) -> Vec { + for _ in 0..50 { + if let Ok(reply) = common::tls_exchange(serve.sni, sni, payload).await { + return reply; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("no TLS reply from {sni}"); +} + +#[tokio::test] +async fn real_tls_clients_reach_their_own_tls_targets() { + let serve = start_serve(vec![ + tenant("ta", "token-a", "a.example.com"), + tenant("tb", "token-b", "b.example.com"), + ]) + .await; + let (addr_a, addr_b) = (tls_target("A:").await, tls_target("B:").await); + let _a = spawn_connect(&serve, "token-a", &[("ta", addr_a)]); + let _b = spawn_connect(&serve, "token-b", &[("tb", addr_b)]); + + assert_eq!( + tls_when_online(&serve, "a.example.com", b"hi").await, + b"A:hi" + ); + assert_eq!( + tls_when_online(&serve, "b.example.com", b"yo").await, + b"B:yo" + ); + // A host nobody owns fails the handshake rather than reaching anyone. + assert!( + common::tls_exchange(serve.sni, "c.example.com", b"x") + .await + .is_err() + ); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn enroll_connect_revoke_and_enroll_again() { + let (serve, store) = start_serve_fast_reload(vec![]).await; + let target = tls_target("T:").await; + + let (code, entry) = new_enroll_code("life.example.com", 4, None); + issue_and_settle(&store, entry).await; + let creds = enroll_now(&serve, &code).await.unwrap(); + let first = spawn_connect(&serve, &creds.token, &[(&creds.name, target)]); + assert_eq!( + tls_when_online(&serve, "life.example.com", b"1").await, + b"T:1" + ); + + store.revoke(&creds.name).unwrap(); + first.cancel(); + wait_until_offline(&serve, "life.example.com").await; + assert!( + common::tls_exchange(serve.sni, "life.example.com", b"2") + .await + .is_err() + ); + // The old token is dead for good, once serve has reloaded. + tokio::time::sleep(Duration::from_millis(300)).await; + let mut sock = TcpStream::connect(&serve.control).await.unwrap(); + assert!( + noise::connect(&mut sock, &noise::derive_psk(&creds.token)) + .await + .is_err() + ); + + // The host is free again: a new code gives a new tenant on the same host. + let (code2, entry2) = new_enroll_code("life.example.com", 4, None); + issue_and_settle(&store, entry2).await; + let creds2 = enroll_now(&serve, &code2).await.unwrap(); + assert_ne!(creds2.name, creds.name); + assert_ne!(creds2.token, creds.token); + let _second = spawn_connect(&serve, &creds2.token, &[(&creds2.name, target)]); + assert_eq!( + tls_when_online(&serve, "life.example.com", b"3").await, + b"T:3" + ); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn restarting_a_connect_resumes_service() { + let serve = start_serve(vec![tenant("ta", "token-a", "a.example.com")]).await; + let target = tls_target("A:").await; + + let first = spawn_connect(&serve, "token-a", &[("ta", target)]); + assert_eq!(tls_when_online(&serve, "a.example.com", b"1").await, b"A:1"); + first.cancel(); + tokio::time::sleep(Duration::from_millis(200)).await; + + let _second = spawn_connect(&serve, "token-a", &[("ta", target)]); + assert_eq!(tls_when_online(&serve, "a.example.com", b"2").await, b"A:2"); + serve.shutdown.cancel(); +} + +#[tokio::test] +async fn the_connection_limit_holds_for_real_tls_clients() { + let (serve, _store) = + start_serve_fast_reload(vec![limited("ta", "token-a", "a.example.com", 1)]).await; + let target = tls_target("A:").await; + let _a = spawn_connect(&serve, "token-a", &[("ta", target)]); + tls_when_online(&serve, "a.example.com", b"warm up").await; + + // Hold the single slot with an open TLS connection. + erbridge_core::tls::install_crypto_provider(); + let connector = + tokio_rustls::TlsConnector::from(erbridge_core::tls::client_tls_config().unwrap()); + let mut held = None; + for _ in 0..30 { + let tcp = TcpStream::connect(serve.sni).await.unwrap(); + let name = rustls_pki_types::ServerName::try_from("a.example.com").unwrap(); + if let Ok(stream) = connector.connect(name, tcp).await { + held = Some(stream); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let mut held = held.expect("could not take the only slot"); + held.write_all(b"x").await.unwrap(); + let mut buf = [0u8; 16]; + assert!(held.read(&mut buf).await.unwrap() > 0); + + assert!( + common::tls_exchange(serve.sni, "a.example.com", b"second") + .await + .is_err(), + "a second TLS client should be refused while the slot is held" + ); + + drop(held); + let mut ok = false; + for _ in 0..30 { + if common::tls_exchange(serve.sni, "a.example.com", b"again") + .await + .is_ok() + { + ok = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(ok, "slot never freed"); + serve.shutdown.cancel(); +} diff --git a/tests/reverse.rs b/crates/erbridge-core/tests/reverse.rs similarity index 76% rename from tests/reverse.rs rename to crates/erbridge-core/tests/reverse.rs index 06141fd..0bd936c 100644 --- a/tests/reverse.rs +++ b/crates/erbridge-core/tests/reverse.rs @@ -4,11 +4,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use erbridge::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; -use erbridge::reverse::connect::run_connect; -use erbridge::reverse::serve::run_serve; -use erbridge::stats::Registry; +use erbridge_core::config::{ConnectConfig, ConnectTunnel, ServeConfig, ServeTunnel}; +use erbridge_core::reverse::connect::{run_connect, run_connect_with_shutdown}; +use erbridge_core::reverse::serve::{run_serve, run_serve_with_shutdown}; +use erbridge_core::stats::Registry; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; #[tokio::test] async fn reverse_tunnel_roundtrip() { @@ -27,6 +28,7 @@ async fn reverse_tunnel_roundtrip() { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), @@ -78,6 +80,7 @@ async fn multiple_streams_share_one_control_connection() { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), @@ -125,6 +128,7 @@ async fn rejects_mismatched_psk() { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), @@ -178,6 +182,7 @@ async fn carries_more_concurrent_connections_than_the_yamux_ack_backlog() { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), @@ -249,6 +254,7 @@ async fn closes_the_client_when_b_cannot_reach_the_target() { name: "web".into(), external: format!("127.0.0.1:{external_port}"), }], + ..Default::default() }; let connect_cfg = ConnectConfig { server: format!("127.0.0.1:{control_port}"), @@ -279,3 +285,79 @@ async fn closes_the_client_when_b_cannot_reach_the_target() { .expect("reading from the external connection"); assert_eq!(read, 0, "expected the connection to be closed, got a byte"); } + +#[tokio::test] +async fn shutdown_stops_both_roles_and_releases_ports() { + let target_port = common::free_port(); + let control_port = common::free_port(); + let external_port = common::free_port(); + + let target_addr = format!("127.0.0.1:{target_port}").parse().unwrap(); + tokio::spawn(common::run_tcp_echo_server(target_addr)); + tokio::time::sleep(Duration::from_millis(50)).await; + + let serve_cfg = ServeConfig { + listen: format!("127.0.0.1:{control_port}"), + token: "shutdown-token".into(), + tunnel: vec![ServeTunnel { + name: "web".into(), + external: format!("127.0.0.1:{external_port}"), + }], + ..Default::default() + }; + let connect_cfg = ConnectConfig { + server: format!("127.0.0.1:{control_port}"), + token: "shutdown-token".into(), + tunnel: vec![ConnectTunnel { + name: "web".into(), + target: format!("127.0.0.1:{target_port}"), + }], + reconnect_min_secs: 1, + reconnect_max_secs: 2, + }; + + let shutdown = CancellationToken::new(); + let serve = tokio::spawn(run_serve_with_shutdown( + serve_cfg, + Registry::new(), + shutdown.clone(), + )); + tokio::time::sleep(Duration::from_millis(50)).await; + let connect = tokio::spawn(run_connect_with_shutdown( + connect_cfg, + Registry::new(), + shutdown.clone(), + )); + + // Hold one tunnelled connection open across the shutdown. + let external_addr: std::net::SocketAddr = format!("127.0.0.1:{external_port}").parse().unwrap(); + let mut held = tokio::net::TcpStream::connect(external_addr).await.unwrap(); + held.write_all(b"ping").await.unwrap(); + let mut buf = [0u8; 4]; + tokio::time::timeout(Duration::from_secs(5), held.read_exact(&mut buf)) + .await + .expect("echo timed out") + .unwrap(); + + shutdown.cancel(); + + tokio::time::timeout(Duration::from_secs(2), serve) + .await + .expect("serve did not stop") + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), connect) + .await + .expect("connect did not stop") + .unwrap() + .unwrap(); + + // The held connection is torn down rather than left dangling. + let mut rest = Vec::new(); + let closed = tokio::time::timeout(Duration::from_secs(2), held.read_to_end(&mut rest)).await; + assert!(closed.is_ok(), "held connection stayed open after shutdown"); + + // Both listeners are released. + std::net::TcpListener::bind(("127.0.0.1", external_port)).expect("external port still bound"); + std::net::TcpListener::bind(("127.0.0.1", control_port)).expect("control port still bound"); +} diff --git a/crates/erbridge/Cargo.toml b/crates/erbridge/Cargo.toml new file mode 100644 index 0000000..3e031ce --- /dev/null +++ b/crates/erbridge/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "erbridge" +description = "TCP/UDP port forwarder and reverse (NAT-traversal) tunnel with a terminal UI and a multi-tenant mode" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +rust-version.workspace = true +readme = "README.md" +keywords = ["tunnel", "port-forwarding", "reverse-proxy", "nat-traversal", "noise-protocol"] +categories = ["network-programming", "command-line-utilities"] + +[dependencies] +anyhow = "1.0.104" +clap = { version = "4.6.6", features = ["derive"] } +crossterm = "0.29.0" +erbridge-core = { workspace = true } +ratatui = "0.30.2" +tokio = { version = "1.53.1", features = ["full"] } + +[dev-dependencies] +tempfile = "3" +rustls-pki-types = "1.15.1" +tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } diff --git a/crates/erbridge/LICENSE b/crates/erbridge/LICENSE new file mode 100644 index 0000000..6ca7d08 --- /dev/null +++ b/crates/erbridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Duye Chen + +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. diff --git a/crates/erbridge/README.md b/crates/erbridge/README.md new file mode 100644 index 0000000..404ec13 --- /dev/null +++ b/crates/erbridge/README.md @@ -0,0 +1,18 @@ +# erbridge + +A low-latency TCP/UDP port forwarder and reverse (NAT-traversal) tunnel with a +terminal UI. It can also run as a multi-tenant server: many users, each with +their own token and hostname, routed by TLS SNI without decrypting the traffic. + +```sh +cargo install erbridge +erbridge --help +``` + +See the [project README](https://github.com/single9/erbridge#readme) for the +four modes (`forward`, `client`, `serve`, `connect`), multi-tenant `serve`, +`serve-admin` and `enroll`, security notes, and prebuilt binaries. + +The reusable library is [`erbridge-core`](https://crates.io/crates/erbridge-core). + +MIT licensed. diff --git a/crates/erbridge/src/admin.rs b/crates/erbridge/src/admin.rs new file mode 100644 index 0000000..1690958 --- /dev/null +++ b/crates/erbridge/src/admin.rs @@ -0,0 +1,196 @@ +//! `serve-admin`: edits the tenant file a multi-tenant `serve` is watching. +//! Safe to run while `serve` is up; changes apply within its reload interval. + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Result, bail}; +use erbridge_core::tenant::{self, DEFAULT_MAX_CONNS}; +use erbridge_core::tenant_store::{FileStore, TenantStore}; + +use crate::cli::AdminAction; + +/// Runs one admin action against the tenant file at `path` and returns what to +/// print on stdout. +pub fn run(path: &Path, action: &AdminAction) -> Result { + let store = FileStore::new(path); + match action { + AdminAction::Issue { + sni, + max_conns, + expires_in, + } => { + let max_conns = max_conns.unwrap_or(DEFAULT_MAX_CONNS); + if max_conns == 0 { + bail!("--max-conns must be at least 1"); + } + let expires_at = expires_in.map(|secs| now_unix() + secs); + let (code, entry) = tenant::new_enroll_code(sni, max_conns, expires_at); + store.issue_code(entry)?; + Ok(format!("{code}\n")) + } + AdminAction::Revoke { name } => { + store.revoke(name)?; + Ok(format!("revoked {name}\n")) + } + AdminAction::Disable { name } => { + store.set_disabled(name, true)?; + Ok(format!("disabled {name}\n")) + } + AdminAction::Enable { name } => { + store.set_disabled(name, false)?; + Ok(format!("enabled {name}\n")) + } + AdminAction::List => { + let set = store.snapshot()?; + let mut out = String::new(); + for t in set.tenants() { + out.push_str(&format!( + "{}\t{}\tmax_conns={}\t{}\n", + t.name, + t.sni, + t.max_conns, + if t.disabled { "disabled" } else { "enabled" } + )); + } + out.push_str(&format!( + "{} tenant(s), {} unused enrollment code(s)\n", + set.tenants().len(), + set.codes().len() + )); + Ok(out) + } + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + use erbridge_core::noise::{Psk, derive_psk}; + use erbridge_core::tenant::{EnrollCode, Tenant}; + + fn file() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tenants.toml"); + (dir, path) + } + + fn issue(path: &Path, sni: &str) -> String { + run( + path, + &AdminAction::Issue { + sni: sni.into(), + max_conns: Some(3), + expires_in: Some(60), + }, + ) + .unwrap() + .trim() + .to_string() + } + + #[test] + fn issue_prints_a_code_and_stores_only_its_hash() { + let (_dir, path) = file(); + let code = issue(&path, "a.example.com"); + assert_eq!(code.len(), 32); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains(&code), "the code itself must not be stored"); + let set = FileStore::new(&path).snapshot().unwrap(); + assert_eq!(set.codes().len(), 1); + assert_eq!(set.codes()[0].psk, derive_psk(&code)); + assert_eq!(set.codes()[0].max_conns, 3); + assert!(set.codes()[0].expires_at.unwrap() > now_unix()); + } + + #[test] + fn revoke_disable_enable_and_list_work_on_existing_tenants() { + let (_dir, path) = file(); + let store = FileStore::new(&path); + store + .add_tenant(Tenant { + name: "t-1".into(), + psk: derive_psk("x"), + sni: "a.example.com".into(), + max_conns: 8, + disabled: false, + }) + .unwrap(); + + run(&path, &AdminAction::Disable { name: "t-1".into() }).unwrap(); + assert!(run(&path, &AdminAction::List).unwrap().contains("disabled")); + run(&path, &AdminAction::Enable { name: "t-1".into() }).unwrap(); + let listing = run(&path, &AdminAction::List).unwrap(); + assert!(listing.contains("t-1\ta.example.com\tmax_conns=8\tenabled")); + assert!(listing.contains("1 tenant(s), 0 unused")); + + run(&path, &AdminAction::Revoke { name: "t-1".into() }).unwrap(); + assert!( + run(&path, &AdminAction::List) + .unwrap() + .contains("0 tenant(s)") + ); + } + + #[test] + fn unknown_names_and_bad_limits_are_errors() { + let (_dir, path) = file(); + assert!( + run( + &path, + &AdminAction::Revoke { + name: "nope".into() + } + ) + .is_err() + ); + assert!( + run( + &path, + &AdminAction::Disable { + name: "nope".into() + } + ) + .is_err() + ); + let zero = AdminAction::Issue { + sni: "a.x".into(), + max_conns: Some(0), + expires_in: None, + }; + assert!(run(&path, &zero).is_err()); + } + + #[test] + fn issuing_a_host_a_tenant_holds_is_refused() { + let (_dir, path) = file(); + let store = FileStore::new(&path); + store + .add_tenant(Tenant { + name: "t-a".into(), + psk: Psk::from_bytes([1; 32]), + sni: "a.example.com".into(), + max_conns: 4, + disabled: false, + }) + .unwrap(); + let err = store + .issue_code(EnrollCode { + psk: Psk::from_bytes([2; 32]), + sni: "A.Example.com.".into(), + max_conns: 4, + expires_at: None, + }) + .unwrap_err(); + assert!(format!("{err:#}").contains("already belongs"), "{err:#}"); + assert!(store.snapshot().unwrap().codes().is_empty()); + } +} diff --git a/crates/erbridge/src/cli.rs b/crates/erbridge/src/cli.rs new file mode 100644 index 0000000..3601d31 --- /dev/null +++ b/crates/erbridge/src/cli.rs @@ -0,0 +1,266 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "erbridge", + version, + about = "Low-latency TCP/UDP port forwarder with a reverse (NAT-traversal) mode" +)] +pub struct Cli { + /// TOML config file (see config.toml.example). + #[arg(long, short = 'c', global = true)] + pub config: Option, + + /// Disable the interactive TUI and write structured JSON logs instead + /// (use when running unattended, e.g. as a background/Windows service). + #[arg(long, global = true)] + pub headless: bool, + + /// JSON log file path when --headless is set. Defaults to `erbridge.log`. + #[arg(long, global = true)] + pub log_file: Option, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Direct forwarding: external listen port(s) -> internal target(s). + Forward { + /// Ad-hoc mapping, repeatable: LISTEN->TARGET[/tcp|udp|both], where + /// proto can add a `+tls` or `+noise` modifier (or bare `tls`/`noise` + /// for `tcp+tls`/`tcp+noise`) to wrap that mapping's listen side in + /// that transport. LISTEN may be a bare port (binds 0.0.0.0) or + /// host:port. + #[arg(long = "map", value_name = "LISTEN->TARGET[/proto]")] + maps: Vec, + /// Token for every `+tls`/`+noise` mapping above that doesn't set its + /// own (applies to all of them). Mandatory for `+noise`; for `+tls`, + /// omitting it lets any TLS client connect, same as today. + #[arg(long)] + token: Option, + }, + + /// Companion to a `forward` mapping secured with `+tls`/`+noise` + /// (optionally `--token`): listens locally in plaintext, and for every + /// connection dials the secured mapping using that same transport, + /// presents the token if configured, then relays bytes -- so a plain + /// local client doesn't need its own TLS/Noise support to reach it. + Client { + /// Ad-hoc mapping, repeatable: LOCAL_LISTEN->SERVER_ADDR[/tls|/noise]. + /// LOCAL_LISTEN may be a bare port (binds 127.0.0.1) or host:port; + /// the modifier names the far side mapping's transport and defaults + /// to `tls` when omitted. + #[arg(long = "map", value_name = "LOCAL_LISTEN->SERVER_ADDR[/proto]")] + maps: Vec, + /// Token to present to the secured mapping (applies to all of them + /// above); must match its `--token`/`token`. + #[arg(long)] + token: Option, + }, + + /// Reverse tunnel role A: wait for `connect` (B) to dial in, then expose + /// external ports that get relayed through B to B's local targets. + Serve { + /// Control address B dials, e.g. 0.0.0.0:9000. + #[arg(long)] + listen: Option, + /// Shared secret B must present to be accepted. + #[arg(long)] + token: Option, + /// Ad-hoc tunnel, repeatable: NAME=EXTERNAL_ADDR. + #[arg(long = "tunnel", value_name = "NAME=EXTERNAL_ADDR")] + tunnels: Vec, + /// Multi-tenant mode: the tenant file (see `serve-admin`). Each tenant + /// has its own token and is reached by its own TLS SNI host. Replaces + /// --token and --tunnel; needs --sni-listen. + #[arg(long, value_name = "FILE")] + tenants: Option, + /// Multi-tenant mode: address that accepts TLS connections and routes + /// them to tenants by SNI, e.g. 0.0.0.0:443. + #[arg(long, value_name = "ADDR")] + sni_listen: Option, + }, + + /// Reverse tunnel role B: dial `serve` (A) and relay each stream it + /// opens to a locally-configured target. + Connect { + /// A's control address to dial, e.g. 1.2.3.4:9000. + #[arg(long)] + server: Option, + /// Shared secret to present to A. + #[arg(long)] + token: Option, + /// Ad-hoc tunnel, repeatable: NAME=TARGET_ADDR. + #[arg(long = "tunnel", value_name = "NAME=TARGET_ADDR")] + tunnels: Vec, + /// Credentials file written by `enroll`: supplies the token and the + /// tunnel name, so only --target is needed besides --server. + #[arg(long, value_name = "FILE")] + credentials: Option, + /// Local target for the tunnel named in --credentials. + #[arg(long, value_name = "ADDR", requires = "credentials")] + target: Option, + }, + + /// Trade a single-use enrollment code for the credentials of a new tenant + /// on a multi-tenant `serve`. + Enroll { + /// The server's control address, e.g. relay.example.com:9000. + #[arg(long)] + server: String, + /// The code from `serve-admin issue`. + #[arg(long)] + code: String, + /// Write the credentials here (mode 0600, never overwrites) instead of + /// printing them. + #[arg(long, value_name = "FILE")] + out: Option, + }, + + /// Manage the tenant file of a multi-tenant `serve`. Safe to run while + /// `serve` is up; changes apply within a second. + ServeAdmin { + /// The tenant file (or set `serve.tenants_file` in the config). + #[arg(long, value_name = "FILE", global = true)] + tenants: Option, + #[command(subcommand)] + action: AdminAction, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum AdminAction { + /// Print a new single-use enrollment code for a host. + Issue { + /// The TLS SNI host the new tenant will be reached at. + #[arg(long)] + sni: String, + /// Concurrent connections the tenant may have (default 64). + #[arg(long)] + max_conns: Option, + /// Seconds until the code stops working (default: never). + #[arg(long, value_name = "SECS")] + expires_in: Option, + }, + /// Remove a tenant; its token stops working and its session ends. + Revoke { name: String }, + /// Suspend a tenant without forgetting it. + Disable { name: String }, + /// Resume a suspended tenant. + Enable { name: String }, + /// List tenants and the number of unused codes. + List, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("erbridge").chain(args.iter().copied())) + } + + #[test] + fn serve_accepts_the_multi_tenant_flags() { + let cli = parse(&[ + "serve", + "--listen", + "0.0.0.0:9000", + "--tenants", + "t.toml", + "--sni-listen", + "0.0.0.0:443", + ]) + .unwrap(); + let Command::Serve { + tenants, + sni_listen, + token, + .. + } = cli.command + else { + panic!("not serve"); + }; + assert_eq!(tenants.unwrap().to_str(), Some("t.toml")); + assert_eq!(sni_listen.as_deref(), Some("0.0.0.0:443")); + assert!(token.is_none()); + } + + #[test] + fn connect_takes_credentials_and_a_target() { + let cli = parse(&[ + "connect", + "--server", + "h:9000", + "--credentials", + "c.toml", + "--target", + "127.0.0.1:80", + ]) + .unwrap(); + let Command::Connect { + credentials, + target, + .. + } = cli.command + else { + panic!("not connect"); + }; + assert_eq!(credentials.unwrap().to_str(), Some("c.toml")); + assert_eq!(target.as_deref(), Some("127.0.0.1:80")); + // --target alone makes no sense. + assert!(parse(&["connect", "--server", "h:1", "--target", "x:1"]).is_err()); + } + + #[test] + fn enroll_needs_a_server_and_a_code() { + assert!(parse(&["enroll", "--server", "h:9000", "--code", "abc"]).is_ok()); + assert!(parse(&["enroll", "--server", "h:9000"]).is_err()); + assert!(parse(&["enroll", "--code", "abc"]).is_err()); + } + + #[test] + fn serve_admin_subcommands_parse() { + let cli = parse(&[ + "serve-admin", + "--tenants", + "t.toml", + "issue", + "--sni", + "a.example.com", + "--max-conns", + "5", + "--expires-in", + "3600", + ]) + .unwrap(); + let Command::ServeAdmin { tenants, action } = cli.command else { + panic!("not serve-admin"); + }; + assert_eq!(tenants.unwrap().to_str(), Some("t.toml")); + assert!(matches!( + action, + AdminAction::Issue { + max_conns: Some(5), + expires_in: Some(3600), + .. + } + )); + for ok in [ + &["serve-admin", "revoke", "t-1"][..], + &["serve-admin", "disable", "t-1"], + &["serve-admin", "enable", "t-1"], + &["serve-admin", "list"], + ] { + assert!(parse(ok).is_ok(), "{ok:?}"); + } + assert!( + parse(&["serve-admin", "issue"]).is_err(), + "issue needs --sni" + ); + } +} diff --git a/crates/erbridge/src/main.rs b/crates/erbridge/src/main.rs new file mode 100644 index 0000000..8fae7c5 --- /dev/null +++ b/crates/erbridge/src/main.rs @@ -0,0 +1,429 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Parser; + +mod admin; +mod cli; +mod tui; + +use cli::{Cli, Command}; +use erbridge_core::config::{ + self, ClientRule, ConnectConfig, ConnectTunnel, FileConfig, ForwardRule, ServeConfig, + ServeTunnel, +}; +use erbridge_core::reverse::enroll::{Credentials, enroll}; +use erbridge_core::stats::Registry; +use erbridge_core::{client, forward, reverse}; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let file_cfg = match &cli.config { + Some(path) => FileConfig::load(path)?, + None => FileConfig::default(), + }; + + // One-shot commands print their result and exit; they have no use for the + // live view or a log file. + match &cli.command { + Command::Enroll { server, code, out } => { + return run_enroll(server, code, out.as_deref()).await; + } + Command::ServeAdmin { tenants, action } => { + let path = tenants_path(tenants.clone(), &file_cfg)?; + print!("{}", admin::run(&path, action)?); + return Ok(()); + } + _ => {} + } + + let registry = Registry::new(); + if cli.headless { + let log_path = cli + .log_file + .clone() + .unwrap_or_else(|| PathBuf::from("erbridge.log")); + registry + .set_log_file(log_path.clone()) + .with_context(|| format!("opening log file {}", log_path.display()))?; + eprintln!("erbridge: headless mode, logging to {}", log_path.display()); + } + + let headless = cli.headless; + let work_registry = registry.clone(); + let work = run_command(cli.command, file_cfg, work_registry); + + if headless { + work.await + } else { + tokio::select! { + result = work => result, + result = tui::run(registry) => result, + } + } +} + +async fn run_command(command: Command, file_cfg: FileConfig, registry: Registry) -> Result<()> { + match command { + Command::Forward { maps, token } => { + let rules = resolve_forward(&file_cfg, &maps, token)?; + forward::run_forward(rules, registry).await + } + Command::Client { maps, token } => { + let rules = resolve_client(&file_cfg, &maps, token)?; + client::run_client(rules, registry).await + } + Command::Serve { + listen, + token, + tunnels, + tenants, + sni_listen, + } => { + let cfg = resolve_serve(&file_cfg, listen, token, &tunnels, tenants, sni_listen)?; + reverse::serve::run_serve(cfg, registry).await + } + Command::Connect { + server, + token, + tunnels, + credentials, + target, + } => { + let cfg = resolve_connect(&file_cfg, server, token, &tunnels, credentials, target)?; + reverse::connect::run_connect(cfg, registry).await + } + Command::Enroll { .. } | Command::ServeAdmin { .. } => { + unreachable!("one-shot commands are handled before run_command") + } + } +} + +fn resolve_forward( + file_cfg: &FileConfig, + cli_maps: &[String], + cli_token: Option, +) -> Result> { + let mut rules = file_cfg.forward.clone(); + for raw in cli_maps { + rules.push(config::parse_map_flag(raw)?); + } + // `--token` applies to every mapping with a `transport` from this + // invocation that doesn't already carry its own token from the config + // file. + if let Some(token) = cli_token { + for rule in &mut rules { + if rule.transport.is_some() && rule.token.is_none() { + rule.token = Some(token.clone()); + } + } + } + Ok(rules) +} + +fn resolve_client( + file_cfg: &FileConfig, + cli_maps: &[String], + cli_token: Option, +) -> Result> { + let mut rules = file_cfg.client.clone(); + for raw in cli_maps { + rules.push(config::parse_client_map_flag(raw)?); + } + if let Some(token) = cli_token { + for rule in &mut rules { + if rule.token.is_none() { + rule.token = Some(token.clone()); + } + } + } + Ok(rules) +} + +fn resolve_serve( + file_cfg: &FileConfig, + listen: Option, + token: Option, + cli_tunnels: &[String], + tenants: Option, + sni_listen: Option, +) -> Result { + let mut cfg = file_cfg.serve.clone().unwrap_or_default(); + if let Some(l) = listen { + cfg.listen = l; + } + if let Some(path) = tenants { + cfg.tenants_file = Some(path.display().to_string()); + } + if let Some(addr) = sni_listen { + cfg.sni_listen = Some(addr); + } + if let Some(t) = token { + cfg.token = t; + } + for raw in cli_tunnels { + let (name, external) = parse_name_value(raw)?; + cfg.tunnel.push(ServeTunnel { name, external }); + } + if cfg.listen.is_empty() { + bail!("serve: missing control listen address (config `serve.listen` or --listen)"); + } + // Also rejects mixing the two modes' options. + if !cfg.is_multi_tenant()? && cfg.token.is_empty() { + bail!("serve: missing token (config `serve.token` or --token)"); + } + Ok(cfg) +} + +fn resolve_connect( + file_cfg: &FileConfig, + server: Option, + token: Option, + cli_tunnels: &[String], + credentials: Option, + target: Option, +) -> Result { + let mut cfg = file_cfg.connect.clone().unwrap_or(ConnectConfig { + server: String::new(), + token: String::new(), + tunnel: Vec::new(), + reconnect_min_secs: 1, + reconnect_max_secs: 30, + }); + if let Some(s) = server { + cfg.server = s; + } + if let Some(t) = token { + cfg.token = t; + } + for raw in cli_tunnels { + let (name, target) = parse_name_value(raw)?; + cfg.tunnel.push(ConnectTunnel { name, target }); + } + if let Some(path) = credentials { + let text = std::fs::read_to_string(&path) + .with_context(|| format!("reading credentials {}", path.display()))?; + let creds = Credentials::from_toml(&text) + .with_context(|| format!("loading credentials {}", path.display()))?; + let target = target.context("connect: --credentials needs --target")?; + if cfg.token.is_empty() { + cfg.token = creds.token; + } + cfg.tunnel.push(ConnectTunnel { + name: creds.name, + target, + }); + } + if cfg.server.is_empty() { + bail!("connect: missing server address (config `connect.server` or --server)"); + } + if cfg.token.is_empty() { + bail!("connect: missing token (config `connect.token` or --token)"); + } + Ok(cfg) +} + +fn parse_name_value(raw: &str) -> Result<(String, String)> { + let (name, value) = raw + .split_once('=') + .with_context(|| format!("expected NAME=VALUE, got: {raw}"))?; + Ok((name.trim().to_string(), value.trim().to_string())) +} + +/// The tenant file for `serve-admin`: the flag, else the config's +/// `serve.tenants_file`. +fn tenants_path(flag: Option, file_cfg: &FileConfig) -> Result { + flag.or_else(|| { + file_cfg + .serve + .as_ref() + .and_then(|s| s.tenants_file.as_deref().map(PathBuf::from)) + }) + .context("serve-admin: no tenant file (--tenants or config `serve.tenants_file`)") +} + +async fn run_enroll(server: &str, code: &str, out: Option<&std::path::Path>) -> Result<()> { + let credentials = enroll(server, code).await?; + let text = credentials.to_toml()?; + match out { + Some(path) => { + write_new_private(path, text.as_bytes())?; + eprintln!("enrolled as '{}' ({})", credentials.name, credentials.sni); + eprintln!("credentials written to {}", path.display()); + eprintln!( + "next: erbridge connect --server {server} --credentials {} --target ", + path.display() + ); + } + None => print!("{text}"), + } + Ok(()) +} + +/// Creates `path` readable by its owner only, refusing to overwrite: the file +/// holds a secret that cannot be fetched again. +fn write_new_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + use std::io::Write as _; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .with_context(|| format!("creating {} (it must not already exist)", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn serve_cfg() -> FileConfig { + FileConfig::default() + } + + #[test] + fn serve_in_multi_tenant_mode_needs_no_token() { + let cfg = resolve_serve( + &serve_cfg(), + Some("0.0.0.0:9000".into()), + None, + &[], + Some("t.toml".into()), + Some("0.0.0.0:443".into()), + ) + .unwrap(); + assert_eq!(cfg.tenants_file.as_deref(), Some("t.toml")); + assert!(cfg.is_multi_tenant().unwrap()); + } + + #[test] + fn serve_still_requires_a_token_without_tenants() { + let err = resolve_serve( + &serve_cfg(), + Some("0.0.0.0:9000".into()), + None, + &[], + None, + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("missing token"), "{err:#}"); + } + + #[test] + fn mixing_token_and_tenants_is_rejected() { + let mixed = resolve_serve( + &serve_cfg(), + Some("0.0.0.0:9000".into()), + Some("t".into()), + &[], + Some("t.toml".into()), + Some("0.0.0.0:443".into()), + ); + assert!(mixed.is_err()); + let no_sni = resolve_serve( + &serve_cfg(), + Some("0.0.0.0:9000".into()), + None, + &[], + Some("t.toml".into()), + None, + ); + assert!(no_sni.is_err()); + } + + #[test] + fn connect_builds_its_tunnel_from_credentials() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("c.toml"); + let creds = Credentials { + name: "t-abc".into(), + token: "secret".into(), + sni: "a.example.com".into(), + }; + std::fs::write(&path, creds.to_toml().unwrap()).unwrap(); + + let cfg = resolve_connect( + &FileConfig::default(), + Some("h:9000".into()), + None, + &[], + Some(path.clone()), + Some("127.0.0.1:8080".into()), + ) + .unwrap(); + assert_eq!(cfg.token, "secret"); + assert_eq!(cfg.tunnel.len(), 1); + assert_eq!( + (cfg.tunnel[0].name.as_str(), cfg.tunnel[0].target.as_str()), + ("t-abc", "127.0.0.1:8080") + ); + + // An explicit token wins over the file's; a missing target is an error. + let explicit = resolve_connect( + &FileConfig::default(), + Some("h:9000".into()), + Some("mine".into()), + &[], + Some(path.clone()), + Some("127.0.0.1:8080".into()), + ) + .unwrap(); + assert_eq!(explicit.token, "mine"); + assert!( + resolve_connect( + &FileConfig::default(), + Some("h:9000".into()), + None, + &[], + Some(path), + None + ) + .is_err() + ); + } + + #[test] + fn tenants_path_prefers_the_flag_over_the_config() { + let file_cfg = FileConfig { + serve: Some(ServeConfig { + tenants_file: Some("from-config.toml".into()), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + tenants_path(Some("flag.toml".into()), &file_cfg).unwrap(), + PathBuf::from("flag.toml") + ); + assert_eq!( + tenants_path(None, &file_cfg).unwrap(), + PathBuf::from("from-config.toml") + ); + assert!(tenants_path(None, &FileConfig::default()).is_err()); + } + + #[test] + fn credentials_are_written_privately_and_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("c.toml"); + write_new_private(&path, b"secret").unwrap(); + assert!(write_new_private(&path, b"other").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"secret"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/src/tui.rs b/crates/erbridge/src/tui.rs similarity index 95% rename from src/tui.rs rename to crates/erbridge/src/tui.rs index 3f06477..4f89eb3 100644 --- a/src/tui.rs +++ b/crates/erbridge/src/tui.rs @@ -8,7 +8,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; -use crate::stats::Registry; +use erbridge_core::stats::Registry; const TICK: Duration = Duration::from_millis(250); @@ -64,10 +64,10 @@ pub async fn run(registry: Registry) -> Result<()> { fn render( frame: &mut Frame, - totals: &crate::stats::Totals, + totals: &erbridge_core::stats::Totals, rate_in: f64, rate_out: f64, - live: &[crate::stats::ConnectionInfo], + live: &[erbridge_core::stats::ConnectionInfo], log: &[String], ) { let area = frame.area(); @@ -90,7 +90,7 @@ fn render( fn render_summary( frame: &mut Frame, area: Rect, - totals: &crate::stats::Totals, + totals: &erbridge_core::stats::Totals, rate_in: f64, rate_out: f64, ) { @@ -107,7 +107,11 @@ fn render_summary( frame.render_widget(Paragraph::new(text).block(block), area); } -fn render_connections(frame: &mut Frame, area: Rect, live: &[crate::stats::ConnectionInfo]) { +fn render_connections( + frame: &mut Frame, + area: Rect, + live: &[erbridge_core::stats::ConnectionInfo], +) { let header = Row::new(vec![ "label", "proto", diff --git a/crates/erbridge/tests/e2e_multi_tenant.rs b/crates/erbridge/tests/e2e_multi_tenant.rs new file mode 100644 index 0000000..59bc5bf --- /dev/null +++ b/crates/erbridge/tests/e2e_multi_tenant.rs @@ -0,0 +1,306 @@ +//! Drives the real `erbridge` binary through a multi-tenant lifecycle: issue a +//! code, serve, enroll, connect, reach the tenant with a TLS client by SNI, +//! then revoke it. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use erbridge_core::reverse::enroll::Credentials; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const BIN: &str = env!("CARGO_BIN_EXE_erbridge"); + +/// A long-running child that is killed and reaped when dropped, so a failing +/// test never leaves a stray server behind. +struct Proc(Child); + +impl Drop for Proc { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Runs a one-shot command to completion and returns (success, stdout, stderr). +fn run(args: &[&str]) -> (bool, String, String) { + let out = Command::new(BIN).args(args).output().unwrap(); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).trim().to_string(), + String::from_utf8_lossy(&out.stderr).trim().to_string(), + ) +} + +fn spawn(log: &Path, args: &[&str]) -> Proc { + let child = Command::new(BIN) + .args(["--headless", "--log-file"]) + .arg(log) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + Proc(child) +} + +async fn wait_for_port(addr: &str) { + for _ in 0..100 { + if TcpStream::connect(addr).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("{addr} never started listening"); +} + +/// A TLS server that answers each read with `T:` followed by what it read. +async fn tls_target() -> SocketAddr { + erbridge_core::tls::install_crypto_provider(); + let cert = erbridge_core::tls::generate_self_signed().unwrap(); + let acceptor = + tokio_rustls::TlsAcceptor::from(erbridge_core::tls::server_tls_config(&cert).unwrap()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((tcp, _)) = listener.accept().await else { + continue; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(tcp).await else { + return; + }; + let mut buf = vec![0u8; 4096]; + while let Ok(n) = stream.read(&mut buf).await { + if n == 0 { + break; + } + let mut reply = b"T:".to_vec(); + reply.extend_from_slice(&buf[..n]); + if stream.write_all(&reply).await.is_err() { + break; + } + } + }); + } + }); + addr +} + +async fn tls_exchange(sni_addr: &str, sni: &str, payload: &[u8]) -> std::io::Result> { + erbridge_core::tls::install_crypto_provider(); + let attempt = async { + let connector = + tokio_rustls::TlsConnector::from(erbridge_core::tls::client_tls_config().unwrap()); + let tcp = TcpStream::connect(sni_addr).await?; + let name = rustls_pki_types::ServerName::try_from(sni.to_string()).unwrap(); + let mut stream = connector.connect(name, tcp).await?; + stream.write_all(payload).await?; + let mut buf = vec![0u8; 4096]; + let n = stream.read(&mut buf).await?; + buf.truncate(n); + Ok(buf) + }; + tokio::time::timeout(Duration::from_secs(3), attempt) + .await + .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out"))? +} + +async fn eventually_ok(sni_addr: &str, sni: &str, payload: &[u8]) -> Vec { + for _ in 0..60 { + if let Ok(reply) = tls_exchange(sni_addr, sni, payload).await { + return reply; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("no TLS reply from {sni}"); +} + +#[tokio::test] +async fn issue_serve_enroll_connect_reach_and_revoke() { + let dir = tempfile::tempdir().unwrap(); + let tenants: PathBuf = dir.path().join("tenants.toml"); + let creds = dir.path().join("credentials.toml"); + let (control, sni) = ( + format!("127.0.0.1:{}", free_port()), + format!("127.0.0.1:{}", free_port()), + ); + let tenants_s = tenants.to_str().unwrap(); + + // 1. An operator issues a code before the server even starts. + let (ok, code, err) = run(&[ + "serve-admin", + "--tenants", + tenants_s, + "issue", + "--sni", + "bin.example.com", + "--expires-in", + "600", + ]); + assert!(ok, "{err}"); + assert_eq!(code.len(), 32); + + // 2. The server starts against that file. + let _serve = spawn( + &dir.path().join("serve.log"), + &[ + "serve", + "--listen", + &control, + "--tenants", + tenants_s, + "--sni-listen", + &sni, + ], + ); + wait_for_port(&control).await; + wait_for_port(&sni).await; + + // 3. A user enrolls with the code. + let (ok, _, err) = run(&[ + "enroll", + "--server", + &control, + "--code", + &code, + "--out", + creds.to_str().unwrap(), + ]); + assert!(ok, "{err}"); + let parsed = Credentials::from_toml(&std::fs::read_to_string(&creds).unwrap()).unwrap(); + let name = parsed.name.clone(); + assert_eq!(parsed.sni, "bin.example.com"); + assert!(!parsed.token.is_empty()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&creds).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + // Credentials are never overwritten, and a spent code does not work twice. + let creds_s = creds.to_str().unwrap(); + assert!( + !run(&[ + "enroll", "--server", &control, "--code", &code, "--out", creds_s + ]) + .0 + ); + + // 4. The user connects, and a TLS client reaches their target by SNI. + let target = tls_target().await; + let _connect = spawn( + &dir.path().join("connect.log"), + &[ + "connect", + "--server", + &control, + "--credentials", + creds_s, + "--target", + &target.to_string(), + ], + ); + assert_eq!( + eventually_ok(&sni, "bin.example.com", b"hello").await, + b"T:hello" + ); + + // 5. It shows up in the listing, and revoking it cuts it off. + let (ok, listing, _) = run(&["serve-admin", "--tenants", tenants_s, "list"]); + assert!( + ok && listing.contains(&name) && listing.contains("1 tenant(s)"), + "{listing}" + ); + + let (ok, _, err) = run(&["serve-admin", "--tenants", tenants_s, "revoke", &name]); + assert!(ok, "{err}"); + let mut cut_off = false; + for _ in 0..60 { + if tls_exchange(&sni, "bin.example.com", b"again") + .await + .is_err() + { + cut_off = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(cut_off, "tenant still reachable after revoke"); +} + +#[tokio::test] +async fn bad_codes_and_mixed_options_fail_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let tenants = dir.path().join("tenants.toml"); + let tenants_s = tenants.to_str().unwrap(); + let (control, sni) = ( + format!("127.0.0.1:{}", free_port()), + format!("127.0.0.1:{}", free_port()), + ); + + // Mixing single- and multi-tenant options is refused up front. + let (ok, _, err) = run(&[ + "--headless", + "--log-file", + dir.path().join("l.log").to_str().unwrap(), + "serve", + "--listen", + &control, + "--token", + "x", + "--tenants", + tenants_s, + "--sni-listen", + &sni, + ]); + assert!(!ok); + assert!(err.contains("tenants_file"), "{err}"); + + // A made-up code is refused by a running server. + let _serve = spawn( + &dir.path().join("serve.log"), + &[ + "serve", + "--listen", + &control, + "--tenants", + tenants_s, + "--sni-listen", + &sni, + ], + ); + wait_for_port(&control).await; + let out = dir.path().join("c.toml"); + let (ok, _, err) = run(&[ + "enroll", + "--server", + &control, + "--code", + "not-a-real-code", + "--out", + out.to_str().unwrap(), + ]); + assert!(!ok); + assert!(!err.is_empty()); + assert!(!out.exists(), "no credentials file on failure"); + + // serve-admin needs to know where the file is. + let (ok, _, err) = run(&["serve-admin", "list"]); + assert!(!ok); + assert!(err.contains("tenant file"), "{err}"); +} diff --git a/docs/benchmarks/tls-vs-noise-reverse-tunnel-latency.md b/docs/benchmarks/tls-vs-noise-reverse-tunnel-latency.md index 3e2a965..3aca5f7 100644 --- a/docs/benchmarks/tls-vs-noise-reverse-tunnel-latency.md +++ b/docs/benchmarks/tls-vs-noise-reverse-tunnel-latency.md @@ -1,6 +1,6 @@ # TLS vs Noise reverse-tunnel latency -Measured while migrating the `serve`/`connect` control channel (and `forward`/`client`'s `transport = "noise"` mappings) from TLS to the Noise protocol, to check whether the switch cost any steady-state latency (see README's Security notes for why that migration happened). Recorded here so a future change to `src/noise.rs`, the `snow` dependency, or its Cargo features can be checked against a known-good number instead of re-deriving one from scratch. +Measured while migrating the `serve`/`connect` control channel (and `forward`/`client`'s `transport = "noise"` mappings) from TLS to the Noise protocol, to check whether the switch cost any steady-state latency (see README's Security notes for why that migration happened). Recorded here so a future change to `crates/erbridge-core/src/noise.rs`, the `snow` dependency, or its Cargo features can be checked against a known-good number instead of re-deriving one from scratch. Absolute numbers below are specific to the machine they were measured on and are only meaningful relative to each other, not as a portable latency figure. @@ -19,7 +19,7 @@ No measurable difference, after two prerequisites were met: 1. `snow` built with the `ring-accelerated` feature (`Cargo.toml`), so ChaCha20-Poly1305 and X25519 run through `ring` instead of the pure-Rust default resolver. BLAKE2s has no `ring` implementation and keeps using the default resolver as a fallback. 2. The measuring machine's CPU governor set to `performance`, not `powersave` (see "Environment note" below) — required for either version to produce a stable number at all, not specific to Noise. -With both in place, `reverse_tunnel_tcp_roundtrip` (a 64-byte ping over an already-open connection, `benches/latency.rs`) lands at essentially the same latency for TLS and Noise: +With both in place, `reverse_tunnel_tcp_roundtrip` (a 64-byte ping over an already-open connection, `crates/erbridge-core/benches/latency.rs`) lands at essentially the same latency for TLS and Noise: | Transport | Run 1 [low mid high] | Run 2 [low mid high] | |---|---|---| @@ -29,7 +29,7 @@ With both in place, `reverse_tunnel_tcp_roundtrip` (a 64-byte ping over an alrea The default-resolver row is included because it's the regression a future contributor might reintroduce by removing the `ring-accelerated` feature (e.g. while trying to drop the `ring` dependency): roughly **+5.7 µs (+14%)** over TLS, reproduced across two independent runs. -Cross-checked against `examples/compare_tunnels.rs`, which drives the same tunnel end-to-end alongside frp/rathole/bore rather than through Criterion: +Cross-checked against `crates/erbridge-core/examples/compare_tunnels.rs`, which drives the same tunnel end-to-end alongside frp/rathole/bore rather than through Criterion: | Tool | TLS p50 | Noise (`ring-accelerated`) p50 | |---|---|---| diff --git a/docs/multi-tenant/issues/01-tenant-model-and-psk-identification.md b/docs/multi-tenant/issues/01-tenant-model-and-psk-identification.md new file mode 100644 index 0000000..78e7e7e --- /dev/null +++ b/docs/multi-tenant/issues/01-tenant-model-and-psk-identification.md @@ -0,0 +1,19 @@ +# 01: Tenant model and PSK identification + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** none + +**What to build:** socket-free tenant types and the handshake that picks a +tenant by trying each PSK. + +- `tenant` module: `Tenant`, `EnrollCode`, `TenantSet` (immutable snapshot with + lookup by name and by sni). +- `noise::accept_any(io, candidates)`: reads handshake message 1 once, tries each + candidate PSK on a fresh responder, finishes the handshake with the one that + verifies, returns the stream and the matched index. + +## Acceptance + +- With three candidates, each matching PSK identifies the right one. +- No match returns an error and writes nothing to the socket. +- Handshake interoperates with the existing `noise::connect`. +- Unit tests, no sockets beyond an in-memory duplex. diff --git a/docs/multi-tenant/issues/02-sni-parser.md b/docs/multi-tenant/issues/02-sni-parser.md new file mode 100644 index 0000000..4957a8e --- /dev/null +++ b/docs/multi-tenant/issues/02-sni-parser.md @@ -0,0 +1,17 @@ +# 02: SNI parser + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** none + +**What to build:** `sni` module that reads the first TLS record from a stream +(cap 16 KiB, 5 s timeout) and extracts the SNI, returning it with the buffered +bytes so they can be replayed. + +## Acceptance + +- Parses real ClientHellos generated by rustls (with and without SNI, IP-literal + names excluded). +- Case-insensitive match helper. +- Rejects: non-TLS bytes, truncated records, oversized records, handshake that + is not a ClientHello, malformed extensions. Never panics (fuzz-style test over + truncations of a valid hello). +- Slow sender hits the timeout. diff --git a/docs/multi-tenant/issues/03-multi-tenant-serve.md b/docs/multi-tenant/issues/03-multi-tenant-serve.md new file mode 100644 index 0000000..f11dcb6 --- /dev/null +++ b/docs/multi-tenant/issues/03-multi-tenant-serve.md @@ -0,0 +1,19 @@ +# 03: Multi-tenant serve with SNI routing + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** 01, 02 + +**What to build:** `ServeConfig` gains `tenants_file` and `sni_listen`; +`run_serve_with_shutdown` runs the multi-tenant mode when `tenants_file` is set +(in-memory store from the file at start, hot reload comes in 04). Per-tenant +session slots; new session replaces the old; header sent to `connect` is the +tenant name. + +## Acceptance + +- Two tenants connected at once; traffic never crosses. +- Tenant A's `connect` claims B's tunnel name; it still reaches only A. +- Wrong token is indistinguishable from an unknown tenant (both just close). +- Unknown / missing SNI and offline tenant close without a reply. +- Setting both `token`/`tunnel` and `tenants_file` is a config error. +- Legacy mode tests still pass unchanged. +- Shutdown stops everything and frees ports. diff --git a/docs/multi-tenant/issues/04-tenant-store-and-hot-reload.md b/docs/multi-tenant/issues/04-tenant-store-and-hot-reload.md new file mode 100644 index 0000000..d319860 --- /dev/null +++ b/docs/multi-tenant/issues/04-tenant-store-and-hot-reload.md @@ -0,0 +1,15 @@ +# 04: Tenant store and hot reload + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** 03 + +**What to build:** `TenantStore` trait (`snapshot`, `redeem`, `issue`, +`revoke`), `MemoryStore`, `FileStore` (TOML, exclusive lock, atomic rename). +`serve` re-reads the store every second and applies changes. + +## Acceptance + +- Adding a tenant while serving makes it connectable without a restart. +- Disabling or removing tenant A ends A's session; B's session is untouched. +- Concurrent writers (two threads issuing codes) lose no entries. +- A corrupt file on reload keeps the previous snapshot and logs an error. +- Same behaviour suite runs against both store implementations. diff --git a/docs/multi-tenant/issues/05-per-tenant-connection-limit.md b/docs/multi-tenant/issues/05-per-tenant-connection-limit.md new file mode 100644 index 0000000..766ed1c --- /dev/null +++ b/docs/multi-tenant/issues/05-per-tenant-connection-limit.md @@ -0,0 +1,13 @@ +# 05: Per-tenant connection limit + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** 03 + +**What to build:** `max_conns` enforced per tenant with a semaphore; excess +connections are closed immediately. + +## Acceptance + +- With `max_conns = 2`, a third simultaneous connection is closed while the + first two keep working; after one closes a new one is accepted. +- One tenant at its limit does not affect another tenant. +- Changing `max_conns` in the file applies to new connections after reload. diff --git a/docs/multi-tenant/issues/06-enrollment.md b/docs/multi-tenant/issues/06-enrollment.md new file mode 100644 index 0000000..93871ac --- /dev/null +++ b/docs/multi-tenant/issues/06-enrollment.md @@ -0,0 +1,18 @@ +# 06: Enrollment codes + +**Status:** done · **Crate:** erbridge-core · **Blocked by:** 04 + +**What to build:** single-use enrollment codes and the client side. + +- `serve`: codes are extra PSK candidates; a match burns the code, creates the + tenant, persists it, sends `{name, token, sni}` inside the channel. +- `reverse::enroll::enroll(server, code) -> Credentials`. + +## Acceptance + +- A valid code yields credentials that then connect as the new tenant. +- The same code a second time fails; an expired code fails. +- Two concurrent redemptions of one code: exactly one succeeds. +- A code never authenticates as an existing tenant, and a tenant token never + redeems as a code. +- Generated names and tokens are random and unique across many enrollments. diff --git a/docs/multi-tenant/issues/07-cli-and-config.md b/docs/multi-tenant/issues/07-cli-and-config.md new file mode 100644 index 0000000..30f4106 --- /dev/null +++ b/docs/multi-tenant/issues/07-cli-and-config.md @@ -0,0 +1,17 @@ +# 07: CLI, config and docs + +**Status:** done · **Crate:** erbridge · **Blocked by:** 06 + +**What to build:** + +- `erbridge serve --tenants FILE --sni-listen ADDR` (and config keys). +- `erbridge serve-admin issue --tenants FILE --sni HOST [--max-conns N] + [--expires-in SECS]` prints a code; `revoke NAME`; `list`. +- `erbridge enroll --server ADDR --code CODE [--out FILE]` writes credentials. +- `config.example.toml` and the README document the new mode. + +## Acceptance + +- Each command has a parsing test; `serve-admin` works on a file `serve` is + running against. +- Errors for conflicting options are clear. diff --git a/docs/multi-tenant/issues/08-e2e.md b/docs/multi-tenant/issues/08-e2e.md new file mode 100644 index 0000000..0b11b76 --- /dev/null +++ b/docs/multi-tenant/issues/08-e2e.md @@ -0,0 +1,14 @@ +# 08: End-to-end tests + +**Status:** done · **Crate:** both · **Blocked by:** 07 + +**What to build:** tests that drive the real `erbridge` binary. + +## Acceptance + +- Core: two tenants, real rustls clients with different SNI reach their own TLS + echo targets through the tunnel; the loop covers enroll, connect, revoke, + reconnect (session replacement) and limits. +- Binary: spawn `serve-admin issue`, `serve`, `enroll`, `connect` as processes + and complete a TLS round trip; revoke and observe the session end. +- Tests use free ports and clean up child processes on failure. diff --git a/docs/multi-tenant/issues/09-publish-prep.md b/docs/multi-tenant/issues/09-publish-prep.md new file mode 100644 index 0000000..3f36187 --- /dev/null +++ b/docs/multi-tenant/issues/09-publish-prep.md @@ -0,0 +1,19 @@ +# 09: crates.io publish preparation + +**Status:** done · **Blocked by:** 07 + +**What to build:** metadata and layout so both crates pass `cargo publish +--dry-run`. + +- `[workspace.dependencies] erbridge-core = { path, version }`, version bumped + together with the workspace version. +- `repository`, `readme`, `keywords`, `categories`, `rust-version`, per-crate + README, `exclude` for non-source files. +- Release workflow keeps the dependency version in sync. + +## Acceptance + +- `cargo publish --dry-run -p erbridge-core` passes. +- `cargo publish --workspace --dry-run` passes: it packages both crates and + verifies `erbridge` against the not-yet-published `erbridge-core` through a + local overlay registry, so no `--no-verify` is needed. diff --git a/docs/multi-tenant/issues/10-ci-and-release-publish.md b/docs/multi-tenant/issues/10-ci-and-release-publish.md new file mode 100644 index 0000000..b7e7cbe --- /dev/null +++ b/docs/multi-tenant/issues/10-ci-and-release-publish.md @@ -0,0 +1,18 @@ +# 10: CI and crates.io publishing + +**Status:** done · **Blocked by:** 09 + +**What to build:** + +- `.github/workflows/ci.yml`: fmt, clippy, tests on push and pull request. +- `release.yml`: after the tag exists, publish `erbridge-core`, wait until the + index has it, then publish `erbridge`. Uses the `CARGO_REGISTRY_TOKEN` secret. + A failed publish does not undo the GitHub release but is reported. + +## Acceptance + +- CI passes on a clean checkout. +- Release workflow is syntax-checked (`actionlint` if available) and a dry run + mode (`publish: false` input) exercises everything except the upload. +- The secret name and setup steps are documented in the README under + "Publishing to crates.io". diff --git a/docs/multi-tenant/spec.md b/docs/multi-tenant/spec.md new file mode 100644 index 0000000..271ff9b --- /dev/null +++ b/docs/multi-tenant/spec.md @@ -0,0 +1,73 @@ +# Multi-tenant `serve` + +Status: implemented (tickets 01-10). Tickets are in `issues/`, built in numeric order. + +Domain names, TLS certificates and payment are out of scope. erbridge treats a +tenant's `sni` as an opaque host string and never terminates TLS: it reads the +ClientHello's SNI and relays the bytes untouched. + +## Problem + +Today `serve` has one shared token and a fixed `[[serve.tunnel]]` list, and +accepts one `connect` at a time. A host that wants to serve many independent +users from one `serve` process (each behind their own hostname, each with their +own secret) cannot. + +## Model + +- **Tenant:** `name` (server-assigned, `t-` + 12 hex), `psk` (hex of + `derive_psk(token)`; the token itself is never stored), `sni`, `max_conns` + (default 64), `disabled`. +- **Enrollment code:** a single-use PSK, optionally expiring, bound to an `sni` + and `max_conns`, redeemed into a tenant. +- **Tenant file:** TOML, the source of truth. `serve` watches it; `serve-admin` + and enrollment edit it. +- **Modes:** legacy (`[serve] token` + `[[serve.tunnel]]`, unchanged) or + multi-tenant (`[serve] tenants_file` + `sni_listen`). Setting both is an error. + +## Behaviour + +1. **Identification.** `Noise_NNpsk0` mixes the PSK in before message 1, so the + responder reads message 1 once and tries each candidate PSK on a fresh + responder; the first AEAD success identifies the tenant (or an enrollment + code). Wire format is unchanged, so existing `connect` binaries work. A wrong + token and an unknown tenant are indistinguishable: the connection closes. +2. **Isolation.** A tenant owns one session slot and one routing key. The tunnel + header `serve` sends to `connect` is the tenant's `name`, decided by `serve`; + nothing the client says picks a tunnel. +3. **Routing.** `sni_listen` accepts TCP, reads the first TLS record (at most + 16 KiB, 5 s), parses the ClientHello SNI (case-insensitive exact match), + opens a stream on the matching tenant's session, replays the buffered bytes, + then pumps. No SNI, unknown SNI, disabled tenant or offline tenant: close + without replying. Limitation: the ClientHello must fit in one TLS record. +4. **Sessions.** A new authenticated session for a tenant replaces the old one + (a reconnecting client must not wait for a dead TCP connection to time out). + Disabling or removing a tenant ends its session and leaves others untouched. +5. **Limits.** `max_conns` caps concurrent relayed connections per tenant; + excess connections are closed. +6. **Enrollment.** `connect`-side `enroll(server, code)` handshakes with PSK = + code. Inside the encrypted channel `serve` burns the code, creates the tenant + (random name, random 256-bit hex token), persists it, and sends + `{name, token, sni}`. A newly issued code becomes usable within one reload + interval (1 s by default). Later connections use the token. Redemption is atomic + under a file lock; a code works once and not after expiry. +7. **Store.** `TenantStore` trait with in-memory and file-backed + implementations. The file is written by atomic rename under an exclusive + lock; `serve` re-reads it once a second (the store is a trait, so there is no file-specific mtime check) and applies changes without + restarting. + +## Non-goals + +Admin HTTP API, bandwidth limits, idle timeouts, TLS termination, Host +routing, ACME, open self-registration without a code. + +## Library readiness + +Done: the `erbridge-core` / `erbridge` workspace split, +`run_serve_with_shutdown` / `run_connect_with_shutdown`, `Registry::subscribe`, +the socket-free tenant module behind `TenantStore`. + +## Release + +`erbridge-core` and `erbridge` are published to crates.io by the release +workflow, core first (tickets 09, 10). diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 0a5dc5f..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::path::PathBuf; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - name = "erbridge", - version, - about = "Low-latency TCP/UDP port forwarder with a reverse (NAT-traversal) mode" -)] -pub struct Cli { - /// TOML config file (see config.toml.example). - #[arg(long, short = 'c', global = true)] - pub config: Option, - - /// Disable the interactive TUI and write structured JSON logs instead - /// (use when running unattended, e.g. as a background/Windows service). - #[arg(long, global = true)] - pub headless: bool, - - /// JSON log file path when --headless is set. Defaults to `erbridge.log`. - #[arg(long, global = true)] - pub log_file: Option, - - #[command(subcommand)] - pub command: Command, -} - -#[derive(Subcommand)] -pub enum Command { - /// Direct forwarding: external listen port(s) -> internal target(s). - Forward { - /// Ad-hoc mapping, repeatable: LISTEN->TARGET[/tcp|udp|both], where - /// proto can add a `+tls` or `+noise` modifier (or bare `tls`/`noise` - /// for `tcp+tls`/`tcp+noise`) to wrap that mapping's listen side in - /// that transport. LISTEN may be a bare port (binds 0.0.0.0) or - /// host:port. - #[arg(long = "map", value_name = "LISTEN->TARGET[/proto]")] - maps: Vec, - /// Token for every `+tls`/`+noise` mapping above that doesn't set its - /// own (applies to all of them). Mandatory for `+noise`; for `+tls`, - /// omitting it lets any TLS client connect, same as today. - #[arg(long)] - token: Option, - }, - - /// Companion to a `forward` mapping secured with `+tls`/`+noise` - /// (optionally `--token`): listens locally in plaintext, and for every - /// connection dials the secured mapping using that same transport, - /// presents the token if configured, then relays bytes -- so a plain - /// local client doesn't need its own TLS/Noise support to reach it. - Client { - /// Ad-hoc mapping, repeatable: LOCAL_LISTEN->SERVER_ADDR[/tls|/noise]. - /// LOCAL_LISTEN may be a bare port (binds 127.0.0.1) or host:port; - /// the modifier names the far side mapping's transport and defaults - /// to `tls` when omitted. - #[arg(long = "map", value_name = "LOCAL_LISTEN->SERVER_ADDR[/proto]")] - maps: Vec, - /// Token to present to the secured mapping (applies to all of them - /// above); must match its `--token`/`token`. - #[arg(long)] - token: Option, - }, - - /// Reverse tunnel role A: wait for `connect` (B) to dial in, then expose - /// external ports that get relayed through B to B's local targets. - Serve { - /// Control address B dials, e.g. 0.0.0.0:9000. - #[arg(long)] - listen: Option, - /// Shared secret B must present to be accepted. - #[arg(long)] - token: Option, - /// Ad-hoc tunnel, repeatable: NAME=EXTERNAL_ADDR. - #[arg(long = "tunnel", value_name = "NAME=EXTERNAL_ADDR")] - tunnels: Vec, - }, - - /// Reverse tunnel role B: dial `serve` (A) and relay each stream it - /// opens to a locally-configured target. - Connect { - /// A's control address to dial, e.g. 1.2.3.4:9000. - #[arg(long)] - server: Option, - /// Shared secret to present to A. - #[arg(long)] - token: Option, - /// Ad-hoc tunnel, repeatable: NAME=TARGET_ADDR. - #[arg(long = "tunnel", value_name = "NAME=TARGET_ADDR")] - tunnels: Vec, - }, -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index efbd39f..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod cli; -pub mod client; -pub mod config; -pub mod forward; -pub mod mux; -pub mod noise; -pub mod proxy; -pub mod reverse; -pub mod stats; -pub mod tls; -pub mod tui; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index ccefe76..0000000 --- a/src/main.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, Result, bail}; -use clap::Parser; - -use erbridge::cli::{Cli, Command}; -use erbridge::config::{ - self, ClientRule, ConnectConfig, ConnectTunnel, FileConfig, ForwardRule, ServeConfig, - ServeTunnel, -}; -use erbridge::stats::Registry; -use erbridge::{client, forward, reverse, tui}; - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - let file_cfg = match &cli.config { - Some(path) => FileConfig::load(path)?, - None => FileConfig::default(), - }; - - let registry = Registry::new(); - if cli.headless { - let log_path = cli - .log_file - .clone() - .unwrap_or_else(|| PathBuf::from("erbridge.log")); - registry - .set_log_file(log_path.clone()) - .with_context(|| format!("opening log file {}", log_path.display()))?; - eprintln!("erbridge: headless mode, logging to {}", log_path.display()); - } - - let headless = cli.headless; - let work_registry = registry.clone(); - let work = run_command(cli.command, file_cfg, work_registry); - - if headless { - work.await - } else { - tokio::select! { - result = work => result, - result = tui::run(registry) => result, - } - } -} - -async fn run_command(command: Command, file_cfg: FileConfig, registry: Registry) -> Result<()> { - match command { - Command::Forward { maps, token } => { - let rules = resolve_forward(&file_cfg, &maps, token)?; - forward::run_forward(rules, registry).await - } - Command::Client { maps, token } => { - let rules = resolve_client(&file_cfg, &maps, token)?; - client::run_client(rules, registry).await - } - Command::Serve { - listen, - token, - tunnels, - } => { - let cfg = resolve_serve(&file_cfg, listen, token, &tunnels)?; - reverse::serve::run_serve(cfg, registry).await - } - Command::Connect { - server, - token, - tunnels, - } => { - let cfg = resolve_connect(&file_cfg, server, token, &tunnels)?; - reverse::connect::run_connect(cfg, registry).await - } - } -} - -fn resolve_forward( - file_cfg: &FileConfig, - cli_maps: &[String], - cli_token: Option, -) -> Result> { - let mut rules = file_cfg.forward.clone(); - for raw in cli_maps { - rules.push(config::parse_map_flag(raw)?); - } - // `--token` applies to every mapping with a `transport` from this - // invocation that doesn't already carry its own token from the config - // file. - if let Some(token) = cli_token { - for rule in &mut rules { - if rule.transport.is_some() && rule.token.is_none() { - rule.token = Some(token.clone()); - } - } - } - Ok(rules) -} - -fn resolve_client( - file_cfg: &FileConfig, - cli_maps: &[String], - cli_token: Option, -) -> Result> { - let mut rules = file_cfg.client.clone(); - for raw in cli_maps { - rules.push(config::parse_client_map_flag(raw)?); - } - if let Some(token) = cli_token { - for rule in &mut rules { - if rule.token.is_none() { - rule.token = Some(token.clone()); - } - } - } - Ok(rules) -} - -fn resolve_serve( - file_cfg: &FileConfig, - listen: Option, - token: Option, - cli_tunnels: &[String], -) -> Result { - let mut cfg = file_cfg.serve.clone().unwrap_or(ServeConfig { - listen: String::new(), - token: String::new(), - tunnel: Vec::new(), - }); - if let Some(l) = listen { - cfg.listen = l; - } - if let Some(t) = token { - cfg.token = t; - } - for raw in cli_tunnels { - let (name, external) = parse_name_value(raw)?; - cfg.tunnel.push(ServeTunnel { name, external }); - } - if cfg.listen.is_empty() { - bail!("serve: missing control listen address (config `serve.listen` or --listen)"); - } - if cfg.token.is_empty() { - bail!("serve: missing token (config `serve.token` or --token)"); - } - Ok(cfg) -} - -fn resolve_connect( - file_cfg: &FileConfig, - server: Option, - token: Option, - cli_tunnels: &[String], -) -> Result { - let mut cfg = file_cfg.connect.clone().unwrap_or(ConnectConfig { - server: String::new(), - token: String::new(), - tunnel: Vec::new(), - reconnect_min_secs: 1, - reconnect_max_secs: 30, - }); - if let Some(s) = server { - cfg.server = s; - } - if let Some(t) = token { - cfg.token = t; - } - for raw in cli_tunnels { - let (name, target) = parse_name_value(raw)?; - cfg.tunnel.push(ConnectTunnel { name, target }); - } - if cfg.server.is_empty() { - bail!("connect: missing server address (config `connect.server` or --server)"); - } - if cfg.token.is_empty() { - bail!("connect: missing token (config `connect.token` or --token)"); - } - Ok(cfg) -} - -fn parse_name_value(raw: &str) -> Result<(String, String)> { - let (name, value) = raw - .split_once('=') - .with_context(|| format!("expected NAME=VALUE, got: {raw}"))?; - Ok((name.trim().to_string(), value.trim().to_string())) -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index 7ffac76..0000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,106 +0,0 @@ -#![allow(dead_code)] // each test binary only exercises a subset of these helpers - -use std::net::SocketAddr; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, UdpSocket}; -use tokio_rustls::TlsConnector; - -/// Binds an ephemeral port, reads its number, then releases it. There's an -/// inherent (tiny) race between releasing and the caller rebinding, but it's -/// the standard way to get a free port for a test without threading the -/// bound listener itself through async task boundaries. -pub fn free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() -} - -/// Accepts TCP connections forever, echoing every byte back until the peer -/// half-closes, then closing its own side too. -pub async fn run_tcp_echo_server(addr: SocketAddr) { - let listener = TcpListener::bind(addr).await.expect("bind echo server"); - loop { - let (stream, _) = match listener.accept().await { - Ok(v) => v, - Err(_) => continue, - }; - tokio::spawn(async move { - let (mut r, mut w) = tokio::io::split(stream); - let _ = tokio::io::copy(&mut r, &mut w).await; - }); - } -} - -/// Receives UDP datagrams forever and echoes each one back upper-cased, so -/// tests can distinguish "the target answered" from "the client's own bytes -/// looped back somewhere". -pub async fn run_udp_echo_server(addr: SocketAddr) { - let socket = UdpSocket::bind(addr).await.expect("bind udp echo server"); - let mut buf = vec![0u8; 4096]; - loop { - let Ok((n, peer)) = socket.recv_from(&mut buf).await else { - continue; - }; - let reply: Vec = buf[..n].to_ascii_uppercase(); - let _ = socket.send_to(&reply, peer).await; - } -} - -pub async fn tcp_roundtrip(addr: SocketAddr, payload: &[u8]) -> Vec { - let mut stream = tokio::net::TcpStream::connect(addr) - .await - .expect("connect to forwarded port"); - stream.write_all(payload).await.expect("write payload"); - stream.shutdown().await.expect("shutdown write half"); - let mut out = Vec::new(); - stream - .read_to_end(&mut out) - .await - .expect("read echoed payload"); - out -} - -/// Same as [`tcp_roundtrip`] but over TLS, accepting the forward listener's -/// self-signed cert the same way `connect` accepts `serve`'s (see `tls` -/// module docs: confidentiality, not peer identity). -pub async fn tls_roundtrip(addr: SocketAddr, payload: &[u8]) -> Vec { - erbridge::tls::install_crypto_provider(); - let connector = TlsConnector::from(erbridge::tls::client_tls_config().unwrap()); - let tcp = tokio::net::TcpStream::connect(addr) - .await - .expect("connect to forwarded port"); - let server_name = rustls_pki_types::ServerName::try_from("erbridge").unwrap(); - let mut stream = connector - .connect(server_name, tcp) - .await - .expect("tls handshake"); - stream.write_all(payload).await.expect("write payload"); - stream.shutdown().await.expect("shutdown write half"); - let mut out = Vec::new(); - stream - .read_to_end(&mut out) - .await - .expect("read echoed payload"); - out -} - -/// Accepts TCP connections forever, counting them, and holds each one open -/// without ever writing a byte back. Models a server that waits for the client -/// to speak first and has not answered yet -- the case where nothing on the -/// target side would prompt the tunnel to acknowledge its streams. -pub async fn run_silent_tcp_server(addr: SocketAddr, accepted: Arc) { - let listener = TcpListener::bind(addr).await.expect("bind silent server"); - let mut held = Vec::new(); - loop { - let Ok((stream, _)) = listener.accept().await else { - continue; - }; - accepted.fetch_add(1, Ordering::SeqCst); - held.push(stream); - } -}