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