Compare commits

..
10 Commits
Author SHA1 Message Date
rootandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 0166fb6511 docs: add comprehensive project wiki for v1.0
CI / cargo fmt (push) Has been cancelled
CI / cargo clippy (macos-latest) (push) Has been cancelled
CI / cargo clippy (ubuntu-latest) (push) Has been cancelled
CI / cargo clippy (windows-latest) (push) Has been cancelled
CI / cargo test (macos-latest) (push) Has been cancelled
CI / cargo test (ubuntu-latest) (push) Has been cancelled
CI / cargo test (windows-latest) (push) Has been cancelled
CI / cargo build (macos-latest) (push) Has been cancelled
CI / cargo build (ubuntu-latest) (push) Has been cancelled
CI / cargo build (windows-latest) (push) Has been cancelled
CI / cargo build --release (macos-latest) (push) Has been cancelled
CI / cargo build --release (ubuntu-latest) (push) Has been cancelled
CI / cargo build --release (windows-latest) (push) Has been cancelled
CI / CLI smoke (macos-latest) (push) Has been cancelled
CI / CLI smoke (ubuntu-latest) (push) Has been cancelled
CI / CLI smoke (windows-latest) (push) Has been cancelled
CI / Minimal E2E (macos-latest) (push) Has been cancelled
CI / Minimal E2E (ubuntu-latest) (push) Has been cancelled
CI / Minimal E2E (windows-latest) (push) Has been cancelled
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 14:07:55 -06:00
rootandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> a758e8e0bc feat: add connection key encoding and full tunnel implementation
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 13:54:55 -06:00
c4ch3c4d3andfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> cc1eb55c58 docs: add rustunnel README
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 08:43:11 -06:00
c4ch3c4d3 241d7d1c1b fix: unmask SOCKS5 E2E curl failures in CI and assert response content
The minimal E2E in ci.yml was masking curl/SOCKS forwarding failures
with '|| true' and piping through 'head', so tunnel failures were
silently ignored and CI always reported success.

Changes:
- Remove '|| true' from the curl/SOCKS5 forwarding test
- Capture curl exit code and fail CI if non-zero
- Assert HTTP 200 status code from the response
- Assert response body contains expected HTTP server content
- Use cross-platform 'sed' instead of 'head -n -1' (BSD/macOS)
- Also unmask the HTTP target health check
- Preserve existing cross-platform cleanup trap and helpers
2026-06-04 08:38:05 -06:00
c4ch3c4d3andfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> b0210a6cfe fix: cross-platform CI E2E cleanup with trap and portable port killing
- Add EXIT trap so cleanup runs on success AND failure
- Replace Unix-only lsof with cross-platform kill_by_port that tries
  lsof, netstat+taskkill, and fuser with graceful fallbacks
- Add kill_process helper for OS-aware PID termination
- Track all PIDs (connector, listener, target) for reliable cleanup
- Clean up temp credential directory on exit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 08:31:58 -06:00
c4ch3c4d3andfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 32a29c89f2 feat: fix flaky generate test and format tunnel.rs
- Fix race condition in generate test temp_dir() by using PID + atomic counter
  instead of nanosecond timestamps that collide under parallel test execution
- Apply cargo fmt to tunnel.rs for consistent formatting

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 08:29:24 -06:00
c4ch3c4d3andfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 10b8b24dce feat: add operational reconnect and shutdown handling
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-04 08:25:36 -06:00
c4ch3c4d3 b67248e14f fix: prevent frame header corruption on partial reads
Frame::deserialize consumed header bytes from the buffer via get_u8/
get_u64/get_u32 even when the payload was incomplete, causing those
bytes to be permanently lost on retry. Replaced with slice-based
reading that never consumes until the complete frame (header + payload)
is available.

Added 9 new tests covering header-only, partial payload, remaining
payload, header-split, and multi-frame chunked scenarios.
Includes async FrameReader tests with chunked reads.
2026-06-03 21:10:00 -06:00
c4ch3c4d3andfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> d6a0a40696 Replace HTTP-shaped forwarding with true persistent multiplexed tunnel
Fix socks-e2e scrutiny blockers by replacing HTTP-shaped request/response
forwarding with a true persistent authenticated tunnel data path.

Key changes:
- Implement bidirectional framing with stream IDs over the authenticated
  tunnel so SOCKS target TCP connections originate from the listener side
- Multiple sequential/concurrent SOCKS streams share one authenticated
  session with stream isolation via frame dispatch
- Bytes stream incrementally without whole-response buffering or fixed EOF
  timeouts
- CLOSE/ERROR frames propagate deterministically for clean stream teardown
- Remove /forward HTTP endpoint and TunnelSession HTTP-based forwarding
- Remove read_until_double_crlf HTTP-specific parsing from SOCKS path
- Add StreamMux for concurrent stream management on the connector side
- Add tests for slow/interleaved streams, concurrent isolation,
  listener-side target origin, close frame propagation, and E2E framing

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-03 21:01:24 -06:00
c4ch3c4d3 f871ba7f7b Replace per-SOCKS-request HTTPS connections with persistent multiplexed tunnel session
- Introduce TunnelSession that holds a single authenticated SendRequest
  shared across all concurrent SOCKS5 streams via Arc<Mutex<>>
- Connector authenticates once on startup, then reuses the persistent
  tunnel for all SOCKS5 CONNECT requests
- Listener /forward returns raw bytes (no HTTP parse/unparse overhead)
  to preserve exact byte semantics for SOCKS5 TCP proxying
- Add stream counters (session-X) for logging and isolation tracking
- Add 5 new tests: sequential streams, concurrent streams with isolation,
  stream counter increments, pre-auth rejection, and E2E concurrent SOCKS5
  streams over a shared tunnel
- All 117 tests pass; E2E CLI validates 5 concurrent streams share one
  persistent TLS connection (127.0.0.1:64216 in transcript)
