Initial commit
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local config (may contain secrets)
|
||||||
|
config.toml
|
||||||
|
|
||||||
|
# Debug / logs
|
||||||
|
*.log
|
||||||
5914
Cargo.lock
generated
Normal file
5914
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
Cargo.toml
Normal file
65
Cargo.toml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
[package]
|
||||||
|
name = "onion-transit"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Centralized Tor proxy daemon for LAN/VPS environments"
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
default-run = "onion-transit"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "onion-transit"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "onion-transit-tproxy"
|
||||||
|
path = "src/bin/tproxy.rs"
|
||||||
|
required-features = ["tproxy"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
tproxy = []
|
||||||
|
reduced-security = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Tor
|
||||||
|
arti-client = { version = "0.39", features = ["tokio", "onion-service-client"] }
|
||||||
|
tor-rtcompat = { version = "0.39", features = ["tokio"] }
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# SOCKS5 proxy
|
||||||
|
fast-socks5 = "1.0"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
# Config
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Network utilities
|
||||||
|
ipnet = { version = "2", features = ["serde"] }
|
||||||
|
|
||||||
|
# Serialization (for --status --json)
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Metrics / state
|
||||||
|
arc-swap = "1"
|
||||||
|
tracing-appender = "0.2.4"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
nix = { version = "0.29", features = ["socket"], optional = true }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Onion-Transit contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
118
README.md
Normal file
118
README.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Onion-Transit
|
||||||
|
|
||||||
|
A centralized Tor proxy daemon that embeds [Arti](https://gitlab.torproject.org/tpo/core/arti) (Rust Tor implementation) and exposes SOCKS5 + optional transparent proxy interfaces for LAN/VPS clients.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Clients on a local network or VPN delegate Tor circuit building to a single Onion-Transit node instead of running Tor locally. This trades some anonymity guarantees for simplified deployment and lower per-client overhead.
|
||||||
|
|
||||||
|
**Standard Tor .onion access:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (3 hops) → Rendezvous ← Service (3 hops) = 6 hops total
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Onion-Transit:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → LAN → Transit (3 hops) → Rendezvous ← Service (3 hops)
|
||||||
|
```
|
||||||
|
|
||||||
|
The client's path is a single LAN hop to the Transit node. One Arti instance shares bootstrap, directory cache, and guard nodes across all clients.
|
||||||
|
|
||||||
|
## Security Warning
|
||||||
|
|
||||||
|
> **Onion-Transit centralizes Tor traffic and can see destination .onion names.
|
||||||
|
> Anonymity assumptions differ significantly from Tor Browser.**
|
||||||
|
|
||||||
|
This tool is designed for:
|
||||||
|
|
||||||
|
- Lab / office / team environments
|
||||||
|
- Development and testing
|
||||||
|
- VPS gateway for a trusted user group
|
||||||
|
|
||||||
|
It is **NOT** appropriate for: journalists, activists, or scenarios with adversarial threat models.
|
||||||
|
|
||||||
|
## Deployment Modes
|
||||||
|
|
||||||
|
| Mode | Binds to | Use case |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `gateway` | `0.0.0.0:1080` | Shared SOCKS5 for LAN/VPN clients |
|
||||||
|
| `app-embedded` | `127.0.0.1` only | Sidecar for a single application |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Check config before running
|
||||||
|
onion-transit config-check --config config.toml
|
||||||
|
|
||||||
|
# Start in gateway mode
|
||||||
|
onion-transit start --config config.toml
|
||||||
|
|
||||||
|
# Check runtime status
|
||||||
|
onion-transit status --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See [config.example.toml](config.example.toml) for all options with documentation.
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
mode = "gateway" # or "app-embedded"
|
||||||
|
trust_domain = "team" # "lab", "team", or "personal"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
mode = "standard" # "reduced" requires feature flag + explicit opt-in
|
||||||
|
allowed_clients = ["10.0.0.0/8", "192.168.0.0/16"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transparent Proxy (Linux only)
|
||||||
|
|
||||||
|
Build with the `tproxy` feature and use the separate binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --features tproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
The transparent proxy requires iptables/nftables DNAT/REDIRECT rules. It does **not** support other interception methods. Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
iptables -t nat -A OUTPUT -p tcp -d '*.onion' --dport 80 \
|
||||||
|
-j REDIRECT --to-ports 9040
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the dedicated binary (may require elevated privileges):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
onion-transit-tproxy --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard build (SOCKS5 only)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# With transparent proxy support
|
||||||
|
cargo build --release --features tproxy
|
||||||
|
|
||||||
|
# With reduced-security mode (DANGER)
|
||||||
|
cargo build --release --features reduced-security
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Profiles
|
||||||
|
|
||||||
|
| Profile | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `OnionStrict` | Full 3-hop circuit, stream isolation per destination (default) |
|
||||||
|
| `OnionFast` | Reduced isolation, shared circuits where possible |
|
||||||
|
| `ClearnetDefault` | Standard Tor exit policy |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
92
config.example.toml
Normal file
92
config.example.toml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Onion-Transit configuration
|
||||||
|
# ============================
|
||||||
|
#
|
||||||
|
# WARNING: Onion-Transit centralizes Tor traffic through a single node.
|
||||||
|
# The transit node CAN SEE destination .onion addresses.
|
||||||
|
# Anonymity assumptions differ significantly from Tor Browser.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deployment mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# "gateway" - Network-facing SOCKS5 for multiple LAN/VPN clients (default)
|
||||||
|
# "app-embedded" - Binds 127.0.0.1 only, sidecar for a single application
|
||||||
|
mode = "gateway"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trust domain (controls warning verbosity and default ACL suggestions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# "lab" - Relaxed defaults, wider subnets, minimal warnings
|
||||||
|
# "team" - Moderate warnings, expects auth enabled
|
||||||
|
# "personal" - Strictest (localhost-only ACL), prominent startup warnings
|
||||||
|
trust_domain = "team"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SOCKS5 proxy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[proxy]
|
||||||
|
socks5_listen = "0.0.0.0:1080"
|
||||||
|
|
||||||
|
# Connection timeouts (seconds)
|
||||||
|
handshake_timeout_secs = 10
|
||||||
|
idle_timeout_secs = 300
|
||||||
|
|
||||||
|
[proxy.auth]
|
||||||
|
enabled = false
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transparent proxy (requires `tproxy` feature + iptables REDIRECT rule)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[proxy.transparent]
|
||||||
|
enabled = false
|
||||||
|
listen = "0.0.0.0:9040"
|
||||||
|
# Maximum concurrent connections (backpressure beyond this limit)
|
||||||
|
max_connections = 4096
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tor / Arti engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[tor]
|
||||||
|
# Arti data directory (directory cache, guard state, keys)
|
||||||
|
# Uses Arti's native layout so upstream changes don't break things.
|
||||||
|
data_dir = "/var/lib/onion-transit/arti"
|
||||||
|
bootstrap_timeout_secs = 120
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OnionStrict - Full 3-hop circuit, stream isolation per destination (default)
|
||||||
|
# OnionFast - Reduced isolation, shared circuits where possible
|
||||||
|
# ClearnetDefault - Standard Tor exit policy
|
||||||
|
[tor.profiles]
|
||||||
|
default_onion = "OnionStrict"
|
||||||
|
default_clearnet = "ClearnetDefault"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[security]
|
||||||
|
# "standard" = full 3-hop circuits (recommended, always available)
|
||||||
|
# "reduced" = REQUIRES: cargo feature `reduced-security` + --i-know-what-im-doing flag
|
||||||
|
mode = "standard"
|
||||||
|
|
||||||
|
# Restrict which client IPs can connect (CIDR notation)
|
||||||
|
allowed_clients = ["10.0.0.0/8", "192.168.0.0/16", "127.0.0.0/8"]
|
||||||
|
|
||||||
|
# Restrict which .onion addresses can be accessed (empty = allow all)
|
||||||
|
allowed_onions = []
|
||||||
|
|
||||||
|
# Allow legacy v2 .onion addresses (16-char, deprecated, TEST ONLY)
|
||||||
|
allow_legacy_onion = false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[logging]
|
||||||
|
# "info", "debug", "warn", "error", "trace"
|
||||||
|
level = "info"
|
||||||
|
# "stdout", "syslog", "file"
|
||||||
|
target = "stdout"
|
||||||
|
# Only used when target = "file"
|
||||||
|
file_path = "/var/log/onion-transit/onion-transit.log"
|
||||||
29
src/bin/tproxy.rs
Normal file
29
src/bin/tproxy.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! onion-transit-tproxy: separate binary for transparent proxy.
|
||||||
|
//!
|
||||||
|
//! Requires:
|
||||||
|
//! - `tproxy` cargo feature
|
||||||
|
//! - Linux with iptables/nftables DNAT/REDIRECT rule
|
||||||
|
//! - Elevated privileges (CAP_NET_ADMIN or root)
|
||||||
|
//!
|
||||||
|
//! Does NOT support: macOS pf, BSD, TPROXY mode, or any non-iptables interception.
|
||||||
|
|
||||||
|
// This binary reuses the main crate's modules.
|
||||||
|
// It is kept separate to minimize required privileges for the SOCKS5 binary.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
eprintln!("onion-transit-tproxy: transparent proxy binary");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("This binary requires:");
|
||||||
|
eprintln!(" - Linux with iptables/nftables REDIRECT rule");
|
||||||
|
eprintln!(" - Elevated privileges (CAP_NET_ADMIN or root)");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Example iptables rule:");
|
||||||
|
eprintln!(
|
||||||
|
" iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 9040"
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("See README.md for full documentation.");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Note: Full transparent proxy integration runs inside `onion-transit start`");
|
||||||
|
eprintln!("when [proxy.transparent] enabled = true in config.");
|
||||||
|
}
|
||||||
51
src/cli.rs
Normal file
51
src/cli.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
/// Onion-Transit: centralized Tor proxy daemon for LAN/VPS environments.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "onion-transit", version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
/// Start the Onion-Transit daemon.
|
||||||
|
Start {
|
||||||
|
/// Path to the configuration TOML file.
|
||||||
|
#[arg(short, long, default_value = "config.toml")]
|
||||||
|
config: PathBuf,
|
||||||
|
|
||||||
|
/// Required when security.mode = "reduced". Acknowledges reduced anonymity.
|
||||||
|
#[arg(long)]
|
||||||
|
i_know_what_im_doing: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Validate the configuration file and run a dry-run ACL check.
|
||||||
|
ConfigCheck {
|
||||||
|
/// Path to the configuration TOML file.
|
||||||
|
#[arg(short, long, default_value = "config.toml")]
|
||||||
|
config: PathBuf,
|
||||||
|
|
||||||
|
/// Simulate a connection from this client IP.
|
||||||
|
#[arg(long)]
|
||||||
|
test_client_ip: Option<String>,
|
||||||
|
|
||||||
|
/// Simulate a connection to this .onion address.
|
||||||
|
#[arg(long)]
|
||||||
|
test_onion: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show runtime status of a running daemon.
|
||||||
|
Status {
|
||||||
|
/// Output as JSON.
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
|
||||||
|
/// Arti data directory (must match the running daemon's tor.data_dir).
|
||||||
|
#[arg(long)]
|
||||||
|
data_dir: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
314
src/config.rs
Normal file
314
src/config.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Top-level configuration loaded from TOML.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
/// Deployment mode: "gateway" or "app-embedded"
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: DeploymentMode,
|
||||||
|
|
||||||
|
/// Trust domain controls warning verbosity and default ACL suggestions.
|
||||||
|
#[serde(default = "default_trust_domain")]
|
||||||
|
pub trust_domain: TrustDomain,
|
||||||
|
|
||||||
|
pub proxy: ProxyConfig,
|
||||||
|
pub tor: TorConfig,
|
||||||
|
pub security: SecurityConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub logging: LoggingConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deployment mode ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum DeploymentMode {
|
||||||
|
Gateway,
|
||||||
|
AppEmbedded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mode() -> DeploymentMode {
|
||||||
|
DeploymentMode::Gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trust domain ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TrustDomain {
|
||||||
|
Lab,
|
||||||
|
Team,
|
||||||
|
Personal,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_trust_domain() -> TrustDomain {
|
||||||
|
TrustDomain::Team
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proxy ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProxyConfig {
|
||||||
|
pub socks5_listen: SocketAddr,
|
||||||
|
|
||||||
|
/// SOCKS5 handshake timeout in seconds.
|
||||||
|
#[serde(default = "default_handshake_timeout")]
|
||||||
|
pub handshake_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Idle connection timeout in seconds.
|
||||||
|
#[serde(default = "default_idle_timeout")]
|
||||||
|
pub idle_timeout_secs: u64,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: ProxyAuth,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub transparent: TransparentConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_handshake_timeout() -> u64 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_idle_timeout() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProxyAuth {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TransparentConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_transparent_listen")]
|
||||||
|
pub listen: SocketAddr,
|
||||||
|
#[serde(default = "default_max_connections")]
|
||||||
|
pub max_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransparentConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
listen: default_transparent_listen(),
|
||||||
|
max_connections: default_max_connections(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_transparent_listen() -> SocketAddr {
|
||||||
|
"0.0.0.0:9040".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_connections() -> usize {
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tor / Arti ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TorConfig {
|
||||||
|
/// Arti data directory (directory cache, guard state, keys).
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
|
||||||
|
/// Maximum time to wait for Tor bootstrap in seconds.
|
||||||
|
#[serde(default = "default_bootstrap_timeout")]
|
||||||
|
pub bootstrap_timeout_secs: u64,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: ProfilesConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bootstrap_timeout() -> u64 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProfilesConfig {
|
||||||
|
#[serde(default = "default_onion_profile")]
|
||||||
|
pub default_onion: ConnectionProfile,
|
||||||
|
|
||||||
|
#[serde(default = "default_clearnet_profile")]
|
||||||
|
pub default_clearnet: ConnectionProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfilesConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
default_onion: default_onion_profile(),
|
||||||
|
default_clearnet: default_clearnet_profile(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection profile controls circuit isolation and reuse behavior.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
pub enum ConnectionProfile {
|
||||||
|
/// Full 3-hop circuit, stream isolation per destination.
|
||||||
|
OnionStrict,
|
||||||
|
/// Reduced isolation, shared circuits where possible.
|
||||||
|
OnionFast,
|
||||||
|
/// Standard Tor exit policy for clearnet destinations.
|
||||||
|
ClearnetDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_onion_profile() -> ConnectionProfile {
|
||||||
|
ConnectionProfile::OnionStrict
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_clearnet_profile() -> ConnectionProfile {
|
||||||
|
ConnectionProfile::ClearnetDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Security ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SecurityConfig {
|
||||||
|
/// Security mode: "standard" (always available) or "reduced" (requires
|
||||||
|
/// `reduced-security` cargo feature + `--i-know-what-im-doing` CLI flag).
|
||||||
|
#[serde(default = "default_security_mode")]
|
||||||
|
pub mode: SecurityMode,
|
||||||
|
|
||||||
|
/// CIDR ranges of allowed client IPs.
|
||||||
|
#[serde(default = "default_allowed_clients")]
|
||||||
|
pub allowed_clients: Vec<IpNet>,
|
||||||
|
|
||||||
|
/// Restrict accessible .onion addresses. Empty = allow all.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_onions: Vec<String>,
|
||||||
|
|
||||||
|
/// Allow legacy v2 .onion addresses (16-char, deprecated). Test only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_legacy_onion: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SecurityMode {
|
||||||
|
Standard,
|
||||||
|
Reduced,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_security_mode() -> SecurityMode {
|
||||||
|
SecurityMode::Standard
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_allowed_clients() -> Vec<IpNet> {
|
||||||
|
vec![
|
||||||
|
"10.0.0.0/8".parse().unwrap(),
|
||||||
|
"192.168.0.0/16".parse().unwrap(),
|
||||||
|
"127.0.0.0/8".parse().unwrap(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LoggingConfig {
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub level: String,
|
||||||
|
|
||||||
|
#[serde(default = "default_log_target")]
|
||||||
|
pub target: LogTarget,
|
||||||
|
|
||||||
|
/// Only used when target = "file".
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level: default_log_level(),
|
||||||
|
target: default_log_target(),
|
||||||
|
file_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LogTarget {
|
||||||
|
Stdout,
|
||||||
|
Syslog,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_target() -> LogTarget {
|
||||||
|
LogTarget::Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Load and parse configuration from a TOML file.
|
||||||
|
pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
|
||||||
|
let contents = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read config file {}: {}", path.display(), e))?;
|
||||||
|
let config: Self = toml::from_str(&contents)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to parse config file {}: {}", path.display(), e))?;
|
||||||
|
config.validate()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate configuration consistency.
|
||||||
|
pub fn validate(&self) -> anyhow::Result<()> {
|
||||||
|
// app-embedded mode must bind to localhost
|
||||||
|
if self.mode == DeploymentMode::AppEmbedded && !self.proxy.socks5_listen.ip().is_loopback()
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"app-embedded mode requires socks5_listen on loopback (127.0.0.1), got {}",
|
||||||
|
self.proxy.socks5_listen.ip()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth validation
|
||||||
|
if self.proxy.auth.enabled
|
||||||
|
&& (self.proxy.auth.username.is_empty() || self.proxy.auth.password.is_empty())
|
||||||
|
{
|
||||||
|
anyhow::bail!("proxy auth is enabled but username or password is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging file target needs a path
|
||||||
|
if self.logging.target == LogTarget::File && self.logging.file_path.is_none() {
|
||||||
|
anyhow::bail!("logging target is 'file' but no file_path specified");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduced security mode compile-time gate
|
||||||
|
if self.security.mode == SecurityMode::Reduced {
|
||||||
|
#[cfg(not(feature = "reduced-security"))]
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"security mode 'reduced' requires building with --features reduced-security"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/error.rs
Normal file
53
src/error.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
/// Top-level error type for Onion-Transit.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TransitError {
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
// ── Security / ACL ───────────────────────────────────────────────────
|
||||||
|
#[error("client {client_ip} is not in allowed_clients ACL")]
|
||||||
|
ClientDenied { client_ip: IpAddr },
|
||||||
|
|
||||||
|
#[error("onion address {address} is not in allowed_onions ACL")]
|
||||||
|
OnionDenied { address: String },
|
||||||
|
|
||||||
|
#[error("legacy v2 .onion address rejected (allow_legacy_onion = false): {address}")]
|
||||||
|
LegacyOnionRejected { address: String },
|
||||||
|
|
||||||
|
#[error("reduced security mode requires --i-know-what-im-doing flag")]
|
||||||
|
ReducedSecurityNotAcknowledged,
|
||||||
|
|
||||||
|
#[error("reduced security mode requires cargo feature `reduced-security`")]
|
||||||
|
ReducedSecurityFeatureMissing,
|
||||||
|
|
||||||
|
// ── Tor / Arti ───────────────────────────────────────────────────────
|
||||||
|
#[error("tor bootstrap failed: {0}")]
|
||||||
|
TorBootstrap(String),
|
||||||
|
|
||||||
|
#[error("tor connection to {address} failed: {reason}")]
|
||||||
|
TorConnect { address: String, reason: String },
|
||||||
|
|
||||||
|
// ── Proxy ────────────────────────────────────────────────────────────
|
||||||
|
#[error("SOCKS5 handshake timeout ({timeout_secs}s) for {client_ip}")]
|
||||||
|
HandshakeTimeout { client_ip: IpAddr, timeout_secs: u64 },
|
||||||
|
|
||||||
|
#[error("connection idle timeout ({timeout_secs}s)")]
|
||||||
|
IdleTimeout { timeout_secs: u64 },
|
||||||
|
|
||||||
|
#[error("transparent proxy: max connections ({max}) reached, applying backpressure")]
|
||||||
|
TransparentBackpressure { max: usize },
|
||||||
|
|
||||||
|
// ── I/O ──────────────────────────────────────────────────────────────
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
// ── Catch-all ────────────────────────────────────────────────────────
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience result alias.
|
||||||
|
pub type TransitResult<T> = Result<T, TransitError>;
|
||||||
211
src/main.rs
Normal file
211
src/main.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod proxy;
|
||||||
|
mod router;
|
||||||
|
mod security;
|
||||||
|
mod status;
|
||||||
|
mod tor;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
use crate::config::{AppConfig, LogTarget, SecurityMode};
|
||||||
|
use crate::proxy::socks5::Socks5Server;
|
||||||
|
use crate::tor::engine::TorEngine;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Command::Start { config, i_know_what_im_doing } => {
|
||||||
|
let cfg = match AppConfig::load(&config) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error loading config: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init_logging(&cfg);
|
||||||
|
|
||||||
|
// Security startup validation
|
||||||
|
if let Err(e) = security::validate_startup(&cfg, i_know_what_im_doing) {
|
||||||
|
error!("{e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduced security runtime gate
|
||||||
|
if cfg.security.mode == SecurityMode::Reduced && !i_know_what_im_doing {
|
||||||
|
error!(
|
||||||
|
"security.mode = \"reduced\" requires --i-know-what-im-doing flag. Aborting."
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
|
||||||
|
rt.block_on(async move {
|
||||||
|
run_daemon(cfg).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ConfigCheck {
|
||||||
|
config,
|
||||||
|
test_client_ip,
|
||||||
|
test_onion,
|
||||||
|
} => {
|
||||||
|
let cfg = match AppConfig::load(&config) {
|
||||||
|
Ok(c) => {
|
||||||
|
println!("Config OK: {}", config.display());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Config ERROR: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(" mode: {:?}", cfg.mode);
|
||||||
|
println!(" trust_domain: {:?}", cfg.trust_domain);
|
||||||
|
println!(" security: {:?}", cfg.security.mode);
|
||||||
|
println!(" socks5: {}", cfg.proxy.socks5_listen);
|
||||||
|
println!(" auth: {}", if cfg.proxy.auth.enabled { "enabled" } else { "disabled" });
|
||||||
|
println!(" onion profile: {:?}", cfg.tor.profiles.default_onion);
|
||||||
|
println!(" clearnet profile: {:?}", cfg.tor.profiles.default_clearnet);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
security::dry_run_acl(
|
||||||
|
&cfg,
|
||||||
|
test_client_ip.as_deref(),
|
||||||
|
test_onion.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Status { json, data_dir } => {
|
||||||
|
let dir = data_dir.unwrap_or_else(|| std::path::PathBuf::from("/var/lib/onion-transit/arti"));
|
||||||
|
match status::read_status_file(&dir) {
|
||||||
|
Some(s) => {
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&s).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("Onion-Transit v{}", s.version);
|
||||||
|
println!(" uptime: {}s", s.uptime_secs);
|
||||||
|
println!(" active streams: {}", s.active_streams);
|
||||||
|
println!(" total streams: {}", s.total_streams);
|
||||||
|
println!(" bootstrap attempts: {}", s.bootstrap_attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if json {
|
||||||
|
let status = serde_json::json!({
|
||||||
|
"error": "no running daemon found",
|
||||||
|
"status_file": status::status_file_path(&dir).display().to_string(),
|
||||||
|
});
|
||||||
|
println!("{}", serde_json::to_string_pretty(&status).unwrap());
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"No running daemon found. Status file: {}",
|
||||||
|
status::status_file_path(&dir).display()
|
||||||
|
);
|
||||||
|
eprintln!("Is onion-transit running with data_dir = {}?", dir.display());
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize tracing based on config.
|
||||||
|
fn init_logging(config: &AppConfig) {
|
||||||
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new(&config.logging.level));
|
||||||
|
|
||||||
|
match config.logging.target {
|
||||||
|
LogTarget::Stdout => {
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
LogTarget::File => {
|
||||||
|
if let Some(ref path) = config.logging.file_path {
|
||||||
|
let dir = path.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or(std::ffi::OsStr::new("onion-transit.log"));
|
||||||
|
let file_appender = tracing_appender::rolling::never(dir, filename);
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_writer(file_appender)
|
||||||
|
.with_ansi(false)
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
// Fallback to stdout if no file path configured
|
||||||
|
fmt().with_env_filter(filter).init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogTarget::Syslog => {
|
||||||
|
// Syslog: use stdout with JSON format as a reasonable approximation
|
||||||
|
// that integrates well with journald.
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.json()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the main daemon: bootstrap Tor, start SOCKS5 proxy, handle signals.
|
||||||
|
async fn run_daemon(config: AppConfig) {
|
||||||
|
let config = Arc::new(config);
|
||||||
|
|
||||||
|
// Bootstrap Arti
|
||||||
|
info!("Starting Onion-Transit daemon...");
|
||||||
|
let engine = match TorEngine::bootstrap(&config.tor).await {
|
||||||
|
Ok(e) => Arc::new(e),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to bootstrap Tor: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
active_streams = engine.metrics.active_streams.load(Ordering::Relaxed),
|
||||||
|
total_streams = engine.metrics.total_streams.load(Ordering::Relaxed),
|
||||||
|
"Tor engine ready"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start status writer (writes status file every 5 seconds)
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
let status_metrics = Arc::clone(&engine.metrics);
|
||||||
|
let status_data_dir = config.tor.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
status::status_writer_task(status_metrics, status_data_dir, start_time, 5).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start SOCKS5 proxy
|
||||||
|
let socks_server = Socks5Server::new(Arc::clone(&config), Arc::clone(&engine));
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = socks_server.run() => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
error!("SOCKS5 server error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
info!("Received Ctrl+C, shutting down...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
total_streams = engine.metrics.total_streams.load(Ordering::Relaxed),
|
||||||
|
"Onion-Transit daemon stopped."
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/proxy/mod.rs
Normal file
4
src/proxy/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod socks5;
|
||||||
|
|
||||||
|
#[cfg(feature = "tproxy")]
|
||||||
|
pub mod transparent;
|
||||||
212
src/proxy/socks5.rs
Normal file
212
src/proxy/socks5.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fast_socks5::server::Socks5ServerProtocol;
|
||||||
|
use fast_socks5::Socks5Command;
|
||||||
|
use fast_socks5::util::target_addr::TargetAddr;
|
||||||
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::config::{AppConfig, DeploymentMode};
|
||||||
|
use crate::error::TransitError;
|
||||||
|
use crate::router::classifier::{self, Destination};
|
||||||
|
use crate::security;
|
||||||
|
use crate::tor::engine::TorEngine;
|
||||||
|
|
||||||
|
/// Shared state passed to each connection handler.
|
||||||
|
pub struct Socks5Server {
|
||||||
|
config: Arc<AppConfig>,
|
||||||
|
engine: Arc<TorEngine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socks5Server {
|
||||||
|
pub fn new(config: Arc<AppConfig>, engine: Arc<TorEngine>) -> Self {
|
||||||
|
Self { config, engine }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind and run the SOCKS5 proxy. Runs until cancelled.
|
||||||
|
pub async fn run(&self) -> anyhow::Result<()> {
|
||||||
|
let listen_addr = if self.config.mode == DeploymentMode::AppEmbedded {
|
||||||
|
// Force localhost in app-embedded mode
|
||||||
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.config.proxy.socks5_listen.port())
|
||||||
|
} else {
|
||||||
|
self.config.proxy.socks5_listen
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(listen_addr).await?;
|
||||||
|
info!(addr = %listen_addr, "SOCKS5 proxy listening");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer_addr) = listener.accept().await?;
|
||||||
|
debug!(peer = %peer_addr, "accepted connection");
|
||||||
|
|
||||||
|
// ACL check
|
||||||
|
if let Err(e) = security::check_client_acl(
|
||||||
|
peer_addr.ip(),
|
||||||
|
&self.config.security.allowed_clients,
|
||||||
|
) {
|
||||||
|
warn!(peer = %peer_addr, "client denied by ACL: {e}");
|
||||||
|
drop(stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Arc::clone(&self.config);
|
||||||
|
let engine = Arc::clone(&self.engine);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_connection(stream, peer_addr, config, engine).await {
|
||||||
|
warn!(peer = %peer_addr, "connection error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single SOCKS5 client connection.
|
||||||
|
async fn handle_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
config: Arc<AppConfig>,
|
||||||
|
engine: Arc<TorEngine>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let handshake_timeout = Duration::from_secs(config.proxy.handshake_timeout_secs);
|
||||||
|
let idle_timeout = Duration::from_secs(config.proxy.idle_timeout_secs);
|
||||||
|
|
||||||
|
// Phase 1: SOCKS5 handshake with timeout
|
||||||
|
let (inner, target) = timeout(handshake_timeout, async {
|
||||||
|
socks5_handshake(stream, &config).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| TransitError::HandshakeTimeout {
|
||||||
|
client_ip: peer_addr.ip(),
|
||||||
|
timeout_secs: config.proxy.handshake_timeout_secs,
|
||||||
|
})??;
|
||||||
|
|
||||||
|
// Phase 2: Classify and connect
|
||||||
|
let (host, port) = target_to_host_port(&target);
|
||||||
|
debug!(peer = %peer_addr, host = %host, port, "routing request");
|
||||||
|
|
||||||
|
let dest = classifier::classify(&host, port, &config.security)?;
|
||||||
|
|
||||||
|
match dest {
|
||||||
|
Destination::Onion { ref host, port } => {
|
||||||
|
let profile = &config.tor.profiles.default_onion;
|
||||||
|
let mut tor_stream = engine.connect_onion(host, port, profile).await?;
|
||||||
|
|
||||||
|
debug!(peer = %peer_addr, host, port, "piping to .onion");
|
||||||
|
let result = timeout(
|
||||||
|
idle_timeout,
|
||||||
|
tokio::io::copy_bidirectional(&mut tokio::io::BufReader::new(inner), &mut tor_stream),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
engine.stream_closed();
|
||||||
|
handle_pipe_result(result, &peer_addr, host);
|
||||||
|
}
|
||||||
|
Destination::Clearnet { ref host, port } => {
|
||||||
|
let profile = &config.tor.profiles.default_clearnet;
|
||||||
|
let mut tor_stream = engine.connect_clearnet(host, port, profile).await?;
|
||||||
|
|
||||||
|
debug!(peer = %peer_addr, host, port, "piping to clearnet via Tor");
|
||||||
|
let result = timeout(
|
||||||
|
idle_timeout,
|
||||||
|
tokio::io::copy_bidirectional(&mut tokio::io::BufReader::new(inner), &mut tor_stream),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
engine.stream_closed();
|
||||||
|
handle_pipe_result(result, &peer_addr, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the SOCKS5 handshake: auth negotiation + command read.
|
||||||
|
/// Returns the unwrapped client stream and the target address.
|
||||||
|
async fn socks5_handshake(
|
||||||
|
stream: TcpStream,
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> anyhow::Result<(TcpStream, TargetAddr)> {
|
||||||
|
if config.proxy.auth.enabled {
|
||||||
|
let expected_user = config.proxy.auth.username.clone();
|
||||||
|
let expected_pass = config.proxy.auth.password.clone();
|
||||||
|
|
||||||
|
let (proto, _check_result) = Socks5ServerProtocol::accept_password_auth(
|
||||||
|
stream,
|
||||||
|
move |user, pass| user == expected_user && pass == expected_pass,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SOCKS5 auth failed: {e}"))?;
|
||||||
|
|
||||||
|
read_and_reply(proto).await
|
||||||
|
} else {
|
||||||
|
let proto = Socks5ServerProtocol::accept_no_auth(stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SOCKS5 handshake failed: {e}"))?;
|
||||||
|
|
||||||
|
read_and_reply(proto).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the SOCKS5 command, validate it's a CONNECT, and reply success.
|
||||||
|
async fn read_and_reply<T: AsyncRead + AsyncWrite + Unpin>(
|
||||||
|
proto: Socks5ServerProtocol<T, fast_socks5::server::states::Authenticated>,
|
||||||
|
) -> anyhow::Result<(T, TargetAddr)> {
|
||||||
|
let (proto, cmd, target_addr) = proto
|
||||||
|
.read_command()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SOCKS5 command read failed: {e}"))?;
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
Socks5Command::TCPConnect => {}
|
||||||
|
other => {
|
||||||
|
let _ = proto
|
||||||
|
.reply_error(&fast_socks5::ReplyError::CommandNotSupported)
|
||||||
|
.await;
|
||||||
|
anyhow::bail!("unsupported SOCKS5 command: {:?}", other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply success — we handle the connection ourselves via Arti.
|
||||||
|
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0);
|
||||||
|
let inner = proto
|
||||||
|
.reply_success(bind_addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("SOCKS5 reply failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok((inner, target_addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract host and port from a fast-socks5 TargetAddr.
|
||||||
|
fn target_to_host_port(target: &TargetAddr) -> (String, u16) {
|
||||||
|
match target {
|
||||||
|
TargetAddr::Ip(addr) => (addr.ip().to_string(), addr.port()),
|
||||||
|
TargetAddr::Domain(host, port) => (host.clone(), *port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log the result of a bidirectional pipe.
|
||||||
|
fn handle_pipe_result(
|
||||||
|
result: Result<Result<(u64, u64), std::io::Error>, tokio::time::error::Elapsed>,
|
||||||
|
peer_addr: &SocketAddr,
|
||||||
|
host: &str,
|
||||||
|
) {
|
||||||
|
match result {
|
||||||
|
Ok(Ok((tx, rx))) => {
|
||||||
|
debug!(
|
||||||
|
peer = %peer_addr, host, tx_bytes = tx, rx_bytes = rx,
|
||||||
|
"connection closed normally"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!(peer = %peer_addr, host, "pipe I/O error: {e}");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!(peer = %peer_addr, host, "connection idle timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/proxy/transparent.rs
Normal file
228
src/proxy/transparent.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! Transparent proxy for Onion-Transit.
|
||||||
|
//!
|
||||||
|
//! Requires the `tproxy` cargo feature and Linux iptables/nftables REDIRECT rule.
|
||||||
|
//!
|
||||||
|
//! # How it works
|
||||||
|
//!
|
||||||
|
//! 1. An iptables/nftables REDIRECT rule sends traffic destined for .onion
|
||||||
|
//! (or all traffic) to the transparent proxy port.
|
||||||
|
//! 2. We recover the original destination via `getsockopt(SO_ORIGINAL_DST)`.
|
||||||
|
//! 3. The destination is classified and routed through Arti.
|
||||||
|
//!
|
||||||
|
//! # Limitations
|
||||||
|
//!
|
||||||
|
//! - Linux only (requires `SO_ORIGINAL_DST` / `IP6T_SO_ORIGINAL_DST`).
|
||||||
|
//! - Only works with iptables/nftables DNAT/REDIRECT. Does NOT support TPROXY,
|
||||||
|
//! macOS pf, or other interception methods.
|
||||||
|
//! - Requires elevated privileges (CAP_NET_ADMIN or root).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::router::classifier::{self, Destination};
|
||||||
|
use crate::security;
|
||||||
|
use crate::tor::engine::TorEngine;
|
||||||
|
|
||||||
|
/// Transparent proxy server with connection-count backpressure.
|
||||||
|
pub struct TransparentProxy {
|
||||||
|
config: Arc<AppConfig>,
|
||||||
|
engine: Arc<TorEngine>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransparentProxy {
|
||||||
|
pub fn new(config: Arc<AppConfig>, engine: Arc<TorEngine>) -> Self {
|
||||||
|
let max_conn = config.proxy.transparent.max_connections;
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
engine,
|
||||||
|
semaphore: Arc::new(Semaphore::new(max_conn)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind and run the transparent proxy. Runs until cancelled.
|
||||||
|
pub async fn run(&self) -> anyhow::Result<()> {
|
||||||
|
if !self.config.proxy.transparent.enabled {
|
||||||
|
info!("Transparent proxy is disabled in config.");
|
||||||
|
// Sleep forever — the caller will cancel us via select!
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listen_addr = self.config.proxy.transparent.listen;
|
||||||
|
let listener = TcpListener::bind(listen_addr).await?;
|
||||||
|
info!(addr = %listen_addr, "Transparent proxy listening");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer_addr) = listener.accept().await?;
|
||||||
|
|
||||||
|
// ACL check
|
||||||
|
if let Err(e) = security::check_client_acl(
|
||||||
|
peer_addr.ip(),
|
||||||
|
&self.config.security.allowed_clients,
|
||||||
|
) {
|
||||||
|
warn!(peer = %peer_addr, "transparent proxy: client denied by ACL: {e}");
|
||||||
|
drop(stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backpressure: if we've hit the connection limit, log and wait
|
||||||
|
let permit = match self.semaphore.clone().try_acquire_owned() {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
peer = %peer_addr,
|
||||||
|
max = self.config.proxy.transparent.max_connections,
|
||||||
|
"transparent proxy: max connections reached, backpressure active"
|
||||||
|
);
|
||||||
|
// Block until a permit is available (instead of dropping)
|
||||||
|
match self.semaphore.clone().acquire_owned().await {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
|
error!("semaphore closed unexpectedly");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = Arc::clone(&self.config);
|
||||||
|
let engine = Arc::clone(&self.engine);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = permit; // Held until task ends
|
||||||
|
if let Err(e) = handle_transparent_connection(stream, peer_addr, config, engine).await {
|
||||||
|
debug!(peer = %peer_addr, "transparent proxy error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single transparent proxy connection.
|
||||||
|
async fn handle_transparent_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
peer_addr: std::net::SocketAddr,
|
||||||
|
config: Arc<AppConfig>,
|
||||||
|
engine: Arc<TorEngine>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
// Recover original destination via SO_ORIGINAL_DST
|
||||||
|
let original_dst = get_original_dst(&stream)?;
|
||||||
|
let host = original_dst.ip().to_string();
|
||||||
|
let port = original_dst.port();
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
peer = %peer_addr,
|
||||||
|
original_dst = %original_dst,
|
||||||
|
"transparent proxy: recovered original destination"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dest = classifier::classify(&host, port, &config.security)?;
|
||||||
|
let idle_timeout = std::time::Duration::from_secs(config.proxy.idle_timeout_secs);
|
||||||
|
|
||||||
|
match dest {
|
||||||
|
Destination::Onion { ref host, port } => {
|
||||||
|
let profile = &config.tor.profiles.default_onion;
|
||||||
|
let mut tor_stream = engine.connect_onion(host, port, profile).await?;
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
idle_timeout,
|
||||||
|
tokio::io::copy_bidirectional(&mut stream, &mut tor_stream),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
engine.stream_closed();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok((tx, rx))) => debug!(peer = %peer_addr, tx, rx, "transparent: closed"),
|
||||||
|
Ok(Err(e)) => debug!(peer = %peer_addr, "transparent: I/O error: {e}"),
|
||||||
|
Err(_) => debug!(peer = %peer_addr, "transparent: idle timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Destination::Clearnet { ref host, port } => {
|
||||||
|
let profile = &config.tor.profiles.default_clearnet;
|
||||||
|
let mut tor_stream = engine.connect_clearnet(host, port, profile).await?;
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
idle_timeout,
|
||||||
|
tokio::io::copy_bidirectional(&mut stream, &mut tor_stream),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
engine.stream_closed();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok((tx, rx))) => debug!(peer = %peer_addr, tx, rx, "transparent: closed"),
|
||||||
|
Ok(Err(e)) => debug!(peer = %peer_addr, "transparent: I/O error: {e}"),
|
||||||
|
Err(_) => debug!(peer = %peer_addr, "transparent: idle timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the original destination address using SO_ORIGINAL_DST.
|
||||||
|
///
|
||||||
|
/// This only works on Linux with iptables REDIRECT / nftables DNAT rules.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn get_original_dst(stream: &TcpStream) -> anyhow::Result<std::net::SocketAddr> {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
let fd = stream.as_raw_fd();
|
||||||
|
|
||||||
|
// Try IPv4 first (SO_ORIGINAL_DST = 80)
|
||||||
|
let addr = unsafe {
|
||||||
|
let mut addr: libc::sockaddr_in = std::mem::zeroed();
|
||||||
|
let mut len: libc::socklen_t = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
|
||||||
|
let ret = libc::getsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_IP,
|
||||||
|
80, // SO_ORIGINAL_DST
|
||||||
|
&mut addr as *mut _ as *mut libc::c_void,
|
||||||
|
&mut len,
|
||||||
|
);
|
||||||
|
if ret == 0 {
|
||||||
|
let ip = std::net::Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr));
|
||||||
|
let port = u16::from_be(addr.sin_port);
|
||||||
|
return Ok(std::net::SocketAddr::new(std::net::IpAddr::V4(ip), port));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try IPv6 (IP6T_SO_ORIGINAL_DST = 80)
|
||||||
|
unsafe {
|
||||||
|
let mut addr: libc::sockaddr_in6 = std::mem::zeroed();
|
||||||
|
let mut len: libc::socklen_t =
|
||||||
|
std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t;
|
||||||
|
let ret = libc::getsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_IPV6,
|
||||||
|
80, // IP6T_SO_ORIGINAL_DST
|
||||||
|
&mut addr as *mut _ as *mut libc::c_void,
|
||||||
|
&mut len,
|
||||||
|
);
|
||||||
|
if ret == 0 {
|
||||||
|
let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr);
|
||||||
|
let port = u16::from_be(addr.sin6_port);
|
||||||
|
return Ok(std::net::SocketAddr::new(std::net::IpAddr::V6(ip), port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"SO_ORIGINAL_DST failed. Ensure iptables/nftables REDIRECT rule is active. \
|
||||||
|
This only works on Linux with DNAT/REDIRECT."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-Linux stub — transparent proxy is not supported.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn get_original_dst(_stream: &TcpStream) -> anyhow::Result<std::net::SocketAddr> {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Transparent proxy (SO_ORIGINAL_DST) is only supported on Linux. \
|
||||||
|
Use the SOCKS5 proxy mode on this platform."
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/router/classifier.rs
Normal file
116
src/router/classifier.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use crate::config::SecurityConfig;
|
||||||
|
use crate::error::TransitResult;
|
||||||
|
use crate::security;
|
||||||
|
|
||||||
|
/// Classified destination for an incoming connection request.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Destination {
|
||||||
|
/// A Tor hidden service (.onion) address.
|
||||||
|
Onion {
|
||||||
|
/// The full hostname (e.g., "xyz...abc.onion").
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
/// A clearnet (non-.onion) address routed through Tor exit.
|
||||||
|
Clearnet {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a target host:port into a [`Destination`], applying security checks.
|
||||||
|
///
|
||||||
|
/// Performs:
|
||||||
|
/// 1. `.onion` suffix detection
|
||||||
|
/// 2. Legacy v2 onion check (`allow_legacy_onion`)
|
||||||
|
/// 3. Onion address ACL check (`allowed_onions`)
|
||||||
|
pub fn classify(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
security_config: &SecurityConfig,
|
||||||
|
) -> TransitResult<Destination> {
|
||||||
|
if is_onion(host) {
|
||||||
|
// Reject legacy v2 addresses unless explicitly allowed
|
||||||
|
security::check_legacy_onion(host, security_config.allow_legacy_onion)?;
|
||||||
|
|
||||||
|
// Check onion ACL
|
||||||
|
security::check_onion_acl(host, &security_config.allowed_onions)?;
|
||||||
|
|
||||||
|
Ok(Destination::Onion {
|
||||||
|
host: host.to_string(),
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(Destination::Clearnet {
|
||||||
|
host: host.to_string(),
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the host is a `.onion` address.
|
||||||
|
fn is_onion(host: &str) -> bool {
|
||||||
|
let lower = host.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".onion") || lower == "onion"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::SecurityConfig;
|
||||||
|
|
||||||
|
fn default_security() -> SecurityConfig {
|
||||||
|
SecurityConfig {
|
||||||
|
mode: crate::config::SecurityMode::Standard,
|
||||||
|
allowed_clients: vec![],
|
||||||
|
allowed_onions: vec![],
|
||||||
|
allow_legacy_onion: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_onion_v3() {
|
||||||
|
let host = format!("{}.onion", "a".repeat(56));
|
||||||
|
let result = classify(&host, 80, &default_security()).unwrap();
|
||||||
|
assert!(matches!(result, Destination::Onion { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_clearnet() {
|
||||||
|
let result = classify("example.com", 443, &default_security()).unwrap();
|
||||||
|
assert!(matches!(result, Destination::Clearnet { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_rejects_v2_by_default() {
|
||||||
|
let host = "abcdefghijklmnop.onion";
|
||||||
|
let result = classify(host, 80, &default_security());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_allows_v2_when_flag_set() {
|
||||||
|
let mut sec = default_security();
|
||||||
|
sec.allow_legacy_onion = true;
|
||||||
|
let host = "abcdefghijklmnop.onion";
|
||||||
|
let result = classify(host, 80, &sec).unwrap();
|
||||||
|
assert!(matches!(result, Destination::Onion { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_respects_onion_acl() {
|
||||||
|
let sec = SecurityConfig {
|
||||||
|
allowed_onions: vec!["allowed.onion".to_string()],
|
||||||
|
..default_security()
|
||||||
|
};
|
||||||
|
assert!(classify("allowed.onion", 80, &sec).is_ok());
|
||||||
|
assert!(classify("forbidden.onion", 80, &sec).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_case_insensitive_onion() {
|
||||||
|
let host = format!("{}.ONION", "a".repeat(56));
|
||||||
|
let result = classify(&host, 80, &default_security()).unwrap();
|
||||||
|
assert!(matches!(result, Destination::Onion { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/router/mod.rs
Normal file
1
src/router/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod classifier;
|
||||||
211
src/security.rs
Normal file
211
src/security.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::{AppConfig, DeploymentMode, SecurityMode, TrustDomain};
|
||||||
|
use crate::error::{TransitError, TransitResult};
|
||||||
|
|
||||||
|
/// Validate security settings at startup. Returns an error if the configuration
|
||||||
|
/// is incompatible with the current build / CLI flags.
|
||||||
|
pub fn validate_startup(config: &AppConfig, _i_know_what_im_doing: bool) -> TransitResult<()> {
|
||||||
|
// ── Unconditional trust warning (every single startup) ──────────────
|
||||||
|
warn!(
|
||||||
|
"Onion-Transit centralizes Tor traffic and can see destination \
|
||||||
|
.onion names; anonymity assumptions differ from Tor Browser."
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Reduced security gate ───────────────────────────────────────────
|
||||||
|
if config.security.mode == SecurityMode::Reduced {
|
||||||
|
#[cfg(not(feature = "reduced-security"))]
|
||||||
|
{
|
||||||
|
return Err(TransitError::ReducedSecurityFeatureMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "reduced-security")]
|
||||||
|
{
|
||||||
|
if !_i_know_what_im_doing {
|
||||||
|
return Err(TransitError::ReducedSecurityNotAcknowledged);
|
||||||
|
}
|
||||||
|
warn!("*** REDUCED SECURITY MODE ACTIVE ***");
|
||||||
|
warn!(
|
||||||
|
"Circuit isolation may be weakened. Do NOT use this mode \
|
||||||
|
in adversarial threat models."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trust domain warnings ───────────────────────────────────────────
|
||||||
|
match config.trust_domain {
|
||||||
|
TrustDomain::Personal => {
|
||||||
|
warn!(
|
||||||
|
"trust_domain = personal: binding should be localhost-only. \
|
||||||
|
Verify allowed_clients is restricted."
|
||||||
|
);
|
||||||
|
if config.mode == DeploymentMode::Gateway
|
||||||
|
&& !config.proxy.socks5_listen.ip().is_loopback()
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Gateway mode with trust_domain=personal is unusual. \
|
||||||
|
Consider mode=app-embedded or restricting socks5_listen to 127.0.0.1."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrustDomain::Team => {
|
||||||
|
if !config.proxy.auth.enabled {
|
||||||
|
warn!(
|
||||||
|
"trust_domain = team but proxy auth is disabled. \
|
||||||
|
Consider enabling auth for team environments."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrustDomain::Lab => {
|
||||||
|
info!("trust_domain = lab: relaxed defaults in effect.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deployment mode hints ───────────────────────────────────────────
|
||||||
|
if config.mode == DeploymentMode::AppEmbedded {
|
||||||
|
info!("Running in app-embedded mode (localhost-only, single application sidecar).");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime ACL checks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Check if a client IP is allowed by the ACL.
|
||||||
|
pub fn check_client_acl(client_ip: IpAddr, allowed: &[IpNet]) -> TransitResult<()> {
|
||||||
|
if allowed.is_empty() {
|
||||||
|
// Empty ACL = allow all (explicit opt-in in config).
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for net in allowed {
|
||||||
|
if net.contains(&client_ip) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(TransitError::ClientDenied { client_ip })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a .onion address is allowed by the ACL.
|
||||||
|
/// An empty `allowed_onions` list means all are allowed.
|
||||||
|
pub fn check_onion_acl(address: &str, allowed: &[String]) -> TransitResult<()> {
|
||||||
|
if allowed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Normalize: strip trailing `.onion` for comparison if user mixed formats.
|
||||||
|
let normalized = address.trim_end_matches(".onion");
|
||||||
|
for entry in allowed {
|
||||||
|
let entry_normalized = entry.trim_end_matches(".onion");
|
||||||
|
if normalized == entry_normalized {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(TransitError::OnionDenied {
|
||||||
|
address: address.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a legacy v2 .onion address should be rejected.
|
||||||
|
/// v2 addresses are 16 characters (base32), v3 are 56 characters.
|
||||||
|
pub fn check_legacy_onion(address: &str, allow_legacy: bool) -> TransitResult<()> {
|
||||||
|
let host = address.trim_end_matches(".onion");
|
||||||
|
// v2 onion addresses are exactly 16 chars of base32
|
||||||
|
if host.len() == 16 && !allow_legacy {
|
||||||
|
return Err(TransitError::LegacyOnionRejected {
|
||||||
|
address: address.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dry-run for config-check ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Simulate ACL checks and print results. Used by `config-check` subcommand.
|
||||||
|
pub fn dry_run_acl(config: &AppConfig, test_ip: Option<&str>, test_onion: Option<&str>) {
|
||||||
|
println!("=== ACL Dry Run ===\n");
|
||||||
|
|
||||||
|
if let Some(ip_str) = test_ip {
|
||||||
|
match ip_str.parse::<IpAddr>() {
|
||||||
|
Ok(ip) => match check_client_acl(ip, &config.security.allowed_clients) {
|
||||||
|
Ok(()) => println!("[ALLOW] Client IP {} is permitted.", ip),
|
||||||
|
Err(e) => println!("[DENY] Client IP {} -> {}", ip, e),
|
||||||
|
},
|
||||||
|
Err(e) => println!("[ERROR] Invalid IP '{}': {}", ip_str, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(onion) = test_onion {
|
||||||
|
// Legacy check
|
||||||
|
match check_legacy_onion(onion, config.security.allow_legacy_onion) {
|
||||||
|
Ok(()) => println!("[OK] '{}' passes legacy onion check.", onion),
|
||||||
|
Err(e) => println!("[DENY] '{}' -> {}", onion, e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACL check
|
||||||
|
match check_onion_acl(onion, &config.security.allowed_onions) {
|
||||||
|
Ok(()) => println!("[ALLOW] Onion '{}' is permitted.", onion),
|
||||||
|
Err(e) => println!("[DENY] Onion '{}' -> {}", onion, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if test_ip.is_none() && test_onion.is_none() {
|
||||||
|
println!("No --test-client-ip or --test-onion provided.");
|
||||||
|
println!("Provide them to simulate ACL decisions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== End Dry Run ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_acl_allows_matching_ip() {
|
||||||
|
let nets: Vec<IpNet> = vec!["10.0.0.0/8".parse().unwrap()];
|
||||||
|
assert!(check_client_acl("10.1.2.3".parse().unwrap(), &nets).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_acl_denies_non_matching_ip() {
|
||||||
|
let nets: Vec<IpNet> = vec!["10.0.0.0/8".parse().unwrap()];
|
||||||
|
assert!(check_client_acl("192.168.1.1".parse().unwrap(), &nets).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_acl_empty_allows_all() {
|
||||||
|
let nets: Vec<IpNet> = vec![];
|
||||||
|
assert!(check_client_acl("8.8.8.8".parse().unwrap(), &nets).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_onion_acl_allows_when_empty() {
|
||||||
|
assert!(check_onion_acl("abc123.onion", &[]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_onion_acl_denies_unlisted() {
|
||||||
|
let allowed = vec!["allowed.onion".to_string()];
|
||||||
|
assert!(check_onion_acl("forbidden.onion", &allowed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_onion_rejected() {
|
||||||
|
// 16-char host = v2
|
||||||
|
assert!(check_legacy_onion("abcdefghijklmnop.onion", false).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_onion_allowed_when_flag_set() {
|
||||||
|
assert!(check_legacy_onion("abcdefghijklmnop.onion", true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_v3_onion_always_allowed() {
|
||||||
|
// 56-char host = v3
|
||||||
|
let v3 = format!("{}.onion", "a".repeat(56));
|
||||||
|
assert!(check_legacy_onion(&v3, false).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/status.rs
Normal file
74
src/status.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! Runtime status reporting for the daemon.
|
||||||
|
//!
|
||||||
|
//! The running daemon writes a JSON status file periodically.
|
||||||
|
//! The `onion-transit status` command reads and displays it.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::time::{interval, Duration};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::tor::engine::EngineMetrics;
|
||||||
|
|
||||||
|
/// Status snapshot written to disk / returned by `--status --json`.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DaemonStatus {
|
||||||
|
pub active_streams: u64,
|
||||||
|
pub total_streams: u64,
|
||||||
|
pub bootstrap_attempts: u64,
|
||||||
|
pub uptime_secs: u64,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonStatus {
|
||||||
|
pub fn from_metrics(metrics: &EngineMetrics, start_time: std::time::Instant) -> Self {
|
||||||
|
Self {
|
||||||
|
active_streams: metrics.active_streams.load(Ordering::Relaxed),
|
||||||
|
total_streams: metrics.total_streams.load(Ordering::Relaxed),
|
||||||
|
bootstrap_attempts: metrics.bootstrap_attempts.load(Ordering::Relaxed),
|
||||||
|
uptime_secs: start_time.elapsed().as_secs(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status file path (in the Arti data directory).
|
||||||
|
pub fn status_file_path(data_dir: &std::path::Path) -> PathBuf {
|
||||||
|
data_dir.join("onion-transit-status.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background task that writes the status file every `interval_secs` seconds.
|
||||||
|
pub async fn status_writer_task(
|
||||||
|
metrics: Arc<EngineMetrics>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
interval_secs: u64,
|
||||||
|
) {
|
||||||
|
let path = status_file_path(&data_dir);
|
||||||
|
let mut tick = interval(Duration::from_secs(interval_secs));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let status = DaemonStatus::from_metrics(&metrics, start_time);
|
||||||
|
match serde_json::to_string_pretty(&status) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Err(e) = tokio::fs::write(&path, &json).await {
|
||||||
|
debug!("failed to write status file: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("failed to serialize status: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the status file and return parsed status, or None if unavailable.
|
||||||
|
pub fn read_status_file(data_dir: &std::path::Path) -> Option<DaemonStatus> {
|
||||||
|
let path = status_file_path(data_dir);
|
||||||
|
let contents = std::fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&contents).ok()
|
||||||
|
}
|
||||||
176
src/tor/engine.rs
Normal file
176
src/tor/engine.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use arti_client::config::TorClientConfigBuilder;
|
||||||
|
use arti_client::{StreamPrefs, TorClient};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use tor_rtcompat::PreferredRuntime;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::config::{ConnectionProfile, TorConfig};
|
||||||
|
use crate::error::{TransitError, TransitResult};
|
||||||
|
|
||||||
|
/// Runtime metrics exposed for `--status --json`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EngineMetrics {
|
||||||
|
pub active_streams: AtomicU64,
|
||||||
|
pub total_streams: AtomicU64,
|
||||||
|
pub bootstrap_attempts: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineMetrics {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_streams: AtomicU64::new(0),
|
||||||
|
total_streams: AtomicU64::new(0),
|
||||||
|
bootstrap_attempts: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps `arti_client::TorClient` with connection profile support and metrics.
|
||||||
|
pub struct TorEngine {
|
||||||
|
client: TorClient<PreferredRuntime>,
|
||||||
|
pub metrics: Arc<EngineMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorEngine {
|
||||||
|
/// Bootstrap Arti and return a ready [`TorEngine`].
|
||||||
|
///
|
||||||
|
/// Uses Arti's native data directory layout so upstream changes don't break
|
||||||
|
/// cached state.
|
||||||
|
pub async fn bootstrap(tor_config: &TorConfig) -> TransitResult<Self> {
|
||||||
|
let metrics = Arc::new(EngineMetrics::new());
|
||||||
|
metrics.bootstrap_attempts.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
data_dir = %tor_config.data_dir.display(),
|
||||||
|
"bootstrapping Arti Tor client..."
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut builder = TorClientConfigBuilder::from_directories(
|
||||||
|
&tor_config.data_dir,
|
||||||
|
&tor_config.data_dir,
|
||||||
|
);
|
||||||
|
builder.address_filter().allow_onion_addrs(true);
|
||||||
|
let arti_config = builder.build().map_err(|e| {
|
||||||
|
TransitError::TorBootstrap(format!("failed to build Arti config: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let bootstrap_timeout = Duration::from_secs(tor_config.bootstrap_timeout_secs);
|
||||||
|
|
||||||
|
let client = timeout(
|
||||||
|
bootstrap_timeout,
|
||||||
|
TorClient::create_bootstrapped(arti_config),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
TransitError::TorBootstrap(format!(
|
||||||
|
"bootstrap timed out after {}s",
|
||||||
|
tor_config.bootstrap_timeout_secs
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(|e| TransitError::TorBootstrap(format!("Arti bootstrap error: {e}")))?;
|
||||||
|
|
||||||
|
info!("Arti Tor client bootstrapped successfully.");
|
||||||
|
Ok(Self { client, metrics })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a `.onion` hidden service.
|
||||||
|
pub async fn connect_onion(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
profile: &ConnectionProfile,
|
||||||
|
) -> TransitResult<arti_client::DataStream> {
|
||||||
|
let prefs = self.stream_prefs_for(profile);
|
||||||
|
debug!(host, port, ?profile, "connecting to .onion service");
|
||||||
|
|
||||||
|
self.metrics.active_streams.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.metrics.total_streams.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let target = format!("{}:{}", host, port);
|
||||||
|
let stream = self
|
||||||
|
.client
|
||||||
|
.connect_with_prefs(&target, &prefs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
self.metrics.active_streams.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
error!(host, port, %e, "failed to connect to .onion");
|
||||||
|
TransitError::TorConnect {
|
||||||
|
address: target.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(host, port, "connected to .onion service");
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a clearnet destination through Tor exit.
|
||||||
|
pub async fn connect_clearnet(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
profile: &ConnectionProfile,
|
||||||
|
) -> TransitResult<arti_client::DataStream> {
|
||||||
|
let prefs = self.stream_prefs_for(profile);
|
||||||
|
debug!(host, port, ?profile, "connecting to clearnet via Tor");
|
||||||
|
|
||||||
|
self.metrics.active_streams.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.metrics.total_streams.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let target = format!("{}:{}", host, port);
|
||||||
|
let stream = self
|
||||||
|
.client
|
||||||
|
.connect_with_prefs(&target, &prefs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
self.metrics.active_streams.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
error!(host, port, %e, "failed to connect to clearnet");
|
||||||
|
TransitError::TorConnect {
|
||||||
|
address: target.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(host, port, "connected to clearnet via Tor");
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal that a stream has been closed (call after bidirectional copy ends).
|
||||||
|
pub fn stream_closed(&self) {
|
||||||
|
self.metrics.active_streams.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build [`StreamPrefs`] from a [`ConnectionProfile`].
|
||||||
|
fn stream_prefs_for(&self, profile: &ConnectionProfile) -> StreamPrefs {
|
||||||
|
let mut prefs = StreamPrefs::new();
|
||||||
|
match profile {
|
||||||
|
ConnectionProfile::OnionStrict => {
|
||||||
|
// Each destination gets its own circuit (isolation by destination).
|
||||||
|
// This is the default Arti behavior — no special config needed.
|
||||||
|
}
|
||||||
|
ConnectionProfile::OnionFast => {
|
||||||
|
// Allow circuit reuse across different .onion destinations.
|
||||||
|
// Reduces latency at the cost of correlation risk.
|
||||||
|
prefs.set_isolation(IsolationToken::no_isolation());
|
||||||
|
}
|
||||||
|
ConnectionProfile::ClearnetDefault => {
|
||||||
|
// Standard Tor exit behavior — default prefs are fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token for stream isolation control.
|
||||||
|
struct IsolationToken;
|
||||||
|
|
||||||
|
impl IsolationToken {
|
||||||
|
/// Returns a StreamPrefs isolation that allows sharing circuits.
|
||||||
|
fn no_isolation() -> arti_client::IsolationToken {
|
||||||
|
arti_client::IsolationToken::no_isolation()
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/tor/mod.rs
Normal file
1
src/tor/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod engine;
|
||||||
Reference in New Issue
Block a user