How webtor-rs Implements Stream Isolation: Architecture and Code Examples

webtor-rs implements stream isolation by grouping HTTP requests into separate Tor circuits based on a configurable StreamIsolationPolicy, using unique isolation keys derived from URLs to bind specific requests to dedicated circuits.

The webtor-rs library provides a Rust-based interface to the Tor network with built-in support for stream isolation—a privacy feature that prevents different websites from sharing the same circuit. This implementation, located primarily in webtor/src/isolation.rs and webtor/src/circuit.rs, allows developers to enforce circuit separation based on domains, subdomains, or full origins, mirroring the first-party isolation model used by the Tor Browser.

Stream Isolation Policies

At the core of the implementation is the StreamIsolationPolicy enum, which defines four distinct strategies for grouping requests. The policy is stored in TorClientOptions and defaults to PerDomain, ensuring that requests to different registrable domains use separate circuits.

The StreamIsolationPolicy Enum

The policy is defined in webtor/src/isolation.rs as a serializable, copyable enum:

/// Stream isolation policy determining how requests are grouped into circuits
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StreamIsolationPolicy {
    /// eTLD+1 (first-party domain) – all sub-domains share a circuit
    PerDomain,
    /// Full hostname – different sub-domains get different circuits
    PerSubdomain,
    /// Full origin (scheme + host + port) – different ports or http/https are separate
    PerOrigin,
    /// No isolation – legacy behaviour, all requests share circuits
    None,
}
  • PerDomain: Treats all subdomains of the same registrable domain (eTLD+1) as a single isolation group. This matches Tor Browser's default behavior where foo.example.com and bar.example.com share a circuit.
  • PerSubdomain: Uses the full hostname, isolating foo.example.com from bar.example.com.
  • PerOrigin: Considers the scheme, host, and port together, meaning https://example.com:443 and http://example.com:8080 receive separate circuits.
  • None: Disables isolation entirely, falling back to legacy circuit pooling.

The default value is PerDomain, configured via TorClientOptions::stream_isolation in webtor/src/config.rs.

Isolation Key Generation

An IsolationKey uniquely identifies the group a request belongs to. It is constructed from a URL according to the selected policy using the IsolationKey::from_url method in webtor/src/isolation.rs.

Generating Isolation Keys from URLs

The key generation logic extracts the relevant components based on the active policy:

pub fn from_url(url: &Url, policy: StreamIsolationPolicy) -> Option<Self> {
    match policy {
        StreamIsolationPolicy::None => None,
        _ => {
            let host = url.host_str().unwrap_or("");
            let port = url.port_or_known_default().unwrap_or(0);
            let key = match policy {
                StreamIsolationPolicy::PerOrigin => format!("{}://{}:{}", url.scheme(), host, port),
                StreamIsolationPolicy::PerSubdomain => host.to_string(),
                StreamIsolationPolicy::PerDomain => extract_domain(host),
                StreamIsolationPolicy::None => unreachable!(),
            };
            Some(IsolationKey(key))
        }
    }
}

For PerDomain isolation, the implementation uses the psl crate to extract the registrable domain (example.com from foo.bar.example.co.uk). The PerSubdomain variant uses the raw hostname, while PerOrigin concatenates the scheme, host, and port into a single identifier.

Circuit Management and Binding

Each Circuit optionally stores an Option<IsolationKey>. When a circuit is created with a specific key, it is bound before being added to the global circuit list to prevent race conditions where two different keys could claim the same unassigned circuit.

Binding Isolation Keys to Circuits

The create_circuit_with_isolation method in webtor/src/circuit.rs handles the atomic binding:

pub async fn create_circuit_with_isolation(
    &self,
    isolation_key: Option<IsolationKey>,
) -> Result<Arc<RwLock<Circuit>>> {
    // ... circuit creation logic ...
    let mut circuit = Circuit::new(circuit_id.clone(), Some(Arc::new(tunnel)));
    
    // Bind isolation key BEFORE adding to list to prevent races
    if let Some(key) = isolation_key {
        circuit.set_isolation_key(key);
    }
    
    let circuit_arc = Arc::new(RwLock::new(circuit));
    circuits.push(circuit_arc.clone());
    Ok(circuit_arc)
}

This ensures that once a circuit appears in the shared pool, it is already associated with its isolation group.

Circuit Selection Logic

The CircuitManager::get_circuit_for_isolation_key method implements the four-tier selection strategy found in webtor/src/circuit.rs:

  1. Reuse: Find an existing ready circuit already bound to the requested key.
  2. Bind: Claim an unassigned ready circuit and bind it to the key (first-come-first-serve).
  3. Limit: Enforce MAX_CIRCUITS_PER_ISOLATION_KEY (currently set to 1). If the limit is reached, reuse an existing circuit for that key.
  4. Create: Spawn a new circuit already bound to the isolation key.

If the isolation policy is None, the method returns None and the HTTP client falls back to general circuit pooling.