2026-06-03 20:17:39 -06:00
47 changed files with 6359 additions and 1636 deletions
+174
View File
@@ -117,3 +117,177 @@ jobs:
echo "ERROR: invalid port should have failed"
exit 1
fi
e2e-minimal:
name: Minimal E2E (${{ matrix.os }})
needs: [test]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build release binary
run: cargo build --release
- name: Run minimal E2E flow
shell: bash
run: |
set -e
# Use cargo run for OS-neutral binary invocation (avoids .exe vs no extension)
RUN="cargo run --release --"
# Use isolated ports to avoid conflicts
CRED_DIR=$(mktemp -d)
TARGET_PORT=17481
LISTEN_PORT=17480
SOCKS_PORT=17479
# Track PIDs for cleanup
CONNECTOR_PID=""
LISTENER_PID=""
TARGET_PID=""
# --- Cross-platform cleanup helpers ---
# kill_process: kill a process by PID, with OS-specific fallback
kill_process() {
local pid="$1"
if [ -z "$pid" ]; then return 0; fi
if command -v taskkill &>/dev/null; then
taskkill //PID "$pid" //F 2>/dev/null || true
else
kill "$pid" 2>/dev/null || true
fi
}
# kill_by_port: kill whatever process is listening on a given port
kill_by_port() {
local port="$1"
if command -v lsof &>/dev/null; then
lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
fi
if command -v netstat &>/dev/null; then
# Works on Windows (Git Bash) and Unix; extract PIDs from netstat output
local pids
pids=$(netstat -ano 2>/dev/null | grep ":${port} " | grep "LISTENING" | awk '{print $5}' | sort -u) || true
for p in $pids; do
# Skip empty, "0.0.0.0", or non-numeric PIDs
if [ -n "$p" ] && [ "$p" != "0.0.0.0" ] && [ "$p" -eq "$p" ] 2>/dev/null; then
kill_process "$p"
fi
done
fi
if command -v fuser &>/dev/null; then
fuser -k "$port/tcp" 2>/dev/null || true
fi
}
# cleanup: called by trap on EXIT (success or failure)
cleanup() {
echo "Running E2E cleanup..."
# Kill tracked PIDs first (reverse order)
kill_process "$CONNECTOR_PID"
kill_process "$LISTENER_PID"
kill_process "$TARGET_PID"
sleep 1
# Fallback: port-based cleanup for any stragglers
for PORT in $SOCKS_PORT $LISTEN_PORT $TARGET_PORT; do
kill_by_port "$PORT"
done
# Wait for tracked PIDs (best-effort)
wait "$CONNECTOR_PID" 2>/dev/null || true
wait "$LISTENER_PID" 2>/dev/null || true
wait "$TARGET_PID" 2>/dev/null || true
# Remove temp credentials
if [ -n "$CRED_DIR" ] && [ -d "$CRED_DIR" ]; then
rm -rf "$CRED_DIR"
fi
echo "E2E cleanup done."
}
# Register cleanup trap: runs on EXIT (covers success, failure, SIGINT, SIGTERM)
trap cleanup EXIT
# 1. Generate credentials
$RUN generate --out "$CRED_DIR"
# 2. Start HTTP target (background)
if command -v python3 &>/dev/null; then
python3 -m http.server "$TARGET_PORT" --bind 127.0.0.1 &
TARGET_PID=$!
else
echo "ERROR: python3 not available for HTTP target"
exit 1
fi
sleep 1
# Verify target is up
set +e
curl -sf "http://127.0.0.1:$TARGET_PORT/" > /dev/null
TARGET_CHECK=$?
set -e
if [ "$TARGET_CHECK" -ne 0 ]; then
echo "ERROR: HTTP target on port $TARGET_PORT is not responding"
exit 1
fi
# 3. Start listener (background)
$RUN listen \
--listen "127.0.0.1:$LISTEN_PORT" \
--cert "$CRED_DIR/server.crt" \
--key "$CRED_DIR/server.key" \
--ca-cert "$CRED_DIR/ca.pem" \
--auth-token-file "$CRED_DIR/token.txt" &
LISTENER_PID=$!
sleep 1
# 4. Start connector (background)
$RUN connect \
--target "127.0.0.1:$LISTEN_PORT" \
--socks "127.0.0.1:$SOCKS_PORT" \
--cert "$CRED_DIR/client.crt" \
--key "$CRED_DIR/client.key" \
--ca-cert "$CRED_DIR/ca.pem" \
--auth-token-file "$CRED_DIR/token.txt" &
CONNECTOR_PID=$!
sleep 3
# 5. Test SOCKS5 forwarding through the tunnel
# Do NOT mask failures with || true - CI must fail if tunnel does not forward traffic
set +e
RESPONSE=$(curl -s -w "\n%{http_code}" --proxy "socks5h://127.0.0.1:$SOCKS_PORT" "http://127.0.0.1:$TARGET_PORT/")
CURL_EXIT=$?
set -e
if [ "$CURL_EXIT" -ne 0 ]; then
echo "ERROR: curl through SOCKS5 proxy failed with exit code $CURL_EXIT"
echo "Response: $RESPONSE"
exit 1
fi
# Extract HTTP status code (last line) and response body
# Note: head -n -1 is not portable (fails on macOS BSD), use sed instead
HTTP_STATUS=$(echo "$RESPONSE" | tail -n 1)
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$ d')
echo "E2E HTTP status: $HTTP_STATUS"
echo "E2E response preview: ${RESPONSE_BODY:0:200}"
# Assert HTTP 200
if [ "$HTTP_STATUS" != "200" ]; then
echo "ERROR: Expected HTTP 200, got $HTTP_STATUS"
exit 1
fi
# Assert response contains expected content from Python http.server
if ! echo "$RESPONSE_BODY" | grep -qi "directory listing\|<html"; then
echo "ERROR: Response content does not match expected HTTP server output"
echo "Body preview: ${RESPONSE_BODY:0:500}"
exit 1
fi
echo "E2E test passed on ${{ matrix.os }}"
+2
View File
@@ -1,5 +1,7 @@
/target
/release
**/*.pem
**/*.json
!.wiki-meta.json
.env
*.log
+1 -1
View File
@@ -6,7 +6,7 @@ description = "Cross-platform HTTPS mTLS lab/dev tunneling tool"
license = "MIT"
[dependencies]
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tracing = "0.1"
+73
View File
@@ -0,0 +1,73 @@
# rustunnel
`rustunnel` is a cross-platform Rust tool for lab and development tunneling over HTTPS with mutual TLS and an application auth token. It exposes a local SOCKS5 proxy on the connector side and forwards traffic only after the HTTPS, certificate, and auth checks succeed.
## Legitimate use
Use `rustunnel` for local labs, development environments, and controlled testing where you own or are authorized to operate both endpoints. Do not use it to access systems without permission.
## Build and test
```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo check
cargo test --all-targets
cargo build
```
## Generate credentials
Generate local certificate and auth material into an explicit directory:
```sh
rustunnel generate --out ./creds
```
This creates `ca.pem`, `ca.key`, `server.crt`, `server.key`, `client.crt`, `client.key`, `token.txt`, and `config.json`.
## Run a tunnel
Start the HTTPS listener:
```sh
rustunnel listen \
--listen 127.0.0.1:4180 \
--cert ./creds/server.crt \
--key ./creds/server.key \
--ca-cert ./creds/ca.pem \
--auth-token-file ./creds/token.txt
```
Start the connector and local SOCKS5 proxy:
```sh
rustunnel connect \
--target 127.0.0.1:4180 \
--socks 127.0.0.1:1180 \
--cert ./creds/client.crt \
--key ./creds/client.key \
--ca-cert ./creds/ca.pem \
--auth-token-file ./creds/token.txt
```
Use the SOCKS5 proxy from a local client:
```sh
curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/
```
## Defaults and custom ports
- HTTPS listener: `127.0.0.1:4180`
- SOCKS5 proxy: `127.0.0.1:1180`
Override ports with `--listen`, `--target`, and `--socks`, as shown above.
## Security posture
HTTPS is the default tunnel carrier. The listener requires a trusted client certificate and a valid auth token; the connector validates the server certificate before establishing the tunnel. Private keys and auth tokens are treated as sensitive and are redacted from normal logs.
## Cross-platform CI
CI runs on Linux, macOS, and Windows. It checks formatting, Clippy, tests, builds, CLI smoke commands, release artifacts, and a minimal local generate/listen/connect/SOCKS5 end-to-end flow.
+50
View File
@@ -0,0 +1,50 @@
{
"generatedAt": "2026-06-04T13:55:00Z",
"commitHash": "753928239e5a86414f6a6912b908da4cf0960231",
"branch": "master",
"pageCount": 30,
"topLevelSections": [
"overview",
"by-the-numbers",
"lore",
"how-to-contribute",
"applications",
"systems",
"features",
"security",
"deployment",
"reference"
],
"pageOrder": [
"overview/index.md",
"overview/architecture.md",
"overview/getting-started.md",
"overview/glossary.md",
"by-the-numbers.md",
"lore.md",
"how-to-contribute/index.md",
"how-to-contribute/development-workflow.md",
"how-to-contribute/testing.md",
"how-to-contribute/debugging.md",
"how-to-contribute/patterns-and-conventions.md",
"how-to-contribute/tooling.md",
"applications/index.md",
"applications/rustunnel-cli.md",
"systems/index.md",
"systems/tunnel-engine.md",
"systems/tls-stack.md",
"systems/framing-protocol.md",
"systems/socks5-proxy.md",
"systems/credential-generation.md",
"features/index.md",
"features/connection-keys.md",
"features/stream-multiplexing.md",
"features/reconnect-and-shutdown.md",
"security.md",
"deployment.md",
"reference/index.md",
"reference/configuration.md",
"reference/data-models.md",
"reference/dependencies.md"
]
}
+3
View File
@@ -0,0 +1,3 @@
# Applications
rustunnel ships as a single binary application with five CLI commands. There are no separate services, daemons, or web frontends.
+57
View File
@@ -0,0 +1,57 @@
# rustunnel CLI
The rustunnel binary exposes five subcommands defined in `src/cli.rs` and dispatched in `src/main.rs`.
## Commands
### `listen`
Start the HTTPS tunnel listener. Binds an HTTPS server that accepts mTLS connections from connectors.
Key arguments:
- `--listen` — bind address (default `0.0.0.0:4180`)
- `--advertise` — public address embedded in auto-generated connection keys
- `--connection-key` — reusable key generated by `keygen` or a previous `listen` run
- `--socks` — optional server-side SOCKS5 proxy address for connector-side network access
- `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key)
- `--auth-token`, `--auth-token-file` — application auth token
- `--insecure-skip-tls-verify` — skip client certificate verification (lab only)
When run without explicit certificate paths and without a connection key, `listen` auto-generates a new connection key and prints it.
### `connect`
Connect to the listener and expose a local SOCKS5 proxy.
Key arguments:
- `CONNECTION_KEY` — positional connection key (optional)
- `--target` — listener address (default from connection key)
- `--connection-key` — connection key via flag or env `RUSTUNNEL_KEY`
- `--socks` — local SOCKS5 proxy address (default `127.0.0.1:1180`)
- `--cert`, `--key`, `--ca-cert` — TLS material paths (required unless using a connection key)
- `--insecure-skip-tls-verify` — skip server certificate verification (lab only)
### `generate`
Generate local certificate and auth material into an output directory.
Creates: `ca.pem`, `ca.key`, `server.crt`, `server.key`, `client.crt`, `client.key`, `token.txt`, `config.json`.
### `keygen`
Generate a single reusable connection key string. This bundles all certificate material, the auth token, and the target address into a base64-encoded JSON blob prefixed with `rtun1.`.
### `version`
Print version, edition, platform, and architecture.
## Entry point
`src/main.rs` installs the ring crypto provider, initializes tracing with an env-filter defaulting to `info`, parses the CLI, and dispatches to the appropriate runner function.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/cli.rs` | clap `Parser` and `Subcommand` definitions |
| `src/main.rs` | Entry point, command dispatch, host:port parsing helpers |
+52
View File
@@ -0,0 +1,52 @@
# By the numbers
Data collected on 2026-06-04.
## Size
| Metric | Value |
| ------ | ----- |
| Total source files | 12 |
| Total lines of code | ~7,535 |
| Largest file | `src/tunnel.rs` (3,526 lines) |
| Second largest | `src/socks5.rs` (789 lines) |
| Third largest | `src/framing.rs` (776 lines) |
| Test files | 0 standalone test files (all tests are inline `#[cfg(test)]` modules) |
| Languages | Rust only |
```mermaid
xychart-beta
title "Lines of code by module"
x-axis [tunnel, socks5, framing, tls, main, generate, redact, cli, config, connkey, errors, signal]
y-axis "Lines" 0 --> 3600
bar [3526, 789, 776, 609, 601, 440, 242, 237, 118, 93, 82, 22]
```
## Activity
- **Commits:** 16 total (as of HEAD)
- **Active development period:** June 34, 2026 (~2 days)
- **Most changed file:** `src/tunnel.rs` — underwent major rewrites from per-request HTTPS to persistent multiplexed tunnel, then to binary framing, then to reconnect/shutdown handling.
- **Second most changed:** `.github/workflows/ci.yml` — iterated on cross-platform E2E cleanup and smoke tests.
## Bot-attributed commits
- **Bot commits:** 7 of 16 (44%)
- **Bot types:** `factory-droid[bot]`
- Note: this is a lower bound. Inline AI tools like Copilot leave no trace in git history.
## Complexity
| File | Lines | Description |
| ---- | ----- | ----------- |
| `src/tunnel.rs` | 3,526 | Core tunnel engine, listener, connector, SOCKS5 proxy, reconnect loop, stream mux, ~30 inline tests |
| `src/socks5.rs` | 789 | SOCKS5 protocol parser, greeting, auth, CONNECT, target resolution |
| `src/framing.rs` | 776 | Binary frame serialization, deserialization, reader/writer, target encoding |
| `src/tls.rs` | 609 | rustls config builders, certificate loading, identity validation, fingerprinting |
| `src/main.rs` | 601 | CLI entry point, command dispatch, connection key integration, argument parsing helpers |
## Dependency count
- **Runtime dependencies:** 26 crates
- **Dev dependencies:** 1 crate (`tempfile`)
- **Largest dependency families:** Tokio ecosystem (`tokio`, `tokio-util`, `tokio-rustls`), rustls ecosystem (`rustls`, `rustls-pki-types`, `rustls-pemfile`), Hyper ecosystem (`hyper`, `hyper-util`, `http-body-util`, `tower-service`)
+54
View File
@@ -0,0 +1,54 @@
# Deployment
rustunnel is deployed as a single static binary. There are no containers, orchestrators, or external services required.
## Release builds
CI builds release artifacts on Linux, macOS, and Windows:
```sh
cargo build --release
```
The resulting binary is uploaded as a GitHub Actions artifact with a 7-day retention.
## Cross-platform CI
The `.github/workflows/ci.yml` runs on `ubuntu-latest`, `windows-latest`, and `macos-latest`:
1. `cargo fmt` — format check
2. `cargo clippy` — lint check
3. `cargo test` — unit and inline tests
4. `cargo build` — debug build
5. `cargo build --release` — release build with artifact upload
6. CLI smoke tests — `--help`, `version`, invalid command/port handling
7. Minimal E2E — generate, listen, connect, SOCKS5, curl
## E2E test flow
The E2E job uses `cargo run --release --` for OS-neutral invocation and performs:
1. Generate credentials to a temp directory
2. Start a Python HTTP server as the target
3. Start `rustunnel listen` in the background
4. Start `rustunnel connect` in the background
5. Wait for services to start
6. curl through the SOCKS5 proxy and assert HTTP 200 + expected body content
7. Cleanup via `trap cleanup EXIT` with PID-based and port-based killing
## No production deployment guidance
rustunnel is explicitly a lab and development tool. It does not include:
- Container images or Dockerfiles
- Kubernetes manifests
- Systemd service files
- Log rotation or monitoring infrastructure
For production tunneling, consider established tools like WireGuard, OpenVPN, or SSH.
## Key source files
| File | Purpose |
| ---- | ------- |
| `.github/workflows/ci.yml` | CI matrix: fmt, clippy, test, build, smoke, E2E |
+58
View File
@@ -0,0 +1,58 @@
# Connection keys
A connection key bundles all credential material and the target address into a single copy-pasteable string.
## Purpose
Simplify distribution of tunnel credentials between machines. Instead of transferring eight separate files, a user can generate one key and paste it into the `listen` and `connect` commands.
## Format
Connection keys start with the prefix `rtun1.` followed by base64url-encoded (no padding) JSON:
```json
{
"version": 1,
"target": "198.51.100.10:4180",
"ca_cert_pem": "-----BEGIN CERTIFICATE-----...",
"server_cert_pem": "-----BEGIN CERTIFICATE-----...",
"server_key_pem": "-----BEGIN PRIVATE KEY-----...",
"client_cert_pem": "-----BEGIN CERTIFICATE-----...",
"client_key_pem": "-----BEGIN PRIVATE KEY-----...",
"auth_token": "a1b2c3..."
}
```
## Key abstractions
| Type | File | Description |
| ---- | ---- | ----------- |
| `ConnectionKey` | `src/connkey.rs` | Struct with all fields, version check, validation |
| `ConnectionKey::encode` | `src/connkey.rs` | Serialize to JSON, base64url-encode, prepend prefix |
| `ConnectionKey::decode` | `src/connkey.rs` | Strip prefix, base64url-decode, deserialize, validate |
| `looks_like_connection_key` | `src/connkey.rs` | Quick check if a string starts with `rtun1.` |
## Validation
`decode` validates:
- Prefix must be `rtun1.`
- Base64 decoding must succeed
- JSON deserialization must succeed
- Version must be exactly `1`
- All string fields must be non-empty after trimming
## Integration
`src/main.rs` uses `ConnectionKey::decode` when the `--connection-key` flag or positional argument is provided. The decoded material is passed to `ServerTlsMaterial::Pem` or `ClientTlsMaterial::Pem` variants, which bypass file loading and use the embedded PEM strings directly.
## Entry points for modification
- To change the key format or add versioning: modify `src/connkey.rs`.
- To add compression or encryption: consider extending the encode/decode pipeline in `ConnectionKey`.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/connkey.rs` | Connection key struct, encoding, decoding, validation |
+7
View File
@@ -0,0 +1,7 @@
# Features
rustunnel's user-visible and developer-visible capabilities:
- [Connection keys](connection-keys.md) — single-string credential bundles
- [Stream multiplexing](stream-multiplexing.md) — multiple SOCKS5 streams over one tunnel
- [Reconnect and shutdown](reconnect-and-shutdown.md) — operational resilience
@@ -0,0 +1,57 @@
# Reconnect and shutdown
The connector is designed to recover from tunnel interruptions without dropping the SOCKS5 proxy.
## Purpose
Lab networks and development environments are unstable. The connector should tolerate listener restarts, network hiccups, and temporary failures without requiring manual intervention.
## Reconnect behavior
`run_reconnect_loop` in `src/tunnel.rs` continuously attempts to maintain a tunnel:
1. Connect to the target address via TCP.
2. Perform TLS handshake and server certificate verification.
3. Send the HTTP auth request and wait for 200 OK.
4. On success, create a `StreamMux`, publish it via the `watch::channel`, and run the framing loop.
5. When the tunnel drops (framing task ends), log the disconnection and retry.
### Backoff
- Initial delay: 1 second
- Exponential doubling up to a maximum of 30 seconds
- Auth failures are terminal (the loop exits rather than retrying with a bad token)
### SOCKS5 proxy during reconnect
The SOCKS5 proxy stays bound. If a client connects while the tunnel is down, the proxy sees `mux_receiver` is `None` and rejects the connection with a warning log. Once the reconnect loop succeeds, new SOCKS5 requests work immediately.
## Shutdown
Both listener and connector handle graceful shutdown:
- Unix: SIGINT or SIGTERM triggers shutdown via `tokio::signal::unix`.
- Windows: Ctrl+C or Ctrl+Break triggers shutdown via `tokio::signal::ctrl_c`.
### Listener shutdown
1. The shutdown signal breaks the `tokio::select!` in `run_listener`.
2. The TCP listener is dropped, releasing the bind port.
3. The SOCKS5 proxy task (if any) is aborted.
4. Active framing sessions end when their streams close.
### Connector shutdown
1. The shutdown signal breaks the `tokio::select!` in `run_socks5_proxy`.
2. The SOCKS5 listener is dropped.
3. The reconnect loop handle is aborted.
4. Existing SOCKS5 connections receive EOF when their streams close.
E2E tests `e2e_graceful_shutdown_listener` and `e2e_graceful_shutdown_connector` verify that ports are released within 3 seconds of abort.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/tunnel.rs` | `run_reconnect_loop`, `run_socks5_proxy`, `run_listener`, shutdown handling |
| `src/signal.rs` | Cross-platform shutdown signal future |
@@ -0,0 +1,47 @@
# Stream multiplexing
rustunnel carries multiple SOCKS5 connections over a single persistent TLS tunnel using stream IDs.
## Purpose
Avoid the overhead of establishing a new HTTPS connection for every SOCKS5 request. A single mTLS+auth session stays open, and multiple logical streams are interleaved via the binary framing protocol.
## How it works
### Connector side
1. A local SOCKS5 client connects to the connector's SOCKS5 proxy.
2. The connector's `StreamMux::open_stream` allocates the next odd stream ID.
3. It sends a CONNECT frame with the target address encoded in the payload.
4. It waits for a CONNECT_REPLY frame (status 0x00 = OK).
5. On success, it returns a `mpsc::Receiver<Bytes>` for tunnel-to-SOCKS5 data.
6. The connector spawns a task to read from the SOCKS5 client and send DATA frames to the tunnel.
7. Another path reads from the `data_rx` channel and writes raw bytes back to the SOCKS5 client.
### Listener side
1. The listener receives a CONNECT frame with a stream ID and target address.
2. It resolves the target address and opens a TCP connection.
3. It spawns a task to read from the target and send DATA frames back to the tunnel.
4. It sends a CONNECT_REPLY frame to acknowledge the stream.
5. Incoming DATA frames for that stream ID are written to the target connection.
6. CLOSE frames trigger shutdown of the target connection.
### Stream isolation
Each stream has its own ID. Data from one stream never mixes with another. The E2E test `e2e_concurrent_streams_isolation` verifies this by sending unique payloads through two concurrent streams and asserting each echo response matches its original payload.
## Key abstractions
| Type | File | Description |
| ---- | ---- | ----------- |
| `StreamMux` | `src/tunnel.rs` | Connector-side multiplexer |
| `ServerStreamEntry` | `src/tunnel.rs` | Listener-side per-stream target write half |
| `ConnectorStreamEntry` | `src/tunnel.rs` | Connector-side per-stream data channel and reply channel |
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/tunnel.rs` | StreamMux, open_stream, dispatch_frame, framing loops |
| `src/framing.rs` | Frame types, CONNECT/DATA/CLOSE/ERROR encoding |
+53
View File
@@ -0,0 +1,53 @@
# Debugging
## Logs
rustunnel uses `tracing` with an `EnvFilter` defaulting to `info`. Set `RUST_LOG=debug` or `RUST_LOG=trace` for more detail.
```sh
RUST_LOG=debug ./target/release/rustunnel listen --listen 127.0.0.1:4180 ...
```
All logged secrets are redacted. The `Redacted` wrapper shows `[REDACTED(len=N)]` instead of the raw value. If you see raw secrets in logs, that is a bug.
## Common errors
### TLS handshake failures
If the connector fails with a TLS error, check:
- Do the listener and connector share the same CA certificate?
- Does the server certificate's SAN match the target hostname? Use `generate` with the correct `--advertise` address, or pass `--insecure-skip-tls-verify` (lab only).
- Are the certificate files readable and valid PEM?
rustunnel prints TLS hint messages for common failures:
- `BadSignature` — the listener certificate is not signed by the CA trusted by the connector.
- `NotValidForName` — the listener certificate does not match the target host.
### Auth failures
- Verify the token file is not empty and has no trailing whitespace.
- The listener and connector must use the same token value.
- Auth failures are terminal for the connector's reconnect loop (it will not retry with a bad token).
### Port conflicts
If binding fails with "address already in use":
- Check for orphaned processes from previous test runs.
- The E2E tests use random free ports; manual testing may need explicit port management.
### SOCKS5 connection refused
If curl fails through the SOCKS5 proxy:
- Is the connector running and the tunnel established?
- Is the SOCKS5 proxy port correct?
- Is the target address reachable from the listener side (for default mode) or connector side (when using `--socks` on the listener)?
## Tracing
The connector logs effective config at startup, including the target, SOCKS5 address, and redacted auth token. The listener logs its bind address, TLS fingerprints, and auth token (redacted).
State transitions are logged at `info` level: "Tunnel session established", "Tunnel disconnected", "Reconnecting in Ns".
@@ -0,0 +1,25 @@
# Development workflow
## Branching
Use feature branches off `master`. The project is small enough that long-lived branches are unnecessary.
## Coding
- Follow existing formatting (enforced by `cargo fmt`).
- Use `anyhow` for application-level errors and `thiserror` for library-style error enums.
- Prefer `tracing` over `println!` for diagnostic output. Secrets must be wrapped with `Redacted` before logging.
- Use `Arc<String>` for shared strings across async boundaries (e.g., auth tokens in `ListenerConfig` and `ConnectorConfig`).
- Keep synchronous protocol parsing in dedicated modules (`socks5.rs`, `framing.rs`) and async I/O in `tunnel.rs`.
## Testing
- Inline tests live in `#[cfg(test)]` modules at the bottom of each source file.
- Use `tempfile::tempdir()` for generating test credentials. Clean up temp directories with `std::fs::remove_dir_all`.
- Use `std::net::TcpListener::bind("127.0.0.1:0")` to get free ports for E2E tests.
- E2E tests spawn tokio tasks for listener, connector, and target servers, then abort them in cleanup.
## Committing
- Follow conventional commit style: `feat:`, `fix:`, `docs:`, `chore:`.
- Keep commits focused. The git history shows the evolution from HTTP-per-stream to multiplexed tunnel; preserve that narrative clarity.
+29
View File
@@ -0,0 +1,29 @@
# How to contribute
rustunnel is a small, focused project. Contributions should align with the project's purpose: a lab and development tunneling tool with strong security defaults.
## Work pickup
- Check the CI status before starting work.
- Open issues or PRs against the `master` branch.
- Keep changes focused. The codebase is small enough that large refactors should be discussed first.
## PR process
1. Run the full test suite locally: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --all-targets`
2. Ensure E2E tests pass if your change touches the tunnel or SOCKS5 path.
3. Write tests for new behavior. The project uses inline `#[cfg(test)]` modules.
4. Update relevant wiki pages if your change adds or removes features.
## Review expectations
- Security-sensitive changes (TLS, auth, framing) get extra scrutiny.
- All CI jobs must pass: fmt, clippy, test, build, smoke, and E2E.
- The project runs CI on Linux, macOS, and Windows. Avoid platform-specific code unless necessary (the signal handling module is the only current exception).
## Definition of done
- Code is formatted with `rustfmt`.
- Clippy warnings are resolved.
- Tests cover the new behavior, including edge cases.
- No `TODO` or `FIXME` comments are introduced (the project currently has zero).
@@ -0,0 +1,55 @@
# Patterns and conventions
## Error handling
The project uses a layered error strategy:
- `thiserror` enums for domain-specific errors (`TlsError`, `AuthError`, `TunnelError`, `HostError`, `Socks5Error`, `FrameError`). These live in `src/errors.rs` and `src/socks5.rs` / `src/framing.rs`.
- `anyhow` for application-level propagation. CLI commands return `anyhow::Result` and `main` exits with `process::exit(exit_code)`.
- All errors are designed to be actionable and fail-closed. There are no silent degradation paths to insecure modes.
## Secret handling
- The `Redacted` wrapper in `src/redact.rs` must be used before logging any secret.
- `Redacted::inner()` and `into_inner()` are marked `#[allow(dead_code)]` to discourage use, but available for file writes.
- `is_sensitive()` detects PEM private key blocks. `is_auth_token()` detects long alphanumeric strings.
- `is_connection_key()` detects strings starting with `rtun1.`.
## Async patterns
- Tokio's multi-threaded runtime is used on both listener and connector sides.
- `tokio::spawn` is used for per-connection and per-stream tasks.
- `tokio::select!` with `biased;` prioritizes shutdown signals over new connections.
- `watch::channel` is used for publishing the active `StreamMux` to the SOCKS5 proxy (supports reconnect).
- `mpsc::channel` and `oneshot::channel` are used for stream data and CONNECT_REPLY coordination.
## Constant-time comparison
Auth token comparison in `src/tunnel.rs` uses a manual constant-time XOR loop to prevent timing attacks:
```rust
fn constant_time_compare(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
let mut result: u8 = 0;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
result == 0
}
```
## Frame reader partial read discipline
`FrameReader` in `src/framing.rs` never consumes bytes from the buffer until a complete frame (header + payload) is available. This prevents corruption when TCP segments arrive in arbitrary chunks.
## TLS material as enum
Both `ServerTlsMaterial` and `ClientTlsMaterial` are enums with `Paths` and `Pem` variants. This lets the listener and connector accept either file paths (from CLI args) or embedded PEM strings (from connection keys).
## Insecure mode
`--insecure-skip-tls-verify` exists for DPI/intercepted environments only. When enabled, the listener skips client certificate verification and the connector skips server certificate verification. This is logged at `warn` level.
+75
View File
@@ -0,0 +1,75 @@
# Testing
## Test organization
All tests are inline `#[cfg(test)]` modules at the bottom of each source file. There are no standalone test files or integration test directories.
## Running tests
```sh
cargo test --all-targets
```
This runs unit tests and doc tests across all modules.
## Test patterns
### Credential generation tests
Tests in `src/generate.rs` verify that `generate()` creates all expected files, that certificates contain valid PEM blocks, that private keys are detected as sensitive by the redaction module, and that config JSON is valid.
### Framing tests
Tests in `src/framing.rs` cover:
- Frame roundtrips for all frame types (CONNECT, CONNECT_REPLY, DATA, CLOSE, ERROR)
- Partial read handling — buffers must not be consumed until a complete frame is available
- Multiple frames in one buffer
- Target address encoding/decoding for IPv4 and domain names
### SOCKS5 tests
Tests in `src/socks5.rs` cover:
- Greeting parsing (no-auth, username/password, invalid versions)
- Auth request parsing (valid and empty credentials)
- CONNECT request parsing (IPv4, domain, unsupported commands)
- Reply building
- Target resolution (IPv4 direct, domain DNS lookup)
- Malformed input handling
### TLS tests
Tests in `src/tls.rs` cover:
- Loading certificates and keys from files and PEM strings
- Building valid server and client configs
- Identity validation against SANs and CN
- Certificate fingerprinting
- Expiration checking
- Insecure config variants
### Tunnel tests
Tests in `src/tunnel.rs` are the most extensive. They include:
- **Auth tests:** valid mTLS + auth, invalid token, missing client cert
- **E2E tests:** basic forwarding, server-side SOCKS5 reaching connector-side targets, concurrent stream isolation, target failure handling, listener-side target origin verification
- **Operational tests:** reconnect restoring traffic after tunnel drop, graceful shutdown of listener and connector, shutdown during active streams
- **Log tests:** verify effective config is logged with redacted tokens, verify auth failures don't leak token values in error messages
## E2E test structure
Typical E2E tests follow this pattern:
1. Generate test credentials with `generate_test_creds()`.
2. Pick a free port with `get_free_port()`.
3. Spawn the listener as a tokio task.
4. Spawn the connector (and optionally a target server) as tokio tasks.
5. Sleep briefly to let services start.
6. Connect via SOCKS5, send data, verify response.
7. Abort all tasks in cleanup.
## CI test matrix
Tests run on `ubuntu-latest`, `windows-latest`, and `macos-latest`.
+40
View File
@@ -0,0 +1,40 @@
# Tooling
## Build system
Cargo is the sole build tool. No custom build scripts or Makefiles are used.
- `cargo build` — debug build
- `cargo build --release` — optimized release build
- `cargo check` — fast type-checking
## Linting and formatting
- `cargo fmt --check` — enforces rustfmt formatting
- `cargo clippy --all-targets -- -D warnings` — runs clippy on all targets with warnings as errors
## CI/CD
GitHub Actions runs the full matrix on every push and PR:
| Job | Platforms | Purpose |
| --- | --------- | ------- |
| `fmt` | Ubuntu | Format check |
| `clippy` | Linux, macOS, Windows | Lint check |
| `test` | Linux, macOS, Windows | Unit and inline tests |
| `build` | Linux, macOS, Windows | Debug build |
| `release-build` | Linux, macOS, Windows | Release build + artifact upload |
| `smoke` | Linux, macOS, Windows | CLI smoke tests (--help, version, invalid command/port) |
| `e2e-minimal` | Linux, macOS, Windows | Full end-to-end flow: generate, listen, connect, SOCKS5, curl |
The E2E job uses `cargo run --release --` for OS-neutral binary invocation and includes cross-platform cleanup helpers (`kill_process`, `kill_by_port`) invoked via `trap cleanup EXIT`.
## Dependencies
Dependencies are managed in `Cargo.toml`. Notable choices:
- `tokio` with `full` features for async runtime
- `rustls` with `ring` for TLS (no OpenSSL)
- `rcgen` for certificate generation
- `clap` with `derive` for CLI parsing
- `tracing` and `tracing-subscriber` for structured logging
+53
View File
@@ -0,0 +1,53 @@
# Lore
## Project origins
rustunnel was created in June 2026 as a lab and development tunneling tool. The goal was a cross-platform, self-contained Rust binary that could tunnel traffic over HTTPS with mutual TLS and an application auth token, without relying on external infrastructure.
## Eras
### Foundation (June 3, 2026)
The first commit (`eb4ef2b`) laid out the project skeleton: CLI surface using clap, a `generate` command for self-signed certificates, secret redaction, and a cross-platform CI matrix. A follow-up commit (`70411e5`) addressed initial scrutiny findings.
### Secure tunnel implementation (June 3, 2026)
The core tunnel logic landed in `393100d`, implementing HTTPS mTLS with an auth handshake. Shortly after, `76bda0b` addressed secure-tunnel scrutiny findings. Dependency updates and CLI refinements followed in `fc776fc`.
### SOCKS5 and multiplexing (June 3, 2026)
Three major commits transformed the architecture:
- `6466753` — Implemented a SOCKS5 proxy with stream multiplexing over HTTPS. This replaced the earlier design where each SOCKS5 request opened a new HTTPS connection.
- `f871ba7` — Replaced per-SOCKS-request HTTPS connections with a single persistent multiplexed tunnel session.
- `d6a0a40` — Replaced HTTP-shaped forwarding with a true persistent multiplexed tunnel using a custom binary framing protocol.
### Framing hardening (June 3, 2026)
- `b67248e` — Fixed frame header corruption on partial reads. The framing reader was refined to never consume bytes from the buffer until a complete frame (header + payload) is available.
### Operational features (June 4, 2026)
- `10b8b24` — Added operational reconnect and shutdown handling. The connector now has a background reconnect loop with exponential backoff, and both sides handle graceful shutdown on SIGINT/SIGTERM.
- `32a29c8` — Fixed flaky generate tests and formatted `tunnel.rs`.
- `b0210a6` — Improved cross-platform CI E2E cleanup with `trap` and portable port killing.
- `241d7d1` — Unmasked SOCKS5 E2E curl failures in CI and added response content assertions.
- `cc1eb55` — Added the project README.
- `a758e8e` — Added connection key encoding (`connkey.rs`) and the full tunnel implementation integrating keys into `listen` and `connect`.
## Longest-standing code
Given the project's short lifespan (2 days), all code is roughly the same age. The modules that have survived the most rewrites are:
- `src/cli.rs` — CLI structure has remained stable since the first commit.
- `src/redact.rs` — Secret redaction has not changed since the foundation commit.
- `src/errors.rs` — Error types have been extended but the core structure is original.
## Deprecated features
None so far. The project is new enough that no features have been removed.
## Major rewrites
- **From HTTP-per-stream to persistent multiplexed tunnel (June 3, 2026)** — The original SOCKS5 implementation opened a new HTTPS connection for every stream. This was replaced by a single persistent tunnel with a binary framing protocol, dramatically reducing connection overhead.
- **From HTTP forwarding to binary framing (June 3, 2026)** — After multiplexing was introduced, the data plane switched from raw HTTP forwarding to a lightweight binary protocol with CONNECT/DATA/CLOSE/ERROR frames.
+86
View File
@@ -0,0 +1,86 @@
# Architecture
rustunnel is organized as a single Rust binary crate with a modular source tree. The architecture separates CLI parsing, credential generation, TLS configuration, SOCKS5 handling, binary framing, and the core tunnel engine into distinct modules.
## Component diagram
```mermaid
graph TD
A[CLI `src/cli.rs`] --> B[Main dispatch `src/main.rs`]
B --> C[Credential generation `src/generate.rs`]
B --> D[Tunnel engine `src/tunnel.rs`]
B --> E[Connection keys `src/connkey.rs`]
D --> F[TLS stack `src/tls.rs`]
D --> G[SOCKS5 proxy `src/socks5.rs`]
D --> H[Framing protocol `src/framing.rs`]
D --> I[Signal handling `src/signal.rs`]
F --> J[Rustls + ring]
G --> K[Tokio TCP]
H --> L[Bytes + tokio-util]
C --> M[rcgen]
```
## Data flow
A typical tunnel session flows like this:
1. **Listener** binds a TCP socket and wraps it with `tokio_rustls::TlsAcceptor`.
2. Connector initiates a TCP connection and upgrades it with `tokio_rustls::TlsConnector`.
3. Both sides perform mTLS handshakes: the listener requires a client certificate signed by the shared CA; the connector validates the server certificate against the same CA.
4. The connector sends an HTTP POST to `/tunnel` with an `X-Rustunnel-Token` header.
5. The listener validates the token with a constant-time comparison (`src/tunnel.rs`).
6. On success, both sides switch from HTTP/1.1 to a binary framing protocol (`src/framing.rs`).
7. The connector's local SOCKS5 proxy accepts client connections and opens multiplexed streams over the single persistent tunnel.
8. The listener decodes each stream's target address, opens a local TCP connection to that target, and forwards data bidirectionally.
```mermaid
sequenceDiagram
participant Client as Local app
participant Socks as SOCKS5 proxy
participant Conn as Connector
participant TLS as mTLS tunnel
participant List as Listener
participant Target as Target server
Client->>Socks: SOCKS5 CONNECT
Socks->>Conn: open stream
Conn->>TLS: TLS handshake
TLS->>List: accept + verify client cert
Conn->>List: POST /tunnel + auth token
List->>Conn: 200 OK
Conn->>List: binary framing CONNECT frame
List->>Target: TCP connect
Target->>List: response
List->>Conn: DATA frame
Conn->>Socks: raw bytes
Socks->>Client: response
```
## Module responsibilities
| Module | Responsibility |
| ------ | -------------- |
| `src/cli.rs` | clap-based CLI definitions for all subcommands and arguments |
| `src/main.rs` | Entry point, crypto provider installation, tracing init, command dispatch |
| `src/tunnel.rs` | Core tunnel engine: listener accept loop, connector reconnect loop, SOCKS5 proxy, stream mux, HTTP auth handshake, framing loop |
| `src/tls.rs` | rustls server and client config builders, certificate loading, identity validation, insecure verifier |
| `src/socks5.rs` | SOCKS5 greeting, auth, CONNECT request parsing, target resolution |
| `src/framing.rs` | Binary frame serialization/deserialization, `FrameReader`, `FrameWriter` |
| `src/generate.rs` | Self-signed CA, server cert, client cert, and auth token generation with rcgen |
| `src/connkey.rs` | `ConnectionKey` struct: encode/decode all material as a base64 string |
| `src/redact.rs` | `Redacted` wrapper and helpers to prevent secrets from leaking into logs |
| `src/errors.rs` | `thiserror`-based error enums for TLS, auth, tunnel, and host operations |
| `src/config.rs` | JSON configuration file format for generated credential sets |
| `src/signal.rs` | Cross-platform shutdown signal handler (SIGINT/SIGTERM on Unix, Ctrl+C on Windows) |
## Threading model
The project uses Tokio's multi-threaded runtime. Each side (listener and connector) spawns tasks for:
- Accepting new TCP connections
- TLS handshakes
- Per-tunnel framing loops
- Per-stream target forwarding
- SOCKS5 client handling
The connector's SOCKS5 proxy stays bound permanently while a background reconnect loop maintains the tunnel. When the tunnel drops, existing streams receive EOF and new streams wait for the reconnect loop to re-establish the session.
+101
View File
@@ -0,0 +1,101 @@
# Getting started
## Prerequisites
- Rust 1.85+ (the project uses Edition 2024)
- A POSIX shell or PowerShell for running commands
- curl (for E2E testing)
## Build
```sh
cd rustunnel
cargo build --release
```
The release binary is produced at `target/release/rustunnel`.
## Run tests
```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo check
cargo test --all-targets
```
The test suite includes unit tests for framing, SOCKS5 parsing, TLS config building, credential generation, connection key roundtrips, and full end-to-end tunnel flows.
## Generate credentials
```sh
./target/release/rustunnel generate --out ./creds
```
This creates:
- `creds/ca.pem` — CA certificate
- `creds/ca.key` — CA private key
- `creds/server.crt` — server certificate
- `creds/server.key` — server private key
- `creds/client.crt` — client certificate
- `creds/client.key` — client private key
- `creds/token.txt` — auth token
- `creds/config.json` — configuration file with paths and defaults
## Run a tunnel
### Start the listener
```sh
./target/release/rustunnel listen \
--listen 127.0.0.1:4180 \
--cert ./creds/server.crt \
--key ./creds/server.key \
--ca-cert ./creds/ca.pem \
--auth-token-file ./creds/token.txt
```
### Start the connector
```sh
./target/release/rustunnel connect \
--target 127.0.0.1:4180 \
--socks 127.0.0.1:1180 \
--cert ./creds/client.crt \
--key ./creds/client.key \
--ca-cert ./creds/ca.pem \
--auth-token-file ./creds/token.txt
```
### Use the SOCKS5 proxy
```sh
curl --proxy socks5h://127.0.0.1:1180 http://127.0.0.1:4181/
```
## Using a connection key
Instead of passing individual certificate paths, you can generate a single connection key:
```sh
./target/release/rustunnel keygen --target 127.0.0.1:4180
```
Then start the listener with the key (it auto-generates credentials):
```sh
./target/release/rustunnel listen --connection-key <key>
```
And connect with the same key:
```sh
./target/release/rustunnel connect <key>
```
## Defaults
- Listener bind address: `0.0.0.0:4180`
- Connector target: required (or taken from connection key)
- Connector SOCKS5 proxy: `127.0.0.1:1180`
+19
View File
@@ -0,0 +1,19 @@
# Glossary
**Connection key** — a base64-encoded JSON blob (prefix `rtun1.`) that bundles the CA certificate, server certificate, server key, client certificate, client key, target address, auth token, and version. Generated by `rustunnel keygen` and consumed by `rustunnel listen` and `rustunnel connect`.
**Connector** — the `rustunnel connect` side. Establishes an outbound HTTPS+mTLS connection to the listener, authenticates, then exposes a local SOCKS5 proxy for local applications.
**Framing protocol** — the binary protocol used after the HTTP auth handshake completes. Frame types: CONNECT, CONNECT_REPLY, DATA, CLOSE, ERROR. All multi-byte integers are big-endian. Max payload per frame is 4 MiB.
**Listener** — the `rustunnel listen` side. Binds an HTTPS server that accepts mTLS connections, validates the auth token, then upgrades the connection to the binary framing protocol.
**mTLS** — mutual TLS. Both the listener and connector present certificates and verify the peer's certificate against a shared CA. rustunnel uses rustls with ring as the crypto provider.
**Security gate** — one of the validation steps that must pass before tunnel forwarding begins: TLS handshake, client certificate verification (listener), server certificate verification (connector), application auth token validation.
**SOCKS5 proxy** — the local proxy exposed by the connector. Implements RFC 1928 CONNECT with optional username/password auth (RFC 1929). Supports IPv4 and domain name targets.
**Stream ID** — a monotonically increasing odd integer (1, 3, 5, ...) assigned by the connector for each SOCKS5 connection. Even IDs are reserved for future server-initiated streams.
**StreamMux** — the connector-side structure that manages multiple concurrent streams over a single tunnel connection. Maps stream IDs to per-stream data channels.
+20
View File
@@ -0,0 +1,20 @@
# rustunnel
`rustunnel` is a cross-platform Rust CLI tool for lab and development tunneling over HTTPS with mutual TLS (mTLS) and an application-level auth token. It exposes a local SOCKS5 proxy on the connector side and forwards traffic only after HTTPS, certificate, and auth checks succeed.
The tool is designed for local labs, development environments, and controlled testing where you own or are authorized to operate both endpoints. It is not a production-grade tunnel or a mechanism for accessing systems without permission.
## What it does
- **Listener** (`rustunnel listen`) — binds an HTTPS server that accepts mTLS connections from connectors, validates an auth token, then upgrades the connection to a persistent binary-framed tunnel.
- **Connector** (`rustunnel connect`) — connects to the listener over HTTPS with mTLS, authenticates, then exposes a local SOCKS5 proxy that forwards traffic through the tunnel.
- **Credential generation** (`rustunnel generate`) — creates a self-signed CA, server certificate, client certificate, and auth token for a local session.
- **Connection keys** (`rustunnel keygen`) — bundles all credential material into a single base64-encoded string that can be copy-pasted between machines.
## Quick links
- [Architecture](../overview/architecture.md)
- [Getting started](../overview/getting-started.md)
- [Tunnel engine](../systems/tunnel-engine.md)
- [TLS stack](../systems/tls-stack.md)
- [Security posture](../security.md)
+82
View File
@@ -0,0 +1,82 @@
# Configuration
## CLI arguments
### `listen`
| Argument | Default | Description |
| -------- | ------- | ----------- |
| `--listen` | `0.0.0.0:4180` | HTTPS listener bind address |
| `--advertise` | derived from `--listen` | Public address in auto-generated connection keys |
| `--connection-key` | none | Reusable key (env: `RUSTUNNEL_KEY`) |
| `--socks` | none | Server-side SOCKS5 proxy address |
| `--cert` | "" | Server certificate path |
| `--key` | "" | Server private key path |
| `--ca-cert` | "" | CA certificate path |
| `--auth-token` | "" | Auth token value |
| `--auth-token-file` | none | Auth token file path |
| `--insecure-skip-tls-verify` | false | Skip client cert verification |
### `connect`
| Argument | Default | Description |
| -------- | ------- | ----------- |
| `CONNECTION_KEY` | none | Positional connection key |
| `--target` | from key | Listener address |
| `--connection-key` | none | Key via flag (env: `RUSTUNNEL_KEY`) |
| `--socks` | `127.0.0.1:1180` | Local SOCKS5 proxy address |
| `--cert` | "" | Client certificate path |
| `--key` | "" | Client private key path |
| `--ca-cert` | "" | CA certificate path |
| `--auth-token` | "" | Auth token value |
| `--auth-token-file` | none | Auth token file path |
| `--insecure-skip-tls-verify` | false | Skip server cert verification |
### `generate`
| Argument | Default | Description |
| -------- | ------- | ----------- |
| `--out` | `.` | Output directory |
| `--ca-name` | `rustunnel-ca` | CA common name |
| `--server-name` | `rustunnel-server` | Server common name |
| `--client-name` | `rustunnel-client` | Client common name |
### `keygen`
| Argument | Default | Description |
| -------- | ------- | ----------- |
| `--target` | `127.0.0.1:4180` | Listener address embedded in key |
| `--ca-name` | `rustunnel-ca` | CA common name |
| `--server-name` | `rustunnel-server` | Server common name |
| `--client-name` | `rustunnel-client` | Client common name |
## Environment variables
- `RUST_LOG` — tracing filter (e.g., `debug`, `trace`)
- `RUSTUNNEL_KEY` — connection key for `listen` and `connect`
## Config file format
`config.json` (generated by `rustunnel generate`):
```json
{
"listen_address": "0.0.0.0",
"listen_port": 4180,
"socks_address": "127.0.0.1",
"socks_port": 1180,
"ca_cert_path": "ca.pem",
"server_cert_path": "server.crt",
"server_key_path": "server.key",
"client_cert_path": "client.crt",
"client_key_path": "client.key",
"auth_token_path": "token.txt"
}
```
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/cli.rs` | clap argument definitions and defaults |
| `src/config.rs` | `Config` struct for JSON serialization |
+93
View File
@@ -0,0 +1,93 @@
# Data models
## ConnectionKey
```rust
pub struct ConnectionKey {
pub version: u8, // must be 1
pub target: String, // listener address
pub ca_cert_pem: String,
pub server_cert_pem: String,
pub server_key_pem: String,
pub client_cert_pem: String,
pub client_key_pem: String,
pub auth_token: String,
}
```
Encoded as: `rtun1.` + base64url(JSON) — no padding.
## Config
```rust
pub struct Config {
pub listen_address: String,
pub listen_port: u16,
pub socks_address: String,
pub socks_port: u16,
pub ca_cert_path: String,
pub server_cert_path: String,
pub server_key_path: String,
pub client_cert_path: String,
pub client_key_path: String,
pub auth_token_path: String,
}
```
## Frame
```rust
pub struct Frame {
pub frame_type: u8, // 0x01 CONNECT, 0x02 CONNECT_REPLY, 0x03 DATA, 0x04 CLOSE, 0x05 ERROR
pub stream_id: u64, // odd for client-initiated
pub flags: u8, // reserved (0x00)
pub payload: Bytes,
}
```
Wire format: 1 byte type + 8 bytes stream_id (big-endian) + 4 bytes len (big-endian) + 1 byte flags + len bytes payload.
Max payload: 4 MiB.
## Socks5Target
```rust
pub enum Socks5Target {
Ipv4(std::net::Ipv4Addr, u16),
Domain { domain: String, port: u16 },
}
```
## GeneratedMaterial
```rust
pub struct GeneratedMaterial {
pub ca_cert_pem: String,
pub ca_key_pem: String,
pub server_cert_pem: String,
pub server_key_pem: String,
pub client_cert_pem: String,
pub client_key_pem: String,
pub auth_token: String,
}
```
## Error enums
- `TlsError` — missing/invalid certificates, identity mismatch, expiration, verification failure
- `AuthError` — missing or invalid token
- `TunnelError` — TLS error, auth error, bind error, connection refused, port in use, protocol error
- `HostError` — DNS resolution failure, invalid hostname, no addresses
- `Socks5Error` — invalid version, unsupported auth/command/addr type, parse errors
- `FrameError` — frame too short, payload overflow, I/O error, protocol error
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/connkey.rs` | `ConnectionKey` |
| `src/config.rs` | `Config` |
| `src/framing.rs` | `Frame`, `FrameReader`, `FrameWriter` |
| `src/socks5.rs` | `Socks5Target`, `Socks5State`, `Socks5Error` |
| `src/generate.rs` | `GeneratedMaterial` |
| `src/errors.rs` | `TlsError`, `AuthError`, `TunnelError`, `HostError` |
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
## Runtime dependencies
| Crate | Version | Purpose |
| ----- | ------- | ------- |
| `clap` | 4 | CLI parsing with derive macros |
| `tokio` | 1 | Async runtime (full features) |
| `tokio-util` | 0.7 | I/O utilities |
| `tracing` | 0.1 | Structured logging |
| `tracing-subscriber` | 0.3 | Log formatting with env-filter |
| `anyhow` | 1 | Application error handling |
| `thiserror` | 1 | Library error enums |
| `serde` | 1 | Serialization (derive) |
| `serde_json` | 1 | JSON encoding/decoding |
| `rcgen` | 0.14 | Certificate generation |
| `x509-parser` | 0.16 | Certificate parsing for identity validation |
| `time` | 0.3 | Date/time formatting for certificate validity |
| `rand` | 0.8 | Random token generation |
| `hex` | 0.4 | Hex encoding for auth tokens |
| `rustls` | 0.23 | TLS implementation (ring provider) |
| `rustls-pki-types` | 1 | rustls type definitions |
| `rustls-pemfile` | 2 | PEM parsing for certificates and keys |
| `tokio-rustls` | 0.26 | Tokio integration for rustls |
| `hyper` | 1 | HTTP types (used for auth handshake) |
| `hyper-util` | 0.1 | HTTP utilities |
| `http-body-util` | 0.1 | HTTP body utilities |
| `tower-service` | 0.3 | Service trait (Hyper ecosystem) |
| `base64` | 0.22 | Connection key encoding |
| `sha2` | 0.10 | Certificate fingerprinting |
| `bytes` | 1 | Byte buffers for framing |
| `futures` | 0.3 | Future utilities |
| `url` | 2 | URL parsing |
## Dev dependencies
| Crate | Version | Purpose |
| ----- | ------- | ------- |
| `tempfile` | 3 | Temporary directories for tests |
## Key source files
| File | Purpose |
| ---- | ------- |
| `Cargo.toml` | Dependency declarations and features |
+7
View File
@@ -0,0 +1,7 @@
# Reference
Configuration, data models, and external dependencies.
- [Configuration](configuration.md)
- [Data models](data-models.md)
- [Dependencies](dependencies.md)
+59
View File
@@ -0,0 +1,59 @@
# Security
rustunnel's security posture is built on multiple layers: TLS transport, mutual authentication, application-level auth tokens, secret redaction, and fail-closed error handling.
## Security gates
Before any traffic flows, four gates must pass:
1. **TLS handshake** — both sides negotiate a TLS 1.2+ connection via rustls with ring.
2. **Server certificate verification** — the connector verifies the listener's certificate against the shared CA.
3. **Client certificate verification** — the listener verifies the connector's certificate against the same CA (skipped in insecure mode).
4. **Application auth token** — the connector sends an `X-Rustunnel-Token` header; the listener validates it with constant-time comparison.
The E2E test `auth_required_in_addition_to_mtls` verifies that valid mTLS alone is not sufficient; the correct auth token is also required.
## mTLS
- The listener requires client certificates signed by the shared CA. It uses `WebPkiClientVerifier`.
- The connector presents its client certificate and validates the server certificate against the CA.
- Certificate identities are validated against SANs (Subject Alternative Names) and CN. The server certificate includes SANs for `localhost`, `rustunnel`, and `127.0.0.1`, plus the bind IP if applicable.
## Auth token
- Generated as 32 random bytes encoded as 64 hex characters.
- Compared with a constant-time XOR loop to prevent timing attacks.
- Never logged in raw form. All log output uses the `Redacted` wrapper.
- Auth failures are terminal: the connector's reconnect loop exits on auth failure rather than retrying.
## Secret redaction
- `Redacted` in `src/redact.rs` wraps strings and displays `[REDACTED(len=N)]`.
- `redact_pem()` shows only the PEM block type and line count.
- `redact_config_json()` scrubs sensitive keys from JSON.
- `is_sensitive()` detects PEM private key blocks.
- `is_auth_token()` detects long alphanumeric strings.
- `is_connection_key()` detects strings starting with `rtun1.`.
The E2E test `ops_auth_failure_is_actionable_and_redacted` verifies that error messages do not contain raw token values.
## Insecure mode
`--insecure-skip-tls-verify` disables certificate verification on either side. This is intended for DPI/intercepted environments only. When enabled:
- The listener uses `with_no_client_auth()` instead of `WebPkiClientVerifier`.
- The connector uses a custom `InsecureServerCertVerifier` that accepts any certificate.
- A `warn` level log is emitted.
## Fail-closed design
All error paths reject the connection rather than falling back to an insecure mode. There are no bypasses around TLS, certificate verification, or auth.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/tls.rs` | TLS config, certificate loading, identity validation |
| `src/tunnel.rs` | Auth handshake, constant-time comparison, security gate tests |
| `src/redact.rs` | Secret redaction wrappers and helpers |
| `src/errors.rs` | Error enums for TLS, auth, tunnel |
@@ -0,0 +1,45 @@
# Credential generation
The credential generation module in `src/generate.rs` creates all the certificate and key material needed for a rustunnel session.
## Purpose
Generate a self-signed CA, server certificate, client certificate, and a random auth token. Write them to disk in a specified output directory.
## Key abstractions
| Type/Function | File | Description |
| ------------- | ---- | ----------- |
| `GeneratedMaterial` | `src/generate.rs` | Struct holding all generated PEM strings and the auth token |
| `generate` | `src/generate.rs` | Generate material and write 8 files to an output directory |
| `generate_material` | `src/generate.rs` | Generate material without writing to disk |
| `generate_ca` | `src/generate.rs` | Create a self-signed CA with rcgen |
| `generate_server_cert` | `src/generate.rs` | Create a server cert signed by the CA |
| `generate_client_cert` | `src/generate.rs` | Create a client cert signed by the CA |
| `generate_auth_token` | `src/generate.rs` | Generate a 32-byte random hex token |
## Certificate details
- **CA** — CN from `--ca-name`, org "rustunnel", serial 1, constrained CA basic constraints
- **Server cert** — CN from `--server-name`, org "rustunnel", serial 2, SANs: `localhost`, `rustunnel`, `127.0.0.1`, and optionally the bind IP if not `0.0.0.0` or `::`
- **Client cert** — CN from `--client-name`, org "rustunnel", serial 3
- **Auth token** — 64 hex characters (32 random bytes)
## Output files
| File | Content |
| ---- | ------- |
| `ca.pem` | CA certificate (PEM) |
| `ca.key` | CA private key (PEM) |
| `server.crt` | Server certificate (PEM) |
| `server.key` | Server private key (PEM) |
| `client.crt` | Client certificate (PEM) |
| `client.key` | Client private key (PEM) |
| `token.txt` | Auth token (plain text) |
| `config.json` | Paths and default addresses (JSON) |
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/generate.rs` | Certificate generation, token generation, file writing |
+50
View File
@@ -0,0 +1,50 @@
# Framing protocol
The framing protocol in `src/framing.rs` defines the binary message format used after the HTTP auth handshake upgrades the connection to a persistent tunnel.
## Purpose
Carry multiple bidirectional streams over a single TLS connection with minimal overhead.
## Frame format
All multi-byte integers are big-endian.
```
+--------+----------+----------+-------+-----------+
| Type | StreamID | Len | Flags | Payload.. |
| 1 byte | 8 bytes | 4 bytes | 1 byte| variable |
+--------+----------+----------+-------+-----------+
```
- **Type** — 0x01 CONNECT, 0x02 CONNECT_REPLY, 0x03 DATA, 0x04 CLOSE, 0x05 ERROR
- **StreamID** — odd IDs for client-initiated streams, even reserved for server-initiated
- **Len** — payload length, max 4 MiB
- **Flags** — reserved for future use (currently always 0x00)
## Key abstractions
| Type | File | Description |
| ---- | ---- | ----------- |
| `Frame` | `src/framing.rs` | Struct holding frame_type, stream_id, flags, payload |
| `FrameReader` | `src/framing.rs` | Async reader with buffering that handles partial TCP reads |
| `FrameWriter` | `src/framing.rs` | Async writer that serializes and flushes frames |
| `encode_target` | `src/framing.rs` | Encode a `Socks5Target` into CONNECT frame payload |
| `decode_target` | `src/framing.rs` | Decode a `Socks5Target` from CONNECT frame payload |
## Partial read discipline
`FrameReader::read_frame` loops until a complete frame is available:
1. Attempt to deserialize from the buffer.
2. If the frame is incomplete (`FrameTooShort`), read more bytes from the underlying TCP stream into the buffer.
3. If the connection closes with a partial frame in the buffer, return an error.
4. Only consume bytes from the buffer once the full frame is present.
This prevents header corruption when TCP segments arrive in arbitrary sizes.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/framing.rs` | Frame format, serialization, async reader/writer, target encoding |
+9
View File
@@ -0,0 +1,9 @@
# Systems
rustunnel's internal architecture is divided into five systems:
- [Tunnel engine](tunnel-engine.md) — listener accept loop, connector reconnect loop, stream multiplexing, SOCKS5 proxy integration
- [TLS stack](tls-stack.md) — rustls configuration, certificate loading, identity validation
- [Framing protocol](framing-protocol.md) — binary frame format, serialization, partial read handling
- [SOCKS5 proxy](socks5-proxy.md) — RFC 1928 handshake, CONNECT support, target resolution
- [Credential generation](credential-generation.md) — self-signed CA and certificate generation with rcgen
+37
View File
@@ -0,0 +1,37 @@
# SOCKS5 proxy
The SOCKS5 module in `src/socks5.rs` implements RFC 1928 (SOCKS5 protocol) and RFC 1929 (username/password authentication).
## Purpose
Handle the SOCKS5 handshake on the connector side, parse CONNECT requests, and resolve target addresses so that local applications can forward traffic through the tunnel.
## Supported features
- CONNECT command only (no BIND or UDP)
- No-auth and username/password authentication methods
- IPv4 and domain name targets (no IPv6)
- Target DNS resolution on the connector side
## Key abstractions
| Type | File | Description |
| ---- | ---- | ----------- |
| `Socks5Target` | `src/socks5.rs` | Enum: `Ipv4(addr, port)` or `Domain { domain, port }` |
| `Socks5State` | `src/socks5.rs` | State machine: ExpectGreeting → ExpectAuth → ExpectRequest → RequestReceived → Established → Closed |
| `Socks5Error` | `src/socks5.rs` | Error enum for malformed input, unsupported methods, and parse failures |
| `parse_greeting` | `src/socks5.rs` | Parse client greeting and choose auth method |
| `parse_auth_request` | `src/socks5.rs` | Parse username/password auth request |
| `parse_connect_request` | `src/socks5.rs` | Parse CONNECT request and extract target |
| `resolve_target` | `src/socks5.rs` | Async DNS resolution of a `Socks5Target` to a `SocketAddr` |
## Integration
The connector's `run_socks5_proxy` in `src/tunnel.rs` calls `socks5_handshake` (an async function in `src/tunnel.rs` that uses `src/socks5.rs` constants and helpers) for each incoming SOCKS5 connection. After handshake, it opens a stream via `StreamMux::open_stream` and forwards data bidirectionally.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/socks5.rs` | SOCKS5 protocol constants, parsing, target resolution |
| `src/tunnel.rs` | Async SOCKS5 handshake and proxy integration |
+45
View File
@@ -0,0 +1,45 @@
# TLS stack
The TLS stack in `src/tls.rs` configures rustls for both server (listener) and client (connector) roles with mutual TLS.
## Purpose
Provide safe, fail-closed TLS configuration builders that load certificates and keys, validate certificate identities, and support both secure and insecure (lab-only) modes.
## Key abstractions
| Type/Function | File | Description |
| ------------- | ---- | ----------- |
| `build_server_config` | `src/tls.rs` | Build a rustls `ServerConfig` with client certificate verification |
| `build_client_config` | `src/tls.rs` | Build a rustls `ClientConfig` with server certificate verification |
| `build_server_config_insecure` | `src/tls.rs` | Build a server config that skips client cert verification |
| `build_client_config_insecure` | `src/tls.rs` | Build a client config that skips server cert verification |
| `load_certs` | `src/tls.rs` | Load PEM-encoded certificates from a file |
| `load_key` | `src/tls.rs` | Load a PEM-encoded private key from a file |
| `validate_cert_identity` | `src/tls.rs` | Check SANs and CN against an expected hostname |
| `check_cert_not_expired` | `src/tls.rs` | Verify certificate validity period |
| `cert_fingerprint_sha256` | `src/tls.rs` | Compute SHA-256 fingerprint of a certificate |
| `InsecureServerCertVerifier` | `src/tls.rs` | Dangerous verifier that accepts any certificate |
## How it works
Server config building:
1. Load the server certificate chain and private key.
2. Load the CA certificate into a `RootCertStore`.
3. Build a `WebPkiClientVerifier` from the root store.
4. Use `ServerConfig::builder().with_client_cert_verifier(...).with_single_cert(...)`.
Client config building:
1. Load the client certificate chain and private key.
2. Load the CA certificate into a `RootCertStore`.
3. Use `ClientConfig::builder().with_root_certificates(...).with_client_auth_cert(...)`.
Identity validation uses `x509-parser` to extract Subject Alternative Names and the subject CN, matching against the expected hostname or IP address.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/tls.rs` | TLS configuration, certificate loading, identity validation |
+69
View File
@@ -0,0 +1,69 @@
# Tunnel engine
The tunnel engine in `src/tunnel.rs` is the largest module (~3,500 lines). It implements the listener, connector, SOCKS5 proxy, stream multiplexer, reconnect loop, and HTTP auth handshake.
## Purpose
The tunnel engine establishes a persistent, authenticated, mTLS-encrypted tunnel between a listener and a connector, then multiplexes multiple SOCKS5 streams over that single connection.
## Key abstractions
| Type | File | Description |
| ---- | ---- | ----------- |
| `ListenerConfig` | `src/tunnel.rs` | Bind address, optional SOCKS5 address, TLS material, auth token, insecure flag |
| `ConnectorConfig` | `src/tunnel.rs` | Target host/port, TLS material, auth token, insecure flag |
| `ServerTlsMaterial` | `src/tunnel.rs` | Enum: file paths or embedded PEM strings for server-side TLS |
| `ClientTlsMaterial` | `src/tunnel.rs` | Enum: file paths or embedded PEM strings for client-side TLS |
| `StreamMux` | `src/tunnel.rs` | Connector-side multiplexer: manages stream IDs, data channels, and frame dispatch |
| `ServerStreamEntry` | `src/tunnel.rs` | Listener-side target write half for an active stream |
| `ConnectorStreamEntry` | `src/tunnel.rs` | Connector-side data channel and reply channel for an active stream |
## How it works
### Listener side
1. `run_listener` binds a TCP socket, builds the TLS acceptor from `ServerTlsMaterial`, and starts accepting connections.
2. `handle_listener_connection` performs the TLS accept, verifies the client certificate (unless insecure), reads the HTTP POST to `/tunnel`, and validates the `X-Rustunnel-Token` header with `constant_time_compare`.
3. On auth success, it calls `handle_framing_server` to switch to binary framing.
4. `handle_framing_server` reads frames from the tunnel. A CONNECT frame triggers a TCP connection to the target address (opened from the listener side). DATA frames are forwarded to the target. CLOSE frames shut down the target connection.
5. If the listener was started with `--socks`, a server-side SOCKS5 proxy runs via `run_socks5_proxy`, receiving the active `StreamMux` through a `watch::channel`.
### Connector side
1. `run_connector_with_socks` builds the client TLS config and starts two concurrent pieces: a reconnect loop and a permanently-bound SOCKS5 proxy.
2. `run_reconnect_loop` repeatedly attempts to connect to the listener with exponential backoff (1s to 30s). Each attempt re-runs all security gates: TLS handshake, server cert verification, and auth token exchange.
3. On success, the loop creates a `StreamMux`, publishes it via the `watch::channel`, and waits for the framing task to finish (indicating disconnection).
4. When the tunnel drops, the loop logs the disconnection and retries.
5. `run_socks5_proxy` accepts SOCKS5 connections. For each connection, it checks if the `StreamMux` is available, opens a stream via `mux.open_stream()`, and performs bidirectional forwarding.
### Stream multiplexing
`StreamMux::open_stream` allocates the next odd stream ID, registers a data channel and reply channel, sends a CONNECT frame, and waits for a CONNECT_REPLY. The caller receives a `mpsc::Receiver<Bytes>` for tunnel-to-SOCKS5 data.
`StreamMux::dispatch_frame` routes inbound CONNECT_REPLY, DATA, CLOSE, and ERROR frames to the correct stream by ID.
```mermaid
graph TD
A[SOCKS5 client] -->|CONNECT| B[SOCKS5 proxy]
B -->|open_stream| C[StreamMux]
C -->|CONNECT frame| D[Framing writer]
D -->|TLS| E[Listener framing]
E -->|decode target| F[TCP connect to target]
F -->|DATA| E
E -->|DATA frame| D
D -->|dispatch| C
C -->|data_rx| B
B -->|raw bytes| A
```
## Entry points for modification
- To change auth behavior: modify `handle_listener_connection` (listener) and `authenticate_tunnel` (connector) in `src/tunnel.rs`.
- To change reconnect strategy: modify `run_reconnect_loop` in `src/tunnel.rs`.
- To add listener-side SOCKS5 behavior: the server-side SOCKS5 proxy is currently minimal; expand `run_socks5_proxy` in `src/tunnel.rs`.
## Key source files
| File | Purpose |
| ---- | ------- |
| `src/tunnel.rs` | Full tunnel engine: listener, connector, SOCKS5 proxy, reconnect, framing loops, stream mux |
+61 -17
View File
@@ -17,19 +17,31 @@ pub enum Commands {
#[command(trailing_var_arg = true)]
Listen {
/// Address to bind the HTTPS listener
#[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")]
#[arg(long, default_value = "0.0.0.0:4180", value_name = "ADDR:PORT")]
listen: String,
/// Public listener address embedded in a generated connection key
#[arg(long, value_name = "ADDR:PORT")]
advertise: Option<String>,
/// Reusable connection key generated by keygen/listen
#[arg(long, env = "RUSTUNNEL_KEY", value_name = "KEY")]
connection_key: Option<String>,
/// Address to bind the server-side SOCKS5 proxy for access to the connector/client network
#[arg(long, value_name = "ADDR:PORT")]
socks: Option<String>,
/// Path to the server certificate file (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
cert: String,
/// Path to the server private key file (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
key: String,
/// Path to the CA certificate file used to validate client certificates (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
ca_cert: String,
/// Auth/session token required from connecting clients
@@ -39,36 +51,44 @@ pub enum Commands {
/// Path to a file containing the auth/session token (alternative to --auth-token)
#[arg(long, value_name = "PATH")]
auth_token_file: Option<String>,
/// Skip TLS certificate verification (insecure — for DPI/intercepted environments only)
#[arg(long, default_value = "false")]
insecure_skip_tls_verify: bool,
},
/// Connect to the HTTPS tunnel listener
///
/// Establishes an mTLS connection to the listener and authenticates
/// via the /tunnel HTTPS endpoint.
///
/// NOTE: SOCKS5 proxy is scheduled for the SOCKS milestone.
/// The --socks address is accepted for configuration continuity
/// but is not yet functional.
#[command(trailing_var_arg = true)]
/// via the /tunnel HTTPS endpoint. Use `listen --socks` on the server
/// to expose SOCKS5 access to this connector's network.
Connect {
/// Address of the HTTPS tunnel listener to connect to
#[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")]
target: String,
/// Connection key generated by `rustunnel keygen` or `rustunnel listen`
#[arg(value_name = "CONNECTION_KEY")]
connection_key_arg: Option<String>,
/// Address to bind the local SOCKS5 proxy listener (not yet implemented — scheduled for SOCKS milestone)
/// Address of the HTTPS tunnel listener to connect to
#[arg(long, value_name = "ADDR:PORT")]
target: Option<String>,
/// Connection key generated by `rustunnel keygen` or `rustunnel listen`
#[arg(long, env = "RUSTUNNEL_KEY", value_name = "KEY")]
connection_key: Option<String>,
/// Address to bind the local SOCKS5 proxy listener for server-side egress
#[arg(long, default_value = "127.0.0.1:1180", value_name = "ADDR:PORT")]
socks: String,
/// Path to the client certificate file (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
cert: String,
/// Path to the client private key file (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
key: String,
/// Path to the CA certificate file used to validate the server certificate (PEM)
#[arg(long, value_name = "PATH")]
#[arg(long, default_value = "", value_name = "PATH")]
ca_cert: String,
/// Auth/session token to authenticate with the listener
@@ -78,6 +98,10 @@ pub enum Commands {
/// Path to a file containing the auth/session token (alternative to --auth-token)
#[arg(long, value_name = "PATH")]
auth_token_file: Option<String>,
/// Skip TLS certificate verification (insecure — for DPI/intercepted environments only)
#[arg(long, default_value = "false")]
insecure_skip_tls_verify: bool,
},
/// Generate local certificate and auth material
@@ -103,6 +127,26 @@ pub enum Commands {
client_name: String,
},
/// Generate a reusable copy/paste connection key
#[command(trailing_var_arg = true)]
Keygen {
/// Listener address that connectors should dial
#[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")]
target: String,
/// Common name for the CA certificate
#[arg(long, default_value = "rustunnel-ca", value_name = "CN")]
ca_name: String,
/// Common name for the server certificate
#[arg(long, default_value = "rustunnel-server", value_name = "CN")]
server_name: String,
/// Common name for the client certificate
#[arg(long, default_value = "rustunnel-client", value_name = "CN")]
client_name: String,
},
/// Print version information
#[command(trailing_var_arg = true)]
Version,
+2 -2
View File
@@ -39,7 +39,7 @@ mod tests {
fn default_config() -> Config {
Config {
listen_address: "127.0.0.1".into(),
listen_address: "0.0.0.0".into(),
listen_port: 4180,
socks_address: "127.0.0.1".into(),
socks_port: 1180,
@@ -72,7 +72,7 @@ mod tests {
// Verify it's valid JSON by deserializing
let _: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(json.contains("\"listen_address\""));
assert!(json.contains("127.0.0.1"));
assert!(json.contains("0.0.0.0"));
}
#[test]
+93
View File
@@ -0,0 +1,93 @@
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use crate::generate::GeneratedMaterial;
const PREFIX: &str = "rtun1.";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectionKey {
pub version: u8,
pub target: String,
pub ca_cert_pem: String,
pub server_cert_pem: String,
pub server_key_pem: String,
pub client_cert_pem: String,
pub client_key_pem: String,
pub auth_token: String,
}
impl ConnectionKey {
pub fn new(target: String, material: GeneratedMaterial) -> Self {
Self {
version: 1,
target,
ca_cert_pem: material.ca_cert_pem,
server_cert_pem: material.server_cert_pem,
server_key_pem: material.server_key_pem,
client_cert_pem: material.client_cert_pem,
client_key_pem: material.client_key_pem,
auth_token: material.auth_token,
}
}
pub fn encode(&self) -> anyhow::Result<String> {
let json = serde_json::to_vec(self)?;
Ok(format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(json)))
}
pub fn decode(input: &str) -> anyhow::Result<Self> {
let encoded = input
.trim()
.strip_prefix(PREFIX)
.ok_or_else(|| anyhow::anyhow!("connection key must start with {}", PREFIX))?;
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| anyhow::anyhow!("invalid connection key encoding: {}", e))?;
let key: Self = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("invalid connection key payload: {}", e))?;
if key.version != 1 {
anyhow::bail!("unsupported connection key version {}", key.version);
}
if key.target.trim().is_empty()
|| key.ca_cert_pem.trim().is_empty()
|| key.server_cert_pem.trim().is_empty()
|| key.server_key_pem.trim().is_empty()
|| key.client_cert_pem.trim().is_empty()
|| key.client_key_pem.trim().is_empty()
|| key.auth_token.trim().is_empty()
{
anyhow::bail!("connection key is missing required material");
}
Ok(key)
}
}
#[allow(dead_code)]
pub fn looks_like_connection_key(input: &str) -> bool {
input.trim_start().starts_with(PREFIX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_key_roundtrip() {
let material =
crate::generate::generate_material("ca", "server", "client").expect("material");
let key = ConnectionKey::new("127.0.0.1:4180".to_string(), material);
let encoded = key.encode().unwrap();
assert!(encoded.starts_with("rtun1."));
let decoded = ConnectionKey::decode(&encoded).unwrap();
assert_eq!(decoded.version, 1);
assert_eq!(decoded.target, "127.0.0.1:4180");
assert_eq!(decoded.auth_token, key.auth_token);
}
#[test]
fn invalid_prefix_fails() {
assert!(ConnectionKey::decode("bad").is_err());
}
}
+3 -56
View File
@@ -17,7 +17,6 @@ pub enum TlsError {
IdentityMismatch(String),
#[error("certificate expired: {0}")]
#[allow(dead_code)]
Expired(String),
#[error("certificate verification failed: {0}")]
@@ -25,10 +24,6 @@ pub enum TlsError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("rustls error: {0}")]
#[allow(dead_code)]
Rustls(String),
}
/// Errors that occur during application-level authentication.
@@ -40,13 +35,8 @@ pub enum AuthError {
#[error("auth token invalid")]
Invalid,
#[error("auth token expired")]
#[allow(dead_code)]
Expired,
#[error("too many auth attempts")]
#[allow(dead_code)]
TooManyAttempts,
#[error("auth not yet completed")]
NotAuthenticated,
}
/// Errors that occur during tunnel lifecycle operations.
@@ -62,65 +52,22 @@ pub enum TunnelError {
BindError(String, String),
#[error("connection refused: {0}")]
ConnectionRefused(String),
#[error("connection closed")]
#[allow(dead_code)]
ConnectionClosed,
ConnectionRefused(String),
#[error("port {0} already in use")]
PortInUse(u16),
#[error("shutdown requested")]
#[allow(dead_code)]
Shutdown,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("protocol error: {0}")]
Protocol(String),
#[error("timeout: {0}")]
#[allow(dead_code)]
Timeout(String),
#[error("hostname error: {0}")]
Host(#[from] HostError),
}
/// Errors returned by the connector side.
#[allow(dead_code)]
#[derive(Debug, Error)]
pub enum ConnectorError {
#[error("TLS handshake failed: {0}")]
TlsHandshake(String),
#[error("server certificate verification failed: {0}")]
ServerCertVerification(String),
#[error("server certificate identity mismatch for {0}: {1}")]
ServerIdentityMismatch(String, String),
#[error("server certificate expired")]
ServerCertExpired,
#[error("auth rejected: {0}")]
AuthRejected(String),
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("protocol error: {0}")]
Protocol(String),
#[error("timeout: {0}")]
Timeout(String),
}
/// Errors returned during hostname resolution.
#[derive(Debug, Error)]
pub enum HostError {
+776
View File
@@ -0,0 +1,776 @@
/// Binary framing protocol for the authenticated tunnel.
///
/// After the mTLS + auth handshake completes, both sides switch from HTTP/1.1
/// to a lightweight binary framing protocol that carries multiple bidirectional
/// streams over a single persistent TCP connection.
///
/// Frame format:
/// +--------+----------+----------+-------+-----------+
/// | Type | StreamID | Len | Flags | Payload.. |
/// | 1 byte | 8 bytes | 4 bytes | 1 byte| variable |
/// +--------+----------+----------+-------+-----------+
///
/// All multi-byte integers are big-endian.
///
/// Frame types:
/// 0x01 CONNECT - Open a new stream to a target (payload: target addr)
/// 0x02 CONNECT_REPLY - Accept/reject a CONNECT (payload: 1-byte status)
/// 0x03 DATA - Stream data (payload: raw bytes)
/// 0x04 CLOSE - Half-close a stream (no payload)
/// 0x05 ERROR - Stream error (payload: error message)
///
/// Stream IDs:
/// - Client-initiated streams: odd IDs (1, 3, 5, ...)
/// - Even IDs reserved for future server-initiated streams
///
/// Flags:
/// - Bit 0 (0x01): EOF — sender will send no more data on this stream
use bytes::{Buf, BufMut, Bytes, BytesMut};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors that can occur during frame operations.
#[derive(Debug, Error)]
pub enum FrameError {
#[error("frame too short: expected {0} bytes, got {1}")]
FrameTooShort(usize, usize),
#[error("payload length overflow: {0}")]
PayloadOverflow(u32),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("protocol error: {0}")]
Protocol(String),
}
// ---------------------------------------------------------------------------
// Frame types
// ---------------------------------------------------------------------------
pub const FRAME_CONNECT: u8 = 0x01;
pub const FRAME_CONNECT_REPLY: u8 = 0x02;
pub const FRAME_DATA: u8 = 0x03;
pub const FRAME_CLOSE: u8 = 0x04;
pub const FRAME_ERROR: u8 = 0x05;
// Flags byte: reserved for future extension (currently always 0x00).
// The former FLAG_EOF bit (0x01) was removed in favor of the CLOSE frame
// (FRAME_CLOSE) for half-close semantics. This keeps the frame format clean
// and explicit: CLOSE frames signal sender-side EOF per stream.
// CONNECT_REPLY status
pub const CONNECT_OK: u8 = 0x00;
pub const CONNECT_FAILED: u8 = 0x01;
// Frame header size (type + stream_id + length + flags)
pub const FRAME_HEADER_LEN: usize = 14;
const MAX_PAYLOAD_LEN: u32 = 4 * 1024 * 1024; // 4 MiB per frame
// ---------------------------------------------------------------------------
// Frame struct
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Frame {
pub frame_type: u8,
pub stream_id: u64,
pub flags: u8,
pub payload: Bytes,
}
impl Frame {
/// Create a CONNECT frame.
pub fn connect(stream_id: u64, target_bytes: impl AsRef<[u8]>) -> Self {
Frame {
frame_type: FRAME_CONNECT,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(target_bytes.as_ref()),
}
}
/// Create a CONNECT_REPLY frame.
pub fn connect_reply(stream_id: u64, status: u8) -> Self {
Frame {
frame_type: FRAME_CONNECT_REPLY,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(&[status]),
}
}
/// Create a DATA frame.
pub fn data(stream_id: u64, data: impl AsRef<[u8]>) -> Self {
Frame {
frame_type: FRAME_DATA,
stream_id,
flags: 0,
payload: Bytes::copy_from_slice(data.as_ref()),
}
}
/// Create a CLOSE frame.
pub fn close(stream_id: u64) -> Self {
Frame {
frame_type: FRAME_CLOSE,
stream_id,
flags: 0,
payload: Bytes::new(),
}
}
/// Create an ERROR frame.
pub fn error(stream_id: u64, message: impl Into<String>) -> Self {
Frame {
frame_type: FRAME_ERROR,
stream_id,
flags: 0,
payload: Bytes::from(message.into()),
}
}
/// Serialize this frame to bytes.
pub fn serialize(&self) -> Bytes {
let payload_len = self.payload.len() as u32;
let total = FRAME_HEADER_LEN + payload_len as usize;
let mut buf = BytesMut::with_capacity(total);
buf.put_u8(self.frame_type);
buf.put_u64(self.stream_id);
buf.put_u32(payload_len);
buf.put_u8(self.flags);
buf.put_slice(&self.payload);
buf.freeze()
}
/// Deserialize a frame from a buffer.
/// Returns (frame, payload_len) if successful.
///
/// CRITICAL: If the frame is incomplete (partial header or partial payload),
/// NO bytes are consumed from the buffer. The caller retains all buffered
/// bytes and can retry after more data arrives.
pub fn deserialize(buf: &mut BytesMut) -> Result<(Self, usize), FrameError> {
// Need at least the header — do NOT consume yet
if buf.len() < FRAME_HEADER_LEN {
return Err(FrameError::FrameTooShort(FRAME_HEADER_LEN, buf.len()));
}
// Read header fields without consuming (use slicing instead of get_u*)
let frame_type = buf[0];
let stream_id = u64::from_be_bytes(buf[1..9].try_into().unwrap());
let payload_len = u32::from_be_bytes(buf[9..13].try_into().unwrap());
let flags = buf[13];
if payload_len > MAX_PAYLOAD_LEN {
return Err(FrameError::PayloadOverflow(payload_len));
}
// Need the full payload — do NOT consume yet
let total_len = FRAME_HEADER_LEN + payload_len as usize;
if buf.len() < total_len {
return Err(FrameError::FrameTooShort(total_len, buf.len()));
}
// Only now, consume the entire frame at once
let raw = buf.split_to(total_len);
Ok((
Frame {
frame_type,
stream_id,
flags,
payload: raw[FRAME_HEADER_LEN..].to_vec().into(),
},
payload_len as usize,
))
}
/// Check if this frame has any flags set (reserved for future use).
#[allow(dead_code)]
#[must_use]
pub fn has_flags(&self) -> bool {
self.flags != 0
}
}
// ---------------------------------------------------------------------------
// Frame reader/writer
// ---------------------------------------------------------------------------
/// Asynchronous frame reader that handles partial reads and buffering.
pub struct FrameReader {
buf: BytesMut,
}
impl FrameReader {
pub fn new() -> Self {
FrameReader {
buf: BytesMut::with_capacity(8192),
}
}
/// Read the next complete frame from the underlying reader.
/// Returns None if the connection is closed gracefully (EOF).
pub async fn read_frame<R: tokio::io::AsyncRead + Unpin>(
&mut self,
reader: &mut R,
) -> Result<Option<Frame>, FrameError> {
loop {
// Try to parse a frame from the buffer
let buf_ref = &mut self.buf;
match Frame::deserialize(buf_ref) {
Ok((frame, _)) => return Ok(Some(frame)),
Err(FrameError::FrameTooShort(_, _)) => {
// Need more data — read from the underlying reader
let capacity = self.buf.capacity();
let len = self.buf.len();
// Ensure we have space
if len == capacity {
self.buf.reserve(8192);
}
let _remaining = self.buf.spare_capacity_mut();
match reader.read_buf(&mut self.buf).await {
Ok(0) => {
// Connection closed — if we have partial data, it's an error
if !self.buf.is_empty() {
return Err(FrameError::Protocol(
"connection closed with partial frame".to_string(),
));
}
return Ok(None);
}
Ok(_) => continue,
Err(e) => return Err(FrameError::Io(e)),
}
}
Err(e) => return Err(e),
}
}
}
}
/// Asynchronous frame writer.
pub struct FrameWriter;
impl FrameWriter {
/// Write a frame to the underlying writer.
pub async fn write_frame<W: tokio::io::AsyncWrite + Unpin>(
writer: &mut W,
frame: &Frame,
) -> Result<(), FrameError> {
let bytes = frame.serialize();
writer.write_all(&bytes).await?;
writer.flush().await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Target address encoding for CONNECT frames
// ---------------------------------------------------------------------------
/// Encode a target address into CONNECT frame payload bytes.
/// Format: ATYP (1) + addr (variable) + port (2)
pub fn encode_target(target: &crate::socks5::Socks5Target) -> Vec<u8> {
match target {
crate::socks5::Socks5Target::Ipv4(addr, port) => {
let mut v = vec![crate::socks5::ATYP_IPV4];
v.extend_from_slice(&addr.octets());
v.extend_from_slice(&port.to_be_bytes());
v
}
crate::socks5::Socks5Target::Domain { domain, port } => {
let mut v = vec![crate::socks5::ATYP_DOMAIN, domain.len() as u8];
v.extend_from_slice(domain.as_bytes());
v.extend_from_slice(&port.to_be_bytes());
v
}
}
}
/// Decode a target address from CONNECT frame payload bytes.
pub fn decode_target(payload: &[u8]) -> Result<crate::socks5::Socks5Target, FrameError> {
if payload.is_empty() {
return Err(FrameError::Protocol("empty target".to_string()));
}
let atyp = payload[0];
let mut cursor = &payload[1..];
match atyp {
crate::socks5::ATYP_IPV4 => {
if cursor.len() < 6 {
return Err(FrameError::Protocol("truncated IPv4 target".to_string()));
}
let addr = std::net::Ipv4Addr::new(cursor[0], cursor[1], cursor[2], cursor[3]);
let port = u16::from_be_bytes([cursor[4], cursor[5]]);
Ok(crate::socks5::Socks5Target::Ipv4(addr, port))
}
crate::socks5::ATYP_DOMAIN => {
if cursor.is_empty() {
return Err(FrameError::Protocol("truncated domain target".to_string()));
}
let domain_len = cursor[0] as usize;
cursor.advance(1);
if cursor.len() < domain_len + 2 {
return Err(FrameError::Protocol("truncated domain target".to_string()));
}
let domain = String::from_utf8(cursor[..domain_len].to_vec())
.map_err(|_| FrameError::Protocol("invalid UTF-8 in domain".to_string()))?;
cursor.advance(domain_len);
let port = u16::from_be_bytes([cursor[0], cursor[1]]);
Ok(crate::socks5::Socks5Target::Domain { domain, port })
}
_ => Err(FrameError::Protocol(format!(
"unsupported address type: {:#04x}",
atyp
))),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn frame_roundtrip_connect() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(127, 0, 0, 1), 4181);
let payload = encode_target(&target);
let frame = Frame::connect(1, payload.clone());
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CONNECT);
assert_eq!(decoded.stream_id, 1);
assert_eq!(decoded.payload, payload);
let decoded_target = decode_target(&decoded.payload).unwrap();
assert_eq!(decoded_target, target);
}
#[test]
fn frame_roundtrip_data() {
let data = b"hello world";
let frame = Frame::data(3, data);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_DATA);
assert_eq!(decoded.stream_id, 3);
assert_eq!(&decoded.payload[..], data);
}
#[test]
fn frame_roundtrip_close_replaces_eof() {
// Half-close semantics are handled by CLOSE frames, not DATA+EOF
let frame = Frame::close(5);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CLOSE);
assert_eq!(decoded.stream_id, 5);
assert!(decoded.payload.is_empty());
}
#[test]
fn frame_roundtrip_close() {
let frame = Frame::close(7);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CLOSE);
assert_eq!(decoded.stream_id, 7);
assert!(decoded.payload.is_empty());
}
#[test]
fn frame_roundtrip_error() {
let frame = Frame::error(9, "connection refused");
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_ERROR);
assert_eq!(
String::from_utf8_lossy(&decoded.payload),
"connection refused"
);
}
#[test]
fn frame_roundtrip_connect_reply() {
let frame = Frame::connect_reply(1, CONNECT_OK);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CONNECT_REPLY);
assert_eq!(decoded.payload[0], CONNECT_OK);
}
#[test]
fn frame_partial_read_returns_error() {
// Header is 14 bytes; with only 10 bytes, deserialization should fail
let mut buf = BytesMut::from(&[0u8; 10][..]);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
// CRITICAL: Buffer must NOT be consumed on failure — bytes must remain
assert_eq!(
buf.len(),
10,
"partial read must not consume bytes from buffer"
);
}
#[test]
fn frame_partial_payload_preserves_buffer() {
// Frame with 11-byte payload, feed only header + 5 payload bytes
let frame = Frame::data(1, b"01234567890");
let serialized = frame.serialize();
// Header (14) + partial payload (5) = 19 bytes
let partial = &serialized[..19];
let mut buf = BytesMut::from(partial);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
// Buffer must still contain all 19 bytes — header must NOT be consumed
assert_eq!(
buf.len(),
19,
"partial payload must not consume header bytes"
);
}
#[test]
fn frame_partial_then_complete() {
// Simulate TCP segmentation: header arrives, then partial payload, then rest
let frame = Frame::data(3, b"hello world");
let serialized = frame.serialize();
let _total_len = serialized.len(); // 14 + 11 = 25
// Step 1: Feed only the header (14 bytes)
let mut buf = BytesMut::from(&serialized[..FRAME_HEADER_LEN]);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
assert_eq!(
buf.len(),
FRAME_HEADER_LEN,
"header must not be consumed yet"
);
// Step 2: Append partial payload (5 bytes)
buf.extend_from_slice(&serialized[FRAME_HEADER_LEN..FRAME_HEADER_LEN + 5]);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
assert_eq!(
buf.len(),
FRAME_HEADER_LEN + 5,
"partial payload must not be consumed yet"
);
// Step 3: Append remaining payload (6 bytes)
buf.extend_from_slice(&serialized[FRAME_HEADER_LEN + 5..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_DATA);
assert_eq!(decoded.stream_id, 3);
assert_eq!(&decoded.payload[..], b"hello world");
assert!(buf.is_empty());
}
#[test]
fn frame_zero_payload_header_only() {
// CLOSE frame has 0-byte payload; header alone should succeed
let frame = Frame::close(7);
let serialized = frame.serialize();
let mut buf = BytesMut::from(&serialized[..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_CLOSE);
assert_eq!(decoded.stream_id, 7);
assert!(decoded.payload.is_empty());
}
#[test]
fn frame_partial_header_then_complete() {
// Feed header in chunks, then payload
let frame = Frame::data(5, b"test");
let serialized = frame.serialize();
// Step 1: Feed first 7 bytes of header
let mut buf = BytesMut::from(&serialized[..7]);
let result = Frame::deserialize(&mut buf);
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
assert_eq!(buf.len(), 7, "partial header must not be consumed");
// Step 2: Feed rest of header
buf.extend_from_slice(&serialized[7..FRAME_HEADER_LEN]);
let result = Frame::deserialize(&mut buf);
// Still not enough — header is complete but payload may be missing
// In this case, header is complete and payload is 4 bytes, but none arrived yet
assert!(matches!(result, Err(FrameError::FrameTooShort(_, _))));
assert_eq!(
buf.len(),
FRAME_HEADER_LEN,
"full header must not be consumed yet"
);
// Step 3: Feed payload
buf.extend_from_slice(&serialized[FRAME_HEADER_LEN..]);
let (decoded, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(decoded.frame_type, FRAME_DATA);
assert_eq!(decoded.stream_id, 5);
assert_eq!(&decoded.payload[..], b"test");
}
#[test]
fn frame_multiple_frames_in_buffer() {
let frame1 = Frame::data(1, b"first");
let frame2 = Frame::data(3, b"second");
let mut buf = BytesMut::new();
buf.extend_from_slice(&frame1.serialize());
buf.extend_from_slice(&frame2.serialize());
let (d1, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(d1.stream_id, 1);
assert_eq!(&d1.payload[..], b"first");
let (d2, _) = Frame::deserialize(&mut buf).unwrap();
assert_eq!(d2.stream_id, 3);
assert_eq!(&d2.payload[..], b"second");
assert!(buf.is_empty());
}
#[test]
fn encode_decode_target_ipv4() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(10, 0, 0, 1), 8080);
let encoded = encode_target(&target);
let decoded = decode_target(&encoded).unwrap();
assert_eq!(decoded, target);
}
#[test]
fn encode_decode_target_domain() {
let target = crate::socks5::Socks5Target::Domain {
domain: "example.com".to_string(),
port: 443,
};
let encoded = encode_target(&target);
let decoded = decode_target(&encoded).unwrap();
assert_eq!(decoded, target);
}
#[test]
fn decode_target_empty_fails() {
assert!(decode_target(&[]).is_err());
}
#[test]
fn decode_target_truncated_fails() {
assert!(decode_target(&[crate::socks5::ATYP_IPV4, 127]).is_err());
}
#[test]
fn decode_target_unsupported_atyp_fails() {
assert!(decode_target(&[0x04, 0, 0, 0, 0, 0, 0]).is_err());
}
#[tokio::test]
async fn frame_reader_writes_all_then_reads() {
let target = crate::socks5::Socks5Target::Ipv4(std::net::Ipv4Addr::new(127, 0, 0, 1), 8080);
let frame = Frame::connect(1, encode_target(&target));
let serialized = frame.serialize();
let mut reader = FrameReader::new();
let mut cursor = Cursor::new(&serialized[..]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_CONNECT);
assert_eq!(result.stream_id, 1);
let decoded_target = decode_target(&result.payload).unwrap();
assert_eq!(decoded_target, target);
}
#[tokio::test]
async fn frame_reader_eof_returns_none() {
let mut reader = FrameReader::new();
let mut cursor = Cursor::new(&[] as &[u8]);
let result = reader.read_frame(&mut cursor).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn frame_writer_roundtrip() {
let frame = Frame::data(5, b"test data here");
let serialized = frame.serialize();
let mut cursor = Vec::new();
FrameWriter::write_frame(&mut cursor, &frame).await.unwrap();
assert_eq!(cursor, serialized.as_ref());
}
#[test]
fn frame_header_size() {
assert_eq!(FRAME_HEADER_LEN, 14);
}
/// Custom async reader that returns data chunk by chunk.
struct ChunkedReader {
chunks: Vec<Vec<u8>>,
chunk_idx: usize,
pos_in_chunk: usize,
}
impl ChunkedReader {
fn new(chunks: Vec<Vec<u8>>) -> Self {
ChunkedReader {
chunks,
chunk_idx: 0,
pos_in_chunk: 0,
}
}
}
impl tokio::io::AsyncRead for ChunkedReader {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.get_mut();
if this.chunk_idx >= this.chunks.len() {
return std::task::Poll::Ready(Ok(()));
}
let chunk = &this.chunks[this.chunk_idx];
let available = &chunk[this.pos_in_chunk..];
let to_copy = available.len().min(buf.remaining());
buf.put_slice(&available[..to_copy]);
this.pos_in_chunk += to_copy;
if this.pos_in_chunk >= chunk.len() {
this.chunk_idx += 1;
this.pos_in_chunk = 0;
}
std::task::Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn frame_reader_header_then_payload_chunks() {
// FrameReader receives header chunk, then payload chunk separately
let frame = Frame::data(2, b"chunked data");
let serialized = frame.serialize();
let chunk1 = serialized[..FRAME_HEADER_LEN].to_vec();
let chunk2 = serialized[FRAME_HEADER_LEN..].to_vec();
let mut reader = FrameReader::new();
let mut cursor = ChunkedReader::new(vec![chunk1, chunk2]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_DATA);
assert_eq!(result.stream_id, 2);
assert_eq!(&result.payload[..], b"chunked data");
}
#[tokio::test]
async fn frame_reader_three_chunk_split() {
// Header only, partial payload, remaining payload
let frame = Frame::data(4, b"hello world");
let serialized = frame.serialize();
let chunk1 = serialized[..FRAME_HEADER_LEN].to_vec();
let chunk2 = serialized[FRAME_HEADER_LEN..FRAME_HEADER_LEN + 5].to_vec();
let chunk3 = serialized[FRAME_HEADER_LEN + 5..].to_vec();
let mut reader = FrameReader::new();
let mut cursor = ChunkedReader::new(vec![chunk1, chunk2, chunk3]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_DATA);
assert_eq!(result.stream_id, 4);
assert_eq!(&result.payload[..], b"hello world");
}
#[tokio::test]
async fn frame_reader_header_split_across_chunks() {
// Header itself is split across multiple reads
let frame = Frame::close(10);
let serialized = frame.serialize();
let chunk1 = serialized[..5].to_vec();
let chunk2 = serialized[5..FRAME_HEADER_LEN].to_vec();
let mut reader = FrameReader::new();
let mut cursor = ChunkedReader::new(vec![chunk1, chunk2]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_CLOSE);
assert_eq!(result.stream_id, 10);
assert!(result.payload.is_empty());
}
#[tokio::test]
async fn frame_reader_multiple_frames_chunked() {
// Two frames, each split into chunks
let f1 = Frame::data(1, b"A");
let f2 = Frame::data(3, b"B");
let mut all = Vec::new();
all.extend_from_slice(&f1.serialize());
all.extend_from_slice(&f2.serialize());
// Split into tiny 7-byte chunks
let mut chunks = Vec::new();
let mut i = 0;
while i < all.len() {
let end = std::cmp::min(i + 7, all.len());
chunks.push(all[i..end].to_vec());
i = end;
}
let mut reader = FrameReader::new();
let mut cursor = ChunkedReader::new(chunks);
let r1 = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(r1.stream_id, 1);
assert_eq!(&r1.payload[..], b"A");
let r2 = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(r2.stream_id, 3);
assert_eq!(&r2.payload[..], b"B");
}
#[tokio::test]
async fn frame_reader_zero_payload_chunked() {
// CLOSE frame (zero payload) delivered as header-only in two chunks
let frame = Frame::close(6);
let serialized = frame.serialize();
let chunk1 = serialized[..7].to_vec();
let chunk2 = serialized[7..].to_vec();
let mut reader = FrameReader::new();
let mut cursor = ChunkedReader::new(vec![chunk1, chunk2]);
let result = reader.read_frame(&mut cursor).await.unwrap().unwrap();
assert_eq!(result.frame_type, FRAME_CLOSE);
assert_eq!(result.stream_id, 6);
}
}
+67 -23
View File
@@ -9,6 +9,17 @@ use rcgen::{
use crate::config::Config;
use crate::redact::Redacted;
#[derive(Debug, Clone)]
pub struct GeneratedMaterial {
pub ca_cert_pem: String,
pub ca_key_pem: String,
pub server_cert_pem: String,
pub server_key_pem: String,
pub client_cert_pem: String,
pub client_key_pem: String,
pub auth_token: String,
}
/// Generate all credential material and write it to the output directory.
///
/// Creates:
@@ -29,32 +40,29 @@ pub fn generate(
std::fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create output directory: {}", out_dir.display()))?;
// Generate CA
let ca = generate_ca(ca_name)?;
let material = generate_material(ca_name, server_name, client_name)?;
let ca_cert_path = out_dir.join("ca.pem");
let ca_key_path = out_dir.join("ca.key");
std::fs::write(&ca_cert_path, ca.as_ref().pem()).with_context(|| {
std::fs::write(&ca_cert_path, &material.ca_cert_pem).with_context(|| {
format!(
"failed to write CA certificate to {}",
ca_cert_path.display()
)
})?;
std::fs::write(&ca_key_path, ca.key().serialize_pem())
std::fs::write(&ca_key_path, &material.ca_key_pem)
.with_context(|| format!("failed to write CA key to {}", ca_key_path.display()))?;
tracing::info!("Generated CA certificate at {}", ca_cert_path.display());
tracing::info!("Generated CA key at {}", ca_key_path.display());
// Generate server certificate
let (server_cert, server_key) = generate_server_cert(&ca, server_name)?;
let server_cert_path = out_dir.join("server.crt");
let server_key_path = out_dir.join("server.key");
std::fs::write(&server_cert_path, server_cert.pem()).with_context(|| {
std::fs::write(&server_cert_path, &material.server_cert_pem).with_context(|| {
format!(
"failed to write server cert to {}",
server_cert_path.display()
)
})?;
std::fs::write(&server_key_path, server_key.serialize_pem()).with_context(|| {
std::fs::write(&server_key_path, &material.server_key_pem).with_context(|| {
format!(
"failed to write server key to {}",
server_key_path.display()
@@ -66,17 +74,15 @@ pub fn generate(
);
tracing::info!("Generated server key at {}", server_key_path.display());
// Generate client certificate
let (client_cert, client_key) = generate_client_cert(&ca, client_name)?;
let client_cert_path = out_dir.join("client.crt");
let client_key_path = out_dir.join("client.key");
std::fs::write(&client_cert_path, client_cert.pem()).with_context(|| {
std::fs::write(&client_cert_path, &material.client_cert_pem).with_context(|| {
format!(
"failed to write client cert to {}",
client_cert_path.display()
)
})?;
std::fs::write(&client_key_path, client_key.serialize_pem()).with_context(|| {
std::fs::write(&client_key_path, &material.client_key_pem).with_context(|| {
format!(
"failed to write client key to {}",
client_key_path.display()
@@ -88,11 +94,9 @@ pub fn generate(
);
tracing::info!("Generated client key at {}", client_key_path.display());
// Generate auth token
let auth_token = generate_auth_token();
let auth_token_redacted = Redacted::new(auth_token.clone());
let auth_token_redacted = Redacted::new(material.auth_token.clone());
let token_path = out_dir.join("token.txt");
std::fs::write(&token_path, auth_token)
std::fs::write(&token_path, &material.auth_token)
.with_context(|| format!("failed to write token to {}", token_path.display()))?;
tracing::info!(
"Generated auth token at {} (value: {})",
@@ -102,7 +106,7 @@ pub fn generate(
// Generate config
let config = Config {
listen_address: "127.0.0.1".to_string(),
listen_address: "0.0.0.0".to_string(),
listen_port: 4180,
socks_address: "127.0.0.1".to_string(),
socks_port: 1180,
@@ -128,6 +132,34 @@ pub fn generate(
Ok(())
}
pub fn generate_material(
ca_name: &str,
server_name: &str,
client_name: &str,
) -> anyhow::Result<GeneratedMaterial> {
generate_material_with_server_host(ca_name, server_name, client_name, None)
}
pub fn generate_material_with_server_host(
ca_name: &str,
server_name: &str,
client_name: &str,
server_host: Option<&str>,
) -> anyhow::Result<GeneratedMaterial> {
let ca = generate_ca(ca_name)?;
let (server_cert, server_key) = generate_server_cert(&ca, server_name, server_host)?;
let (client_cert, client_key) = generate_client_cert(&ca, client_name)?;
Ok(GeneratedMaterial {
ca_cert_pem: ca.as_ref().pem(),
ca_key_pem: ca.key().serialize_pem(),
server_cert_pem: server_cert.pem(),
server_key_pem: server_key.serialize_pem(),
client_cert_pem: client_cert.pem(),
client_key_pem: client_key.serialize_pem(),
auth_token: generate_auth_token(),
})
}
fn generate_ca(cn: &str) -> anyhow::Result<CertifiedIssuer<'static, KeyPair>> {
let mut params = CertificateParams::default();
let mut dn = DistinguishedName::new();
@@ -145,6 +177,7 @@ fn generate_ca(cn: &str) -> anyhow::Result<CertifiedIssuer<'static, KeyPair>> {
fn generate_server_cert(
ca: &CertifiedIssuer<'_, KeyPair>,
cn: &str,
extra_host: Option<&str>,
) -> anyhow::Result<(Certificate, KeyPair)> {
let mut params = CertificateParams::default();
let mut dn = DistinguishedName::new();
@@ -157,6 +190,16 @@ fn generate_server_cert(
SanType::DnsName("rustunnel".try_into().unwrap()),
SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))),
];
if let Some(host) = extra_host
&& host != "0.0.0.0"
&& host != "::"
{
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
params.subject_alt_names.push(SanType::IpAddress(ip));
} else if let Ok(dns) = host.try_into() {
params.subject_alt_names.push(SanType::DnsName(dns));
}
}
params.serial_number = Some(SerialNumber::from(2));
let key_pair = KeyPair::generate()?;
@@ -200,12 +243,13 @@ mod tests {
use crate::redact;
fn temp_dir() -> std::path::PathBuf {
// Use process ID + monotonic counter to avoid collisions when tests run in parallel
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!(
"rustunnel_generate_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
"rustunnel_generate_test_{}_{}",
std::process::id(),
unique
))
}
@@ -296,7 +340,7 @@ mod tests {
let config_str = std::fs::read_to_string(dir.join("config.json")).unwrap();
let config: Config = serde_json::from_str(&config_str).unwrap();
assert_eq!(config.listen_address, "127.0.0.1");
assert_eq!(config.listen_address, "0.0.0.0");
assert_eq!(config.listen_port, 4180);
assert_eq!(config.socks_address, "127.0.0.1");
assert_eq!(config.socks_port, 1180);
+325 -46
View File
@@ -1,8 +1,11 @@
mod cli;
mod config;
mod connkey;
mod errors;
mod framing;
mod generate;
mod redact;
mod signal;
mod socks5;
mod tls;
mod tunnel;
@@ -14,7 +17,8 @@ use std::sync::Arc;
use clap::Parser;
use crate::cli::Cli;
use crate::tunnel::{ConnectorConfig, ListenerConfig};
use crate::connkey::ConnectionKey;
use crate::tunnel::{ClientTlsMaterial, ConnectorConfig, ListenerConfig, ServerTlsMaterial};
fn main() {
// Install the default crypto provider (ring) for rustls
@@ -34,28 +38,49 @@ fn main() {
let exit_code = match cli.command {
crate::cli::Commands::Listen {
listen,
advertise,
connection_key,
socks,
cert,
key,
ca_cert,
auth_token,
auth_token_file,
} => run_listen(listen, cert, key, ca_cert, auth_token, auth_token_file),
insecure_skip_tls_verify,
} => run_listen(
listen,
advertise,
connection_key,
socks,
cert,
key,
ca_cert,
auth_token,
auth_token_file,
insecure_skip_tls_verify,
),
crate::cli::Commands::Connect {
connection_key_arg,
target,
connection_key,
socks,
cert,
key,
ca_cert,
auth_token,
auth_token_file,
insecure_skip_tls_verify,
} => run_connect(
connection_key_arg,
target,
connection_key,
socks,
cert,
key,
ca_cert,
auth_token,
auth_token_file,
insecure_skip_tls_verify,
),
crate::cli::Commands::Generate {
out,
@@ -63,34 +88,31 @@ fn main() {
server_name,
client_name,
} => run_generate(&out, &ca_name, &server_name, &client_name),
crate::cli::Commands::Keygen {
target,
ca_name,
server_name,
client_name,
} => run_keygen(&target, &ca_name, &server_name, &client_name),
crate::cli::Commands::Version => run_version(),
};
process::exit(exit_code);
}
#[allow(clippy::too_many_arguments)]
fn run_listen(
listen: String,
advertise: Option<String>,
connection_key: Option<String>,
socks: Option<String>,
cert: String,
key: String,
ca_cert: String,
auth_token: String,
auth_token_file: Option<String>,
insecure_skip_tls_verify: bool,
) -> i32 {
// Validate all inputs before opening any sockets
if cert.is_empty() {
eprintln!("Error: --cert is required for listen command");
return 1;
}
if key.is_empty() {
eprintln!("Error: --key is required for listen command");
return 1;
}
if ca_cert.is_empty() {
eprintln!("Error: --ca-cert is required for listen command");
return 1;
}
let (host, port) = match cli::parse_host_port(&listen) {
Ok(v) => v,
Err(e) => {
@@ -111,7 +133,99 @@ fn run_listen(
}
};
// Verify files exist before opening sockets
let socks_addr = if let Some(socks) = socks {
let (socks_host, socks_port) = match cli::parse_host_port(&socks) {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {}", e);
return 1;
}
};
match tokio::runtime::Runtime::new()
.unwrap()
.block_on(tunnel::resolve_host(&socks_host, socks_port))
{
Ok(addrs) => Some(addrs[0]),
Err(e) => {
eprintln!(
"Error: failed to resolve SOCKS5 address '{}': {}",
socks_host, e
);
return 1;
}
}
} else {
None
};
let (tls, auth_token, generated_key) = if let Some(raw_key) = connection_key {
match ConnectionKey::decode(&raw_key) {
Ok(k) => (
ServerTlsMaterial::Pem {
cert: Arc::new(k.server_cert_pem),
key: Arc::new(k.server_key_pem),
ca_cert: Arc::new(k.ca_cert_pem),
},
k.auth_token,
None,
),
Err(e) => {
eprintln!("Error: {}", e);
return 1;
}
}
} else if cert.is_empty()
&& key.is_empty()
&& ca_cert.is_empty()
&& auth_token.is_empty()
&& auth_token_file.is_none()
{
let target = advertise.unwrap_or_else(|| {
if host == "0.0.0.0" || host == "::" {
format!("127.0.0.1:{}", port)
} else {
listen.clone()
}
});
if let Err(e) = cli::parse_host_port(&target) {
eprintln!("Error: invalid --advertise value: {}", e);
return 1;
}
match generate_key(
&target,
"rustunnel-ca",
"rustunnel-server",
"rustunnel-client",
) {
Ok((key, encoded)) => (
ServerTlsMaterial::Pem {
cert: Arc::new(key.server_cert_pem),
key: Arc::new(key.server_key_pem),
ca_cert: Arc::new(key.ca_cert_pem),
},
key.auth_token,
Some(encoded),
),
Err(e) => {
eprintln!("Error generating connection key: {}", e);
return 1;
}
}
} else {
if cert.is_empty() {
eprintln!("Error: --cert is required for listen command unless using a connection key");
return 1;
}
if key.is_empty() {
eprintln!("Error: --key is required for listen command unless using a connection key");
return 1;
}
if ca_cert.is_empty() {
eprintln!(
"Error: --ca-cert is required for listen command unless using a connection key"
);
return 1;
}
if !std::path::Path::new(&cert).exists() {
eprintln!("Error: server certificate not found: {}", cert);
return 1;
@@ -124,8 +238,6 @@ fn run_listen(
eprintln!("Error: CA certificate not found: {}", ca_cert);
return 1;
}
// Load auth token from file or value
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
let token_value: Option<&str> = if auth_token.is_empty() {
None
@@ -139,26 +251,39 @@ fn run_listen(
return 1;
}
};
(
ServerTlsMaterial::from_paths(&cert, &key, &ca_cert),
auth_token,
None,
)
};
tracing::info!(
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport, endpoint: /tunnel)",
host,
port
);
tracing::info!("Using server certificate: {}", redact::Redacted::new(&cert));
tracing::info!("Using server key: {}", redact::Redacted::new(&key));
tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert));
if let Some(key) = generated_key {
println!("Connection key:\n{}\n", key);
println!("Connect with:\nrustunnel connect {}", key);
}
tracing::info!(
"Auth token configured: {}",
redact::Redacted::new(&auth_token)
);
if insecure_skip_tls_verify {
tracing::warn!(
"Listener running with --insecure-skip-tls-verify: client certificate verification is DISABLED"
);
}
let config = ListenerConfig {
bind_addr,
server_cert_path: Arc::from(Path::new(&cert)),
server_key_path: Arc::from(Path::new(&key)),
ca_cert_path: Arc::from(Path::new(&ca_cert)),
socks_addr,
tls,
auth_token: Arc::new(auth_token),
insecure_skip_tls_verify,
};
// Run the async listener
@@ -173,30 +298,45 @@ fn run_listen(
0
}
#[allow(clippy::too_many_arguments)]
fn run_connect(
target: String,
connection_key_arg: Option<String>,
target: Option<String>,
connection_key: Option<String>,
socks: String,
cert: String,
key: String,
ca_cert: String,
auth_token: String,
auth_token_file: Option<String>,
insecure_skip_tls_verify: bool,
) -> i32 {
// Validate all inputs before opening any sockets
if cert.is_empty() {
eprintln!("Error: --cert is required for connect command");
let raw_connection_key = connection_key_arg.or(connection_key);
let decoded_key = if let Some(raw) = raw_connection_key {
match ConnectionKey::decode(&raw) {
Ok(k) => Some(k),
Err(e) => {
eprintln!("Error: {}", e);
return 1;
}
if key.is_empty() {
eprintln!("Error: --key is required for connect command");
return 1;
}
if ca_cert.is_empty() {
eprintln!("Error: --ca-cert is required for connect command");
return 1;
}
} else {
None
};
let (target_host, target_port) = match cli::parse_host_port(&target) {
let target_value = match select_connect_target(target.as_deref(), decoded_key.as_ref()) {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {}", e);
return 1;
}
};
if should_warn_target_override(target.as_deref(), decoded_key.as_ref()) {
tracing::warn!(
"--target overrides the listener address embedded in the connection key; TLS trust still uses the key's CA/client certificate material"
);
}
let (target_host, target_port) = match cli::parse_host_port(target_value) {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {}", e);
@@ -212,7 +352,32 @@ fn run_connect(
}
};
// Verify files exist before opening any sockets
let (tls, auth_token) = if let Some(k) = decoded_key {
(
ClientTlsMaterial::Pem {
cert: Arc::new(k.client_cert_pem),
key: Arc::new(k.client_key_pem),
ca_cert: Arc::new(k.ca_cert_pem),
},
k.auth_token,
)
} else {
if cert.is_empty() {
eprintln!(
"Error: --cert is required for connect command unless using a connection key"
);
return 1;
}
if key.is_empty() {
eprintln!("Error: --key is required for connect command unless using a connection key");
return 1;
}
if ca_cert.is_empty() {
eprintln!(
"Error: --ca-cert is required for connect command unless using a connection key"
);
return 1;
}
if !std::path::Path::new(&cert).exists() {
eprintln!("Error: client certificate not found: {}", cert);
return 1;
@@ -225,8 +390,6 @@ fn run_connect(
eprintln!("Error: CA certificate not found: {}", ca_cert);
return 1;
}
// Load auth token from file or value
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
let token_value: Option<&str> = if auth_token.is_empty() {
None
@@ -240,6 +403,11 @@ fn run_connect(
return 1;
}
};
(
ClientTlsMaterial::from_paths(&cert, &key, &ca_cert),
auth_token,
)
};
tracing::info!(
"Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)",
@@ -251,9 +419,6 @@ fn run_connect(
socks_host,
socks_port
);
tracing::info!("Using client certificate: {}", redact::Redacted::new(&cert));
tracing::info!("Using client key: {}", redact::Redacted::new(&key));
tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert));
tracing::info!(
"Auth token configured: {}",
redact::Redacted::new(&auth_token)
@@ -274,13 +439,18 @@ fn run_connect(
}
};
if insecure_skip_tls_verify {
tracing::warn!(
"Connector running with --insecure-skip-tls-verify: server certificate verification is DISABLED"
);
}
let config = ConnectorConfig {
target_host,
target_port,
client_cert_path: Arc::from(Path::new(&cert)),
client_key_path: Arc::from(Path::new(&key)),
ca_cert_path: Arc::from(Path::new(&ca_cert)),
tls,
auth_token: Arc::new(auth_token),
insecure_skip_tls_verify,
};
// Run the async connector with SOCKS5 proxy
@@ -295,6 +465,60 @@ fn run_connect(
0
}
fn select_connect_target<'a>(
target: Option<&'a str>,
decoded_key: Option<&'a ConnectionKey>,
) -> anyhow::Result<&'a str> {
if let Some(target) = target {
return Ok(target);
}
if let Some(k) = decoded_key {
return Ok(k.target.as_str());
}
anyhow::bail!("--target is required for connect unless using a connection key")
}
fn should_warn_target_override(target: Option<&str>, decoded_key: Option<&ConnectionKey>) -> bool {
matches!((target, decoded_key), (Some(target), Some(k)) if target != k.target)
}
fn generate_key(
target: &str,
ca_name: &str,
server_name: &str,
client_name: &str,
) -> anyhow::Result<(ConnectionKey, String)> {
let (host, _) = cli::parse_host_port(target)?;
let material = generate::generate_material_with_server_host(
ca_name,
server_name,
client_name,
Some(&host),
)?;
let key = ConnectionKey::new(target.to_string(), material);
let encoded = key.encode()?;
Ok((key, encoded))
}
fn run_keygen(target: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 {
if let Err(e) = cli::parse_host_port(target) {
eprintln!("Error: {}", e);
return 1;
}
match generate_key(target, ca_name, server_name, client_name) {
Ok((_key, encoded)) => {
println!("{}", encoded);
0
}
Err(e) => {
eprintln!("Error generating connection key: {}", e);
1
}
}
}
fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 {
let out_dir = std::path::Path::new(out);
@@ -320,3 +544,58 @@ fn run_version() -> i32 {
println!("Arch: {}", std::env::consts::ARCH);
0
}
#[cfg(test)]
mod tests {
use super::*;
fn test_connection_key(target: &str) -> ConnectionKey {
ConnectionKey {
version: 1,
target: target.to_string(),
ca_cert_pem: "ca".to_string(),
server_cert_pem: "server-cert".to_string(),
server_key_pem: "server-key".to_string(),
client_cert_pem: "client-cert".to_string(),
client_key_pem: "client-key".to_string(),
auth_token: "token".to_string(),
}
}
#[test]
fn explicit_connect_target_overrides_key_target() {
let key = test_connection_key("198.51.100.10:443");
let selected = select_connect_target(Some("23.167.32.42:443"), Some(&key)).unwrap();
assert_eq!(selected, "23.167.32.42:443");
}
#[test]
fn connect_uses_key_target_when_explicit_target_absent() {
let key = test_connection_key("198.51.100.10:443");
let selected = select_connect_target(None, Some(&key)).unwrap();
assert_eq!(selected, "198.51.100.10:443");
}
#[test]
fn connect_target_is_required_without_key() {
let err = select_connect_target(None, None).unwrap_err();
assert_eq!(
err.to_string(),
"--target is required for connect unless using a connection key"
);
}
#[test]
fn warns_when_explicit_target_differs_from_key_target() {
let key = test_connection_key("198.51.100.10:443");
assert!(should_warn_target_override(
Some("23.167.32.42:443"),
Some(&key)
));
assert!(!should_warn_target_override(
Some("198.51.100.10:443"),
Some(&key)
));
assert!(!should_warn_target_override(Some("23.167.32.42:443"), None));
}
}
+11
View File
@@ -118,6 +118,11 @@ pub fn is_auth_token(content: &str) -> bool {
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
#[allow(dead_code)]
pub fn is_connection_key(content: &str) -> bool {
content.trim_start().starts_with("rtun1.")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -206,6 +211,12 @@ mod tests {
assert!(!is_auth_token("short"));
}
#[test]
fn is_connection_key_detects_rtun_prefix() {
assert!(is_connection_key("rtun1.abc"));
assert!(!is_connection_key("abc"));
}
#[test]
fn tracing_logs_redact_secrets() {
// Verify that tracing with a Redacted value doesn't leak secrets
+22
View File
@@ -0,0 +1,22 @@
/// Cross-platform shutdown signal handling.
///
/// Provides a unified async future that resolves when the process should
/// shut down (SIGINT/SIGTERM on Unix, Ctrl+C/Ctrl+Break on Windows).
pub async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigint =
signal(SignalKind::interrupt()).expect("failed to create SIGINT signal handler");
let mut sigterm =
signal(SignalKind::terminate()).expect("failed to create SIGTERM signal handler");
tokio::select! {
_ = sigint.recv() => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await.ok();
}
}
+3
View File
@@ -45,6 +45,7 @@ pub enum Socks5Error {
AuthFailed,
#[error("connection not allowed")]
#[allow(dead_code)]
ConnectionNotAllowed,
#[error("network unreachable")]
@@ -52,9 +53,11 @@ pub enum Socks5Error {
NetworkUnreachable,
#[error("host unreachable")]
#[allow(dead_code)]
HostUnreachable,
#[error("connection refused")]
#[allow(dead_code)]
ConnectionRefused,
#[error("protocol error: {0}")]
+288 -62
View File
@@ -7,21 +7,93 @@ use std::sync::Arc;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::server::danger::ClientCertVerifier;
use rustls::{ClientConfig, RootCertStore, ServerConfig};
use rustls::{ClientConfig, RootCertStore, ServerConfig, SignatureScheme};
use crate::errors::TlsError;
/// Insecure server certificate verifier that accepts any certificate.
/// Used only when `--insecure-skip-tls-verify` is set on the connector.
#[derive(Debug)]
struct InsecureServerCertVerifier;
impl rustls::client::danger::ServerCertVerifier for InsecureServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
]
}
}
pub fn cert_fingerprint_sha256(cert: &CertificateDer<'_>) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(cert.as_ref());
hex::encode(digest)
}
/// Load PEM-encoded certificates from a file.
pub fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let content = std::fs::read(path).map_err(TlsError::Io)?;
load_certs_from_pem(&content, "certificate file").map_err(|e| match e {
TlsError::Invalid(_, msg) if msg.starts_with("no certificates found") => TlsError::Invalid(
"certificate file",
format!("no certificates found in {}", path.display()),
),
other => other,
})
}
/// Load PEM-encoded certificates from bytes.
pub fn load_certs_from_pem(
content: &[u8],
label: &'static str,
) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let mut cursor = std::io::Cursor::new(&content);
let certs = rustls_pemfile::certs(&mut cursor)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TlsError::Invalid("certificate file", e.to_string()))?;
.map_err(|e| TlsError::Invalid(label, e.to_string()))?;
if certs.is_empty() {
return Err(TlsError::Invalid(
"certificate file",
format!("no certificates found in {}", path.display()),
label,
"no certificates found".to_string(),
));
}
Ok(certs)
@@ -30,17 +102,87 @@ pub fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError>
/// Load a PEM-encoded private key from a file.
pub fn load_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
let content = std::fs::read(path).map_err(TlsError::Io)?;
load_key_from_pem(&content, "private key file").map_err(|e| match e {
TlsError::Invalid(_, msg) if msg == "no private key found" => TlsError::Invalid(
"private key file",
format!("no private key found in {}", path.display()),
),
other => other,
})
}
/// Load a PEM-encoded private key from bytes.
pub fn load_key_from_pem(
content: &[u8],
label: &'static str,
) -> Result<PrivateKeyDer<'static>, TlsError> {
let mut cursor = std::io::Cursor::new(&content);
match rustls_pemfile::private_key(&mut cursor) {
Ok(Some(key)) => Ok(key),
Ok(None) => Err(TlsError::Invalid(
"private key file",
format!("no private key found in {}", path.display()),
)),
Err(e) => Err(TlsError::Invalid("private key file", e.to_string())),
Ok(None) => Err(TlsError::Invalid(label, "no private key found".to_string())),
Err(e) => Err(TlsError::Invalid(label, e.to_string())),
}
}
fn build_server_config_from_parts(
server_certs: Vec<CertificateDer<'static>>,
server_key: PrivateKeyDer<'static>,
ca_certs: Vec<CertificateDer<'static>>,
) -> Result<Arc<ServerConfig>, TlsError> {
if server_certs.is_empty() {
return Err(TlsError::Missing("server certificate"));
}
if ca_certs.is_empty() {
return Err(TlsError::Missing("CA certificate for client verification"));
}
let mut root_store = RootCertStore::empty();
for ca_cert in &ca_certs {
root_store
.add(ca_cert.clone())
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
}
let client_verifier: Arc<dyn ClientCertVerifier> =
rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.map_err(|e| TlsError::Invalid("client verifier", e.to_string()))?;
let config = ServerConfig::builder()
.with_client_cert_verifier(client_verifier)
.with_single_cert(server_certs, server_key)
.map_err(|e| TlsError::Invalid("server config", e.to_string()))?;
Ok(Arc::new(config))
}
fn build_client_config_from_parts(
client_certs: Vec<CertificateDer<'static>>,
client_key: PrivateKeyDer<'static>,
ca_certs: Vec<CertificateDer<'static>>,
) -> Result<Arc<ClientConfig>, TlsError> {
if client_certs.is_empty() {
return Err(TlsError::Missing("client certificate"));
}
if ca_certs.is_empty() {
return Err(TlsError::Missing("CA certificate for server verification"));
}
let mut root_store = RootCertStore::empty();
for ca_cert in &ca_certs {
root_store
.add(ca_cert.clone())
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
}
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_client_auth_cert(client_certs, client_key)
.map_err(|e| TlsError::Invalid("client config", e.to_string()))?;
Ok(Arc::new(config))
}
/// Build a Rustls server configuration with mTLS.
///
/// The server will:
@@ -52,42 +194,22 @@ pub fn build_server_config(
server_key_path: &Path,
ca_cert_path: &Path,
) -> Result<Arc<ServerConfig>, TlsError> {
// Load server certificate
let server_certs = load_certs(server_cert_path)?;
if server_certs.is_empty() {
return Err(TlsError::Missing("server certificate"));
}
// Load server private key
let server_key = load_key(server_key_path)?;
// Load CA certificate for client verification
let ca_certs = load_certs(ca_cert_path)?;
if ca_certs.is_empty() {
return Err(TlsError::Missing("CA certificate for client verification"));
}
build_server_config_from_parts(server_certs, server_key, ca_certs)
}
// Build root cert store for client verification
let mut root_store = RootCertStore::empty();
for ca_cert in &ca_certs {
root_store
.add(ca_cert.clone())
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
}
// Create client verifier that requires valid client certs
let client_verifier: Arc<dyn ClientCertVerifier> =
rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.map_err(|e| TlsError::Invalid("client verifier", e.to_string()))?;
// Build server config
let config = ServerConfig::builder()
.with_client_cert_verifier(client_verifier)
.with_single_cert(server_certs, server_key)
.map_err(|e| TlsError::Invalid("server config", e.to_string()))?;
Ok(Arc::new(config))
pub fn build_server_config_from_pem(
server_cert_pem: &str,
server_key_pem: &str,
ca_cert_pem: &str,
) -> Result<Arc<ServerConfig>, TlsError> {
build_server_config_from_parts(
load_certs_from_pem(server_cert_pem.as_bytes(), "server certificate")?,
load_key_from_pem(server_key_pem.as_bytes(), "server private key")?,
load_certs_from_pem(ca_cert_pem.as_bytes(), "CA certificate")?,
)
}
/// Build a Rustls client configuration with mTLS.
@@ -101,35 +223,88 @@ pub fn build_client_config(
client_key_path: &Path,
ca_cert_path: &Path,
) -> Result<Arc<ClientConfig>, TlsError> {
// Load client certificate
let client_certs = load_certs(client_cert_path)?;
let client_key = load_key(client_key_path)?;
let ca_certs = load_certs(ca_cert_path)?;
build_client_config_from_parts(client_certs, client_key, ca_certs)
}
pub fn build_client_config_from_pem(
client_cert_pem: &str,
client_key_pem: &str,
ca_cert_pem: &str,
) -> Result<Arc<ClientConfig>, TlsError> {
build_client_config_from_parts(
load_certs_from_pem(client_cert_pem.as_bytes(), "client certificate")?,
load_key_from_pem(client_key_pem.as_bytes(), "client private key")?,
load_certs_from_pem(ca_cert_pem.as_bytes(), "CA certificate")?,
)
}
fn build_client_config_insecure_from_parts(
client_certs: Vec<CertificateDer<'static>>,
client_key: PrivateKeyDer<'static>,
) -> Result<Arc<ClientConfig>, TlsError> {
if client_certs.is_empty() {
return Err(TlsError::Missing("client certificate"));
}
// Load client private key
let client_key = load_key(client_key_path)?;
// Load CA certificate for server verification
let ca_certs = load_certs(ca_cert_path)?;
if ca_certs.is_empty() {
return Err(TlsError::Missing("CA certificate for server verification"));
}
// Build root cert store for server verification
let mut root_store = RootCertStore::empty();
for ca_cert in &ca_certs {
root_store
.add(ca_cert.clone())
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
}
// Build client config
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureServerCertVerifier))
.with_client_auth_cert(client_certs, client_key)
.map_err(|e| TlsError::Invalid("client config", e.to_string()))?;
Ok(Arc::new(config))
}
pub fn build_client_config_insecure(
client_cert_path: &Path,
client_key_path: &Path,
) -> Result<Arc<ClientConfig>, TlsError> {
let client_certs = load_certs(client_cert_path)?;
let client_key = load_key(client_key_path)?;
build_client_config_insecure_from_parts(client_certs, client_key)
}
pub fn build_client_config_insecure_from_pem(
client_cert_pem: &str,
client_key_pem: &str,
) -> Result<Arc<ClientConfig>, TlsError> {
build_client_config_insecure_from_parts(
load_certs_from_pem(client_cert_pem.as_bytes(), "client certificate")?,
load_key_from_pem(client_key_pem.as_bytes(), "client private key")?,
)
}
pub fn build_server_config_insecure(
server_cert_path: &Path,
server_key_path: &Path,
) -> Result<Arc<ServerConfig>, TlsError> {
let server_certs = load_certs(server_cert_path)?;
let server_key = load_key(server_key_path)?;
build_server_config_insecure_from_parts(server_certs, server_key)
}
pub fn build_server_config_insecure_from_pem(
server_cert_pem: &str,
server_key_pem: &str,
) -> Result<Arc<ServerConfig>, TlsError> {
build_server_config_insecure_from_parts(
load_certs_from_pem(server_cert_pem.as_bytes(), "server certificate")?,
load_key_from_pem(server_key_pem.as_bytes(), "server private key")?,
)
}
fn build_server_config_insecure_from_parts(
server_certs: Vec<CertificateDer<'static>>,
server_key: PrivateKeyDer<'static>,
) -> Result<Arc<ServerConfig>, TlsError> {
if server_certs.is_empty() {
return Err(TlsError::Missing("server certificate"));
}
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(server_certs, server_key)
.map_err(|e| TlsError::Invalid("server config", e.to_string()))?;
Ok(Arc::new(config))
}
@@ -325,6 +500,19 @@ mod tests {
assert!(validate_cert_identity(&cert_der, "example.com").is_err());
}
#[test]
fn cert_fingerprint_sha256_is_stable_hex() {
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path();
crate::generate::generate(out_dir, "ca", "server", "client").unwrap();
let cert = load_certs(&out_dir.join("server.crt")).unwrap()[0].clone();
let fingerprint = cert_fingerprint_sha256(&cert);
assert_eq!(fingerprint.len(), 64);
assert!(fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(fingerprint, cert_fingerprint_sha256(&cert));
}
#[test]
fn check_cert_not_expired_with_valid_cert() {
init_crypto();
@@ -380,4 +568,42 @@ mod tests {
);
assert!(config.is_err());
}
#[test]
fn build_client_config_insecure_skips_ca() {
init_crypto();
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path();
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
let config =
build_client_config_insecure(&out_dir.join("client.crt"), &out_dir.join("client.key"));
assert!(config.is_ok());
}
#[test]
fn build_server_config_insecure_skips_ca() {
init_crypto();
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path();
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
let config =
build_server_config_insecure(&out_dir.join("server.crt"), &out_dir.join("server.key"));
assert!(config.is_ok());
}
#[test]
fn build_client_config_insecure_from_pem_skips_ca() {
init_crypto();
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path();
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
let client_cert = std::fs::read_to_string(out_dir.join("client.crt")).unwrap();
let client_key = std::fs::read_to_string(out_dir.join("client.key")).unwrap();
let config = build_client_config_insecure_from_pem(&client_cert, &client_key);
assert!(config.is_ok());
}
}
+2938 -1391
View File
File diff suppressed because it is too large Load Diff