pub mod actions;
pub use actions::TorClientProtocol;
use anyhow::{Context, Result};
use arti_client::{TorClient as ArtiClient, TorClientConfig};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex};
use tracing::{error, info, trace, warn};
#[cfg(unix)]
use serde::Serialize;
#[cfg(unix)]
use tor_netdir::{NetDir, Relay};
use crate::client::llm_budget::call_llm_for_client;
use crate::client::tor::actions::{TOR_CLIENT_CONNECTED_EVENT, TOR_CLIENT_DATA_RECEIVED_EVENT};
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::Event;
use crate::state::app_state::AppState;
use crate::state::{ClientId, ClientStatus};
#[derive(Debug, Clone, PartialEq)]
enum ConnectionState {
Idle,
Processing,
Accumulating,
}
struct ClientData {
state: ConnectionState,
queued_data: Vec<u8>,
memory: String,
}
#[cfg(unix)]
#[derive(Debug, Clone, Default)]
pub struct RelayFilter {
pub flags: Option<Vec<String>>,
pub min_bandwidth: Option<u64>,
pub nickname_pattern: Option<String>,
pub limit: Option<usize>,
}
#[cfg(unix)]
impl RelayFilter {
fn matches(&self, _relay: &Relay<'_>) -> bool {
true
}
}
#[cfg(unix)]
#[derive(Debug, Clone, Serialize)]
pub struct RelayInfo {
pub nickname: String,
pub fingerprint: String,
pub flags: Vec<String>,
pub is_guard: bool,
pub is_exit: bool,
pub is_fast: bool,
pub is_stable: bool,
pub is_running: bool,
pub is_valid: bool,
}
#[cfg(unix)]
impl RelayInfo {
fn from_relay(relay: &Relay<'_>) -> Self {
let fingerprint = format!("{:?}", relay.rsa_id());
let nickname = fingerprint.chars().take(8).collect::<String>();
Self {
nickname,
fingerprint,
flags: vec![], is_guard: false,
is_exit: false,
is_fast: false,
is_stable: false,
is_running: false,
is_valid: false,
}
}
}
pub const ALLOW_PUBLIC_TOR_NETWORK_PARAM: &str = "allow_public_tor_network";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootstrapTarget {
CustomDirectory(String),
PublicNetwork,
}
pub fn bootstrap_target(
directory_server: Option<&str>,
allow_public_tor_network: bool,
) -> Result<BootstrapTarget> {
match (directory_server, allow_public_tor_network) {
(Some(dir), false) => Ok(BootstrapTarget::CustomDirectory(dir.to_string())),
(None, true) => Ok(BootstrapTarget::PublicNetwork),
(Some(dir), true) => Err(anyhow::anyhow!(
"Tor client: `directory_server` ({dir}) and `{ALLOW_PUBLIC_TOR_NETWORK_PARAM}` are \
mutually exclusive — the first bootstraps from the directory you named, the second \
from the public Tor directory authorities. Pass exactly one."
)),
(None, false) => Err(anyhow::anyhow!(
"Tor client refused to start: bootstrapping contacts the real Tor directory \
authorities on the public internet, and does so before it looks at the address you \
asked for — so opening this client would make outbound connections to third \
parties regardless of the destination. NetGet does not do that by default. Either \
pass `directory_server` (e.g. \"127.0.0.1:9001\", a local `tor_relay`) to bootstrap \
locally, or pass `{ALLOW_PUBLIC_TOR_NETWORK_PARAM}: true` to opt in to the public \
Tor network."
)),
}
}
type TorWriteHalf = Arc<Mutex<tokio::io::WriteHalf<arti_client::DataStream>>>;
#[cfg(unix)]
async fn run_directory_action(
name: &str,
data: &serde_json::Value,
client_id: ClientId,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
) {
match name {
"get_consensus_info" => match TorClient::get_consensus_info(app_state, client_id).await {
Ok(info) => {
let _ = status_tx.send(format!(
"[TOR] Consensus: {} relays, valid until {}",
info["relay_count"], info["valid_until"]
));
trace!("Consensus info: {}", info);
}
Err(e) => {
error!("Failed to get consensus info: {}", e);
let _ = status_tx.send(format!("[TOR] Error: {}", e));
}
},
"list_relays" | "search_relays" => {
let limit = data.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let filter = RelayFilter {
flags: data.get("flags").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
}),
nickname_pattern: data
.get("nickname")
.and_then(|v| v.as_str())
.map(String::from),
limit: Some(limit),
..Default::default()
};
match TorClient::query_relays(app_state, client_id, filter).await {
Ok(relays) => {
let _ = status_tx.send(format!("[TOR] Found {} relays", relays.len()));
for (i, relay) in relays.iter().take(10).enumerate() {
trace!(
"Relay {}: {} ({})",
i + 1,
relay.nickname,
relay.flags.join(", ")
);
}
}
Err(e) => {
error!("Failed to query relays: {}", e);
let _ = status_tx.send(format!("[TOR] Error: {}", e));
}
}
}
other => {
warn!("Unknown custom action: {}", other);
}
}
}
pub async fn apply_actions(
actions: Vec<serde_json::Value>,
write_half: Option<&TorWriteHalf>,
client_id: ClientId,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
) -> bool {
use crate::llm::actions::client_trait::{Client, ClientActionResult};
let protocol = crate::client::tor::actions::TorClientProtocol::new();
for action in actions {
match protocol.execute_action(action) {
Ok(ClientActionResult::SendData(bytes)) => {
let Some(write_half) = write_half else {
warn!(
"Tor client {} cannot send {} bytes: no circuit yet",
client_id,
bytes.len()
);
let _ = status_tx.send(format!(
"[TOR] ✖ send_tor_data ignored for client {}: the circuit is not open yet",
client_id
));
continue;
};
match write_half.lock().await.write_all(&bytes).await {
Ok(()) => trace!("Tor client {} sent {} bytes", client_id, bytes.len()),
Err(e) => error!("Tor client {} write failed: {}", client_id, e),
}
}
Ok(ClientActionResult::Disconnect) => {
info!("Tor client {} disconnecting", client_id);
return true;
}
#[cfg(unix)]
Ok(ClientActionResult::Custom { name, data }) => {
run_directory_action(&name, &data, client_id, app_state, status_tx).await;
}
Ok(_) => {}
Err(e) => {
warn!("Tor client {} rejected action: {}", client_id, e);
let _ = status_tx.send(format!(
"[TOR] ✖ client {} rejected action: {}",
client_id, e
));
}
}
}
false
}
pub struct TorClient;
impl TorClient {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<crate::protocol::StartupParams>,
) -> Result<SocketAddr> {
info!("Tor client {} initializing...", client_id);
let _ = status_tx.send(format!("[CLIENT] Tor client {} initializing...", client_id));
let directory_server = match startup_params {
Some(ref params) => params.get_optional_string("directory_server")?,
None => None,
};
let allow_public = match startup_params {
Some(ref params) => params
.get_optional_bool(ALLOW_PUBLIC_TOR_NETWORK_PARAM)?
.unwrap_or(false),
None => false,
};
let config = match bootstrap_target(directory_server.as_deref(), allow_public)? {
BootstrapTarget::CustomDirectory(directory_server) => {
info!(
"Tor client {} using custom directory server: {}",
client_id, directory_server
);
let _ = status_tx.send(format!(
"[CLIENT] Tor client {} using custom directory: {}",
client_id, directory_server
));
Self::create_custom_config(&directory_server)?
}
BootstrapTarget::PublicNetwork => {
warn!(
"Tor client {} is bootstrapping against the PUBLIC Tor directory \
authorities ({}=true): this makes outbound connections to third parties \
before any traffic is sent to {}",
client_id, ALLOW_PUBLIC_TOR_NETWORK_PARAM, remote_addr
);
let _ = status_tx.send(format!(
"[CLIENT] âš Tor client {} contacting the PUBLIC Tor directory authorities \
(opted in via {})",
client_id, ALLOW_PUBLIC_TOR_NETWORK_PARAM
));
TorClientConfig::default()
}
};
let tor_client = ArtiClient::create_bootstrapped(config)
.await
.context("Failed to bootstrap Tor client")?;
info!("Tor client {} bootstrapped successfully", client_id);
let _ = status_tx.send(format!("[CLIENT] Tor client {} bootstrapped", client_id));
#[cfg(unix)]
app_state
.set_tor_client(client_id, Arc::new(tor_client.clone()))
.await;
#[cfg(unix)]
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let protocol = Arc::new(crate::client::tor::actions::TorClientProtocol::new());
match Self::get_consensus_info(&app_state, client_id).await {
Ok(info) => {
use crate::client::tor::actions::TOR_BOOTSTRAP_COMPLETE_EVENT;
let event = Event::new(
&TOR_BOOTSTRAP_COMPLETE_EVENT,
serde_json::json!({
"relay_count": info["relay_count"],
"valid_after": info["valid_after"],
}),
);
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
"",
Some(&event),
protocol.as_ref(),
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
apply_actions(actions, None, client_id, &app_state, &status_tx).await;
}
Err(e) => {
warn!("Failed to call LLM for bootstrap event: {}", e);
}
}
}
Err(e) => {
trace!(
"Could not get consensus info at bootstrap (may not be available yet): {}",
e
);
}
}
}
let target = remote_addr.clone();
let stream = tor_client
.connect(target.as_str())
.await
.context(format!("Failed to connect to {} through Tor", target))?;
let local_addr = SocketAddr::from(([127, 0, 0, 1], 0));
info!(
"Tor client {} connected to {} through Tor network",
client_id, remote_addr
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Tor client {} connected to {}",
client_id, remote_addr
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let (mut read_half, write_half) = tokio::io::split(stream);
let write_half_arc = Arc::new(Mutex::new(write_half));
let client_data = Arc::new(Mutex::new(ClientData {
state: ConnectionState::Idle,
queued_data: Vec::new(),
memory: String::new(),
}));
let task_registrar = app_state.clone();
let mut command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let task_handle = tokio::spawn(async move {
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let protocol = crate::client::tor::actions::TorClientProtocol::new();
let event = Event::new(
&TOR_CLIENT_CONNECTED_EVENT,
serde_json::json!({ "target": remote_addr }),
);
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
"",
Some(&event),
&protocol,
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
client_data.lock().await.memory = mem;
}
if apply_actions(
actions,
Some(&write_half_arc),
client_id,
&app_state,
&status_tx,
)
.await
{
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
app_state.remove_client_handle(client_id).await;
return;
}
}
Err(e) => {
warn!(
"Failed to call LLM for Tor client {} connection: {}",
client_id, e
);
}
}
}
let mut buffer = vec![0u8; 8192];
loop {
let read_result = tokio::select! {
read = read_half.read(&mut buffer) => read,
Some(cmd) = command_rx.recv() => {
let disconnect = crate::client::command_support::handle_stream_client_command(
&crate::client::tor::actions::TorClientProtocol,
&write_half_arc,
cmd,
client_id,
&app_state,
&status_tx,
)
.await;
if disconnect {
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Tor client {} disconnected (injected action)",
client_id
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
break;
}
continue;
}
};
match read_result {
Ok(0) => {
info!("Tor client {} disconnected", client_id);
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx
.send(format!("[CLIENT] Tor client {} disconnected", client_id));
let _ = status_tx.send("__UPDATE_UI__".to_string());
break;
}
Ok(n) => {
let data = buffer[..n].to_vec();
trace!("Tor client {} received {} bytes", client_id, n);
let mut disconnect_requested = false;
let mut client_data_lock = client_data.lock().await;
match client_data_lock.state {
ConnectionState::Idle => {
client_data_lock.state = ConnectionState::Processing;
drop(client_data_lock);
if let Some(instruction) =
app_state.get_instruction_for_client(client_id).await
{
let protocol = Arc::new(
crate::client::tor::actions::TorClientProtocol::new(),
);
let event = Event::new(
&TOR_CLIENT_DATA_RECEIVED_EVENT,
crate::client::tor::actions::inbound_event_fields(&data),
);
match call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
&client_data.lock().await.memory,
Some(&event),
protocol.as_ref(),
&status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
client_data.lock().await.memory = mem;
}
disconnect_requested = apply_actions(
actions,
Some(&write_half_arc),
client_id,
&app_state,
&status_tx,
)
.await;
}
Err(e) => {
error!("LLM error for Tor client {}: {}", client_id, e);
}
}
}
let mut client_data_lock = client_data.lock().await;
if !client_data_lock.queued_data.is_empty() {
client_data_lock.queued_data.clear();
}
client_data_lock.state = ConnectionState::Idle;
}
ConnectionState::Processing => {
client_data_lock.queued_data.extend_from_slice(&data);
client_data_lock.state = ConnectionState::Accumulating;
}
ConnectionState::Accumulating => {
client_data_lock.queued_data.extend_from_slice(&data);
}
}
if disconnect_requested {
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Tor client {} disconnected (model asked)",
client_id
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
break;
}
}
Err(e) => {
error!("Tor client {} read error: {}", client_id, e);
app_state
.update_client_status(client_id, ClientStatus::Error(e.to_string()))
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
break;
}
}
}
app_state.remove_client_handle(client_id).await;
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
Ok(local_addr)
}
#[cfg(unix)]
fn create_custom_config(directory_server: &str) -> Result<TorClientConfig> {
use arti_client::config::dir::FallbackDir;
let addr: SocketAddr = directory_server.parse().context(
"Invalid directory_server address format. Expected 'IP:port' like '127.0.0.1:9030'",
)?;
let mut fallback = FallbackDir::builder();
fallback
.rsa_identity([0x42; 20].into()) .ed_identity([0x99; 32].into()) .orports() .push(addr);
let mut bld = TorClientConfig::builder();
bld.tor_network().set_fallback_caches(vec![fallback]);
let config = bld.build().context("Failed to build TorClientConfig")?;
Ok(config)
}
#[cfg(unix)]
pub async fn get_netdir(
app_state: &Arc<crate::state::app_state::AppState>,
client_id: crate::state::ClientId,
) -> Result<Arc<NetDir>> {
let tor_client = app_state
.get_tor_client(client_id)
.await
.context("Tor client not found in app state")?;
let dirmgr = tor_client.dirmgr();
let netdir = dirmgr
.netdir(tor_netdir::Timeliness::Timely)
.context("No network directory available - client may still be bootstrapping")?;
Ok(netdir)
}
#[cfg(unix)]
pub async fn query_relays(
app_state: &Arc<crate::state::app_state::AppState>,
client_id: crate::state::ClientId,
filter: RelayFilter,
) -> Result<Vec<RelayInfo>> {
let netdir = Self::get_netdir(app_state, client_id).await?;
let limit = filter.limit.unwrap_or(100);
let mut relays = Vec::new();
for relay in netdir.relays() {
if filter.matches(&relay) {
relays.push(RelayInfo::from_relay(&relay));
if relays.len() >= limit {
break;
}
}
}
Ok(relays)
}
#[cfg(unix)]
pub async fn get_consensus_info(
app_state: &Arc<crate::state::app_state::AppState>,
client_id: crate::state::ClientId,
) -> Result<serde_json::Value> {
let netdir = Self::get_netdir(app_state, client_id).await?;
let relay_count = netdir.relays().count();
let lifetime = netdir.lifetime();
Ok(serde_json::json!({
"relay_count": relay_count,
"valid_after": format!("{:?}", lifetime.valid_after()),
"fresh_until": format!("{:?}", lifetime.fresh_until()),
"valid_until": format!("{:?}", lifetime.valid_until()),
}))
}
}