HTTP Client Integration

The TorHttpClient bridges the isolation layer to actual HTTP requests. Before executing a request, it derives the isolation key from the target URL and requests a matching circuit from the manager.

In webtor/src/http.rs, the client performs the following:

let isolation_key = IsolationKey::from_url(&url, self.isolation_policy);
let circuit = circuit_manager
    .get_circuit_for_isolation_key(isolation_key)
    .await?;

If isolation_policy is None, isolation_key becomes None, triggering the legacy "any available circuit" behavior. Otherwise, the manager guarantees the returned circuit belongs exclusively to the key's isolation group.

Configuring Stream Isolation

Users configure the isolation behavior through TorClientOptions during client initialization. The policy can be customized via the builder pattern:

pub struct TorClientOptions {
    // ... other fields ...
    /// Stream isolation policy for domain-based circuit separation
    #[serde(default)]
    pub stream_isolation: StreamIsolationPolicy,
}

The with_stream_isolation method allows runtime configuration, while deserialization defaults ensure backward compatibility.

Practical Usage Examples

Example 1: Per-Domain Isolation (Default)

Create a client that isolates circuits by registrable domain, matching Tor Browser behavior:

use webtor::client::TorClient;
use webtor::config::TorClientOptions;
use webtor::isolation::StreamIsolationPolicy;

let opts = TorClientOptions::default()
    .with_stream_isolation(StreamIsolationPolicy::PerDomain);

let client = TorClient::new(opts).await?;

// Both requests share the same circuit bound to "example.com"
let _ = client.http().get("https://example.com/").await?;
let _ = client.http().get("https://sub.example.com/").await?;

Example 2: Per-Subdomain Isolation

Enforce stricter isolation where different subdomains receive separate circuits:

let opts = TorClientOptions::default()
    .with_stream_isolation(StreamIsolationPolicy::PerSubdomain);
let client = TorClient::new(opts).await?;

// Different subdomains → different circuits
let _ = client.http().get("https://foo.example.com/").await?;
let _ = client.http().get("https://bar.example.com/").await?;

Example 3: Disable Isolation

Revert to legacy behavior where all requests share a global circuit pool:

let opts = TorClientOptions::default()
    .with_stream_isolation(StreamIsolationPolicy::None);
let client = TorClient::new(opts).await?;

// Circuit selection ignores domains
let _ = client.http().get("https://site-a.com/").await?;
let _ = client.http().get("https://site-b.org/").await?;

Example 4: Manual Isolation Key Generation

Directly inspect how URLs map to isolation keys:

use webtor::isolation::{IsolationKey, StreamIsolationPolicy};
use url::Url;

let url = Url::parse("https://foo.bar.example.co.uk:8443/path")?;
let key = IsolationKey::from_url(&url, StreamIsolationPolicy::PerDomain);
assert_eq!(key.unwrap().to_string(), "example.co.uk");

Summary

  • Stream isolation in webtor-rs is implemented through the StreamIsolationPolicy enum with four variants: PerDomain, PerSubdomain, PerOrigin, and None.
  • IsolationKey structs are generated from URLs via IsolationKey::from_url in webtor/src/isolation.rs, using the psl crate for domain extraction.
  • Each Circuit optionally stores an isolation key set atomically during creation in webtor/src/circuit.rs to prevent race conditions.
  • The CircuitManager selects circuits using a four-step priority system: reuse existing, bind unassigned, enforce per-key limits (MAX_CIRCUITS_PER_ISOLATION_KEY = 1), or create new.
  • The HTTP client in webtor/src/http.rs automatically derives isolation keys from request URLs and requests matching circuits from the manager.
  • Configuration occurs through TorClientOptions::stream_isolation in webtor/src/config.rs, defaulting to PerDomain isolation.

Frequently Asked Questions

What is the default stream isolation policy in webtor-rs?

The default policy is PerDomain, which corresponds to first-party isolation. This means all subdomains of the same registrable domain (eTLD+1) share a single Tor circuit, preventing third-party tracking while optimizing connection reuse.

How does webtor-rs prevent race conditions when binding circuits?

When create_circuit_with_isolation creates a new circuit, it calls circuit.set_isolation_key(key) before wrapping the circuit in an Arc<RwLock> and pushing it to the global circuit list. This ensures the key is assigned atomically, preventing two different isolation groups from concurrently claiming the same unassigned circuit.

Can I use different isolation policies for different requests in the same client?

No, the StreamIsolationPolicy is set once during TorClient initialization via TorClientOptions. All HTTP requests made through that client instance use the same policy. To use different policies simultaneously, you must create separate TorClient instances with different configurations.

What happens when the per-key circuit limit is reached?

The implementation enforces MAX_CIRCUITS_PER_ISOLATION_KEY (currently hardcoded to 1). When a request requires a circuit for a key that already has an active circuit, the manager reuses the existing circuit rather than creating a new one. This prevents resource exhaustion while maintaining isolation integrity.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →