Compare commits
11
Commits
6466753176
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f1d04fcaf | ||
|
|
0166fb6511 | ||
|
|
a758e8e0bc | ||
|
|
cc1eb55c58 | ||
|
|
241d7d1c1b | ||
|
|
b0210a6cfe | ||
|
|
32a29c89f2 | ||
|
|
10b8b24dce | ||
|
|
b67248e14f | ||
|
|
d6a0a40696 | ||
|
|
f871ba7f7b |
@@ -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 }}"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/target
|
||||
/release
|
||||
**/*.pem
|
||||
**/*.json
|
||||
!.wiki-meta.json
|
||||
.env
|
||||
*.log
|
||||
|
||||
Generated
+1
@@ -1293,6 +1293,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
"rand",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
@@ -33,6 +33,7 @@ sha2 = "0.10"
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
url = "2"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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 3–4, 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`)
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,7 @@
|
||||
# Reference
|
||||
|
||||
Configuration, data models, and external dependencies.
|
||||
|
||||
- [Configuration](configuration.md)
|
||||
- [Data models](data-models.md)
|
||||
- [Dependencies](dependencies.md)
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
+113
-17
@@ -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,72 @@ 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,
|
||||
|
||||
/// Target process file descriptor limit to request on Unix
|
||||
#[arg(long, default_value_t = 1_048_576)]
|
||||
max_open_files: u64,
|
||||
|
||||
/// Maximum concurrent authenticated HTTPS tunnel sessions
|
||||
#[arg(long, default_value_t = 4096)]
|
||||
max_tunnel_sessions: usize,
|
||||
|
||||
/// Maximum concurrent SOCKS5 client connections
|
||||
#[arg(long, default_value_t = 65_536)]
|
||||
max_socks_connections: usize,
|
||||
|
||||
/// Maximum concurrent streams per tunnel
|
||||
#[arg(long, default_value_t = 65_536)]
|
||||
max_streams_per_tunnel: usize,
|
||||
|
||||
/// TLS/HTTP/SOCKS handshake timeout in milliseconds
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
handshake_timeout_ms: u64,
|
||||
|
||||
/// DNS/target connect timeout in milliseconds
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
connect_timeout_ms: u64,
|
||||
|
||||
/// Timeout waiting for tunneled CONNECT replies in milliseconds
|
||||
#[arg(long, default_value_t = 15_000)]
|
||||
stream_open_timeout_ms: u64,
|
||||
},
|
||||
|
||||
/// 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 +126,34 @@ 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,
|
||||
|
||||
/// Target process file descriptor limit to request on Unix
|
||||
#[arg(long, default_value_t = 1_048_576)]
|
||||
max_open_files: u64,
|
||||
|
||||
/// Maximum concurrent SOCKS5 client connections
|
||||
#[arg(long, default_value_t = 65_536)]
|
||||
max_socks_connections: usize,
|
||||
|
||||
/// Maximum concurrent streams per tunnel
|
||||
#[arg(long, default_value_t = 65_536)]
|
||||
max_streams_per_tunnel: usize,
|
||||
|
||||
/// TLS/HTTP/SOCKS handshake timeout in milliseconds
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
handshake_timeout_ms: u64,
|
||||
|
||||
/// DNS/target connect timeout in milliseconds
|
||||
#[arg(long, default_value_t = 10_000)]
|
||||
connect_timeout_ms: u64,
|
||||
|
||||
/// Timeout waiting for tunneled CONNECT replies in milliseconds
|
||||
#[arg(long, default_value_t = 15_000)]
|
||||
stream_open_timeout_ms: u64,
|
||||
},
|
||||
|
||||
/// Generate local certificate and auth material
|
||||
@@ -103,6 +179,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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
/// 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?;
|
||||
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
@@ -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);
|
||||
|
||||
+409
-48
@@ -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;
|
||||
@@ -10,11 +13,15 @@ mod tunnel;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::tunnel::{ConnectorConfig, ListenerConfig};
|
||||
use crate::connkey::ConnectionKey;
|
||||
use crate::tunnel::{
|
||||
ClientTlsMaterial, ConnectorConfig, ListenerConfig, PerformanceConfig, ServerTlsMaterial,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
// Install the default crypto provider (ring) for rustls
|
||||
@@ -34,28 +41,75 @@ 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,
|
||||
max_open_files,
|
||||
max_tunnel_sessions,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
} => run_listen(
|
||||
listen,
|
||||
advertise,
|
||||
connection_key,
|
||||
socks,
|
||||
cert,
|
||||
key,
|
||||
ca_cert,
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
insecure_skip_tls_verify,
|
||||
max_open_files,
|
||||
max_tunnel_sessions,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
),
|
||||
crate::cli::Commands::Connect {
|
||||
connection_key_arg,
|
||||
target,
|
||||
connection_key,
|
||||
socks,
|
||||
cert,
|
||||
key,
|
||||
ca_cert,
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
insecure_skip_tls_verify,
|
||||
max_open_files,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
} => run_connect(
|
||||
connection_key_arg,
|
||||
target,
|
||||
connection_key,
|
||||
socks,
|
||||
cert,
|
||||
key,
|
||||
ca_cert,
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
insecure_skip_tls_verify,
|
||||
max_open_files,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
),
|
||||
crate::cli::Commands::Generate {
|
||||
out,
|
||||
@@ -63,33 +117,47 @@ 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,
|
||||
max_open_files: u64,
|
||||
max_tunnel_sessions: usize,
|
||||
max_socks_connections: usize,
|
||||
max_streams_per_tunnel: usize,
|
||||
handshake_timeout_ms: u64,
|
||||
connect_timeout_ms: u64,
|
||||
stream_open_timeout_ms: u64,
|
||||
) -> 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;
|
||||
}
|
||||
configure_performance(
|
||||
max_open_files,
|
||||
max_tunnel_sessions,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
);
|
||||
|
||||
let (host, port) = match cli::parse_host_port(&listen) {
|
||||
Ok(v) => v,
|
||||
@@ -111,7 +179,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 +284,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 +297,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 +344,61 @@ 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,
|
||||
max_open_files: u64,
|
||||
max_socks_connections: usize,
|
||||
max_streams_per_tunnel: usize,
|
||||
handshake_timeout_ms: u64,
|
||||
connect_timeout_ms: u64,
|
||||
stream_open_timeout_ms: u64,
|
||||
) -> i32 {
|
||||
// Validate all inputs before opening any sockets
|
||||
if cert.is_empty() {
|
||||
eprintln!("Error: --cert is required for connect command");
|
||||
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;
|
||||
}
|
||||
configure_performance(
|
||||
max_open_files,
|
||||
1,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout_ms,
|
||||
connect_timeout_ms,
|
||||
stream_open_timeout_ms,
|
||||
);
|
||||
|
||||
let (target_host, target_port) = match cli::parse_host_port(&target) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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 +414,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 +452,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 +465,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 +481,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 +501,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 +527,80 @@ fn run_connect(
|
||||
0
|
||||
}
|
||||
|
||||
fn configure_performance(
|
||||
max_open_files: u64,
|
||||
max_tunnel_sessions: usize,
|
||||
max_socks_connections: usize,
|
||||
max_streams_per_tunnel: usize,
|
||||
handshake_timeout_ms: u64,
|
||||
connect_timeout_ms: u64,
|
||||
stream_open_timeout_ms: u64,
|
||||
) {
|
||||
tunnel::set_performance_config(PerformanceConfig {
|
||||
max_open_files,
|
||||
max_tunnel_sessions,
|
||||
max_socks_connections,
|
||||
max_streams_per_tunnel,
|
||||
handshake_timeout: Duration::from_millis(handshake_timeout_ms),
|
||||
connect_timeout: Duration::from_millis(connect_timeout_ms),
|
||||
stream_open_timeout: Duration::from_millis(stream_open_timeout_ms),
|
||||
});
|
||||
}
|
||||
|
||||
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 +626,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+3303
-1333
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user