Initial commit

This commit is contained in:
2026-02-15 08:14:32 +01:00
commit 60cb88f9a6
20 changed files with 7909 additions and 0 deletions

18
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

65
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
pub mod socks5;
#[cfg(feature = "tproxy")]
pub mod transparent;

212
src/proxy/socks5.rs Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
pub mod classifier;

211
src/security.rs Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
pub mod engine;