Skip to main content

netget/client/tor/
actions.rs

1//! Tor client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Turn an outbound data action into the exact bytes to put on the wire.
15///
16/// The model-facing spelling is `data` plus an optional `encoding`:
17///
18/// - `encoding` absent or `"utf8"`: the characters of `data` are sent verbatim. This is the
19///   default because what a Tor circuit usually carries is a request a person can read.
20/// - `encoding` = `"hex"`: `data` is decoded as hex, so `"48656c6c6f"` sends the 5 bytes
21///   `Hello`.
22///
23/// There is deliberately **no auto-detection**. `"48656c6c6f"` is simultaneously valid text
24/// and valid hex, and only the sender knows which it means — sniffing is the `send_tcp_data`
25/// bug wearing a different hat.
26///
27/// `data_hex` is the field this action used to declare, and it is still accepted so that an
28/// existing static handler or stored prompt keeps working. It is no longer advertised, and it
29/// means exactly `{"data": <the hex>, "encoding": "hex"}`. Supplying **both** `data` and
30/// `data_hex` is refused rather than resolved by precedence: the two say different things
31/// about the same wire bytes and guessing which one the caller meant is how the original bug
32/// happened.
33pub fn decode_outbound_data(action: &serde_json::Value) -> Result<Vec<u8>> {
34    let data = action.get("data").and_then(|v| v.as_str());
35    let legacy = action.get("data_hex").and_then(|v| v.as_str());
36
37    match (data, legacy) {
38        (Some(_), Some(_)) => Err(anyhow::anyhow!(
39            "Both 'data' and 'data_hex' were supplied. Send exactly one: 'data' with an \
40             optional 'encoding' (\"utf8\" by default, or \"hex\"), or the deprecated \
41             'data_hex'. They are not combined and neither takes precedence."
42        )),
43        (Some(text), None) => decode_with_encoding(text, action),
44        (None, Some(h)) => decode_hex_field(h, "data_hex"),
45        (None, None) => Err(anyhow::anyhow!(
46            "Missing 'data' field. Put the payload in 'data' and, when it is binary, add \
47             \"encoding\": \"hex\"."
48        )),
49    }
50}
51
52fn decode_with_encoding(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
53    match action
54        .get("encoding")
55        .and_then(|v| v.as_str())
56        .unwrap_or("utf8")
57    {
58        "utf8" => Ok(data.as_bytes().to_vec()),
59        "hex" => decode_hex_field(data, "data"),
60        other => Err(anyhow::anyhow!(
61            "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
62             string's characters as-is) and \"hex\" (decode the string as hex-encoded bytes)."
63        )),
64    }
65}
66
67/// Hex with the separators a model naturally writes stripped, and a refusal that names the
68/// field and says how to send the same string as literal text instead.
69fn decode_hex_field(value: &str, field: &str) -> Result<Vec<u8>> {
70    let cleaned: String = value
71        .chars()
72        .filter(|c| !c.is_ascii_whitespace() && *c != ':')
73        .collect();
74    let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
75
76    if cleaned.len() % 2 != 0 {
77        return Err(anyhow::anyhow!(
78            "Invalid hex in '{field}': expected an even number of hex digits, got {} \
79             ({value:?}). Each byte is two hex digits, e.g. \"48656c6c6f\" = \"Hello\".",
80            cleaned.len()
81        ));
82    }
83
84    hex::decode(cleaned).map_err(|e| {
85        anyhow::anyhow!(
86            "Invalid hex in '{field}' ({value:?}): {e}. Use only 0-9/a-f, two digits per byte, \
87             e.g. \"48656c6c6f\" = \"Hello\". To send this string as literal text, put it in \
88             'data' and omit 'encoding' (or set it to \"utf8\")."
89        )
90    })
91}
92
93/// What received bytes look like to the model: `data`, `encoding`, `data_length`.
94///
95/// Printable ASCII is passed through as text so the model can read an HTTP response for what
96/// it is; anything else is hex-encoded and `encoding` says so. The pair round-trips — handing
97/// this `data` and `encoding` straight back to `send_tor_data` puts the same bytes on the
98/// wire — which is exactly what the field used to make impossible: it was `data_hex` only, so
99/// every readable payload reached the model as hex it had to decode in its head.
100pub fn inbound_event_fields(data: &[u8]) -> serde_json::Value {
101    let (text, encoding) = if data
102        .iter()
103        .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
104    {
105        (String::from_utf8_lossy(data).to_string(), "utf8")
106    } else {
107        (hex::encode(data), "hex")
108    };
109
110    json!({
111        "data": text,
112        "encoding": encoding,
113        "data_length": data.len(),
114    })
115}
116
117/// The one description both spellings of `send_tor_data` share.
118///
119/// It says outright that nothing is sniffed, because the whole point of the `encoding` field
120/// is that a string like `"48656c6c6f"` is ambiguous and the model has to resolve it.
121const SEND_DATA_DESCRIPTION: &str = "Send a payload to the destination through the established \
122     Tor circuit. The 'data' field holds the payload and the optional 'encoding' field says \
123     how to turn it into bytes: omit 'encoding' (or use \"utf8\") to send the string's \
124     characters as-is, or set \"encoding\": \"hex\" to send 'data' decoded from hex. There \
125     is no auto-detection - a string like \"48656c6c6f\" is sent literally unless you set \
126     \"encoding\": \"hex\".";
127
128/// The `data` parameter every outbound Tor action carries.
129fn data_parameter() -> Parameter {
130    Parameter {
131        name: "data".to_string(),
132        type_hint: "string".to_string(),
133        description: "Payload to send to the destination through the Tor circuit. Interpreted \
134                      according to 'encoding': as literal text by default, or as hex-encoded \
135                      bytes when \"encoding\": \"hex\"."
136            .to_string(),
137        required: true,
138    }
139}
140
141/// The `encoding` parameter, a closed choice set matching [`decode_with_encoding`].
142fn encoding_parameter() -> Parameter {
143    Parameter {
144        name: "encoding".to_string(),
145        type_hint: "string".to_string(),
146        description: "How to convert 'data' into the bytes put on the wire. \"utf8\" (the \
147                      default when omitted) sends the characters of 'data' unchanged - use it \
148                      for text protocols such as HTTP. \"hex\" decodes 'data' as hex-encoded \
149                      bytes, two hex digits per byte. There is no auto-detection: \
150                      {\"data\": \"48656c6c6f\"} sends the 10 characters 4-8-6-5-6-c-6-c-6-f, \
151                      and only {\"data\": \"48656c6c6f\", \"encoding\": \"hex\"} sends the \
152                      5 bytes 'Hello'. No other values are accepted."
153            .to_string(),
154        required: false,
155    }
156    .with_choices(["utf8", "hex"])
157}
158
159/// Tor client connected event
160pub static TOR_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
161    EventType::new(
162        "tor_connected",
163        "Tor client successfully connected through Tor network",
164        json!({
165            "type": "send_tor_data",
166            "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
167            "encoding": "utf8"
168        }),
169    )
170    .with_parameters(vec![Parameter {
171        name: "target".to_string(),
172        type_hint: "string".to_string(),
173        description: "Target address (can be regular hostname:port or .onion:port)".to_string(),
174        required: true,
175    }])
176});
177
178/// Tor client data received event
179pub static TOR_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
180    EventType::new(
181        "tor_data_received",
182        "Data received from destination through Tor",
183        json!({
184            "type": "send_tor_data",
185            "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
186            "encoding": "utf8"
187        }),
188    )
189    .with_parameters(vec![
190        Parameter {
191            name: "data".to_string(),
192            type_hint: "string".to_string(),
193            description: "The data received from the destination. Read it according to the \
194                          'encoding' field of this event."
195                .to_string(),
196            required: true,
197        },
198        Parameter {
199            name: "encoding".to_string(),
200            type_hint: "string".to_string(),
201            description: "How to read 'data': \"utf8\" means 'data' is the received bytes as \
202                          literal text, \"hex\" means it is those bytes hex-encoded (two hex \
203                          digits per byte, used whenever they are not all printable ASCII). To \
204                          send the same bytes back unchanged, pass this 'data' and 'encoding' \
205                          straight to send_tor_data."
206                .to_string(),
207            required: true,
208        },
209        Parameter {
210            name: "data_length".to_string(),
211            type_hint: "number".to_string(),
212            description: "Length of the received data in bytes, before any encoding".to_string(),
213            required: true,
214        },
215    ])
216});
217
218/// Tor bootstrap complete event (directory consensus downloaded)
219#[cfg(unix)]
220pub static TOR_BOOTSTRAP_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
221    EventType::new(
222        "tor_bootstrap_complete",
223        "Tor client finished bootstrapping and downloaded network consensus",
224        json!({
225            "type": "send_tor_data",
226            "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
227            "encoding": "utf8"
228        }),
229    )
230    .with_parameters(vec![
231        Parameter {
232            name: "relay_count".to_string(),
233            type_hint: "number".to_string(),
234            description: "Number of relays in consensus".to_string(),
235            required: true,
236        },
237        Parameter {
238            name: "valid_after".to_string(),
239            type_hint: "string".to_string(),
240            description: "Consensus valid-after timestamp".to_string(),
241            required: true,
242        },
243    ])
244});
245
246/// Tor client protocol action handler
247pub struct TorClientProtocol;
248
249impl TorClientProtocol {
250    pub fn new() -> Self {
251        Self
252    }
253}
254
255// Implement Protocol trait (common functionality)
256impl Protocol for TorClientProtocol {
257    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
258        vec![
259            ParameterDefinition {
260                name: "directory_server".to_string(),
261                type_hint: "string".to_string(),
262                description: "Custom Tor relay address (e.g., '127.0.0.1:9001') to bootstrap from, using BEGIN_DIR (directory over circuit) instead of the public Tor network. Pair with a local tor_relay server for offline use. Exactly one of this or allow_public_tor_network must be given; without either the client refuses to start.".to_string(),
263                required: false,
264                example: json!("127.0.0.1:9001"),
265                default: None,
266            },
267            ParameterDefinition {
268                name: crate::client::tor::ALLOW_PUBLIC_TOR_NETWORK_PARAM.to_string(),
269                type_hint: "boolean".to_string(),
270                description: "Opt in to bootstrapping against the REAL Tor directory authorities on the public internet. Off by default: bootstrapping contacts third parties before the requested destination is even looked at, so opening a Tor client would otherwise reach the internet no matter what you asked it to connect to. Set true only when you intend outbound public traffic.".to_string(),
271                required: false,
272                example: json!(true),
273                default: None,
274            },
275        ]
276    }
277
278    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
279        vec![
280            ActionDefinition {
281                name: "send_tor_data".to_string(),
282                description: SEND_DATA_DESCRIPTION.to_string(),
283                parameters: vec![data_parameter(), encoding_parameter()],
284                example: json!({
285                    "type": "send_tor_data",
286                    "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
287                    "encoding": "utf8"
288                }),
289                log_template: None,
290            },
291            ActionDefinition {
292                name: "disconnect".to_string(),
293                description: "Disconnect from the Tor circuit".to_string(),
294                parameters: vec![],
295                example: json!({
296                    "type": "disconnect"
297                }),
298                log_template: None,
299            },
300            #[cfg(unix)]
301            ActionDefinition {
302                name: "get_consensus_info".to_string(),
303                description: "Get network consensus metadata (relay count, validity times)"
304                    .to_string(),
305                parameters: vec![],
306                example: json!({
307                    "type": "get_consensus_info"
308                }),
309                log_template: None,
310            },
311            #[cfg(unix)]
312            ActionDefinition {
313                name: "list_relays".to_string(),
314                description: "List relays from the Tor network consensus".to_string(),
315                parameters: vec![Parameter {
316                    name: "limit".to_string(),
317                    type_hint: "number".to_string(),
318                    description: "Maximum number of relays to return (default: 100)".to_string(),
319                    required: false,
320                }],
321                example: json!({
322                    "type": "list_relays",
323                    "limit": 50
324                }),
325                log_template: None,
326            },
327            #[cfg(unix)]
328            ActionDefinition {
329                name: "search_relays".to_string(),
330                description: "Search for relays matching criteria (flags, nickname pattern)"
331                    .to_string(),
332                parameters: vec![
333                    Parameter {
334                        name: "flags".to_string(),
335                        type_hint: "array".to_string(),
336                        description: "Required flags (e.g., [\"Guard\", \"Exit\", \"Fast\"])"
337                            .to_string(),
338                        required: false,
339                    },
340                    Parameter {
341                        name: "nickname".to_string(),
342                        type_hint: "string".to_string(),
343                        description: "Nickname pattern to match".to_string(),
344                        required: false,
345                    },
346                    Parameter {
347                        name: "limit".to_string(),
348                        type_hint: "number".to_string(),
349                        description: "Maximum results (default: 100)".to_string(),
350                        required: false,
351                    },
352                ],
353                example: json!({
354                    "type": "search_relays",
355                    "flags": ["Exit", "Fast"],
356                    "limit": 20
357                }),
358                log_template: None,
359            },
360        ]
361    }
362    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
363        vec![
364            ActionDefinition {
365                name: "send_tor_data".to_string(),
366                description: format!(
367                    "Send data through the Tor circuit in response to data received from the \
368                     destination. {SEND_DATA_DESCRIPTION}"
369                ),
370                parameters: vec![data_parameter(), encoding_parameter()],
371                example: json!({
372                    "type": "send_tor_data",
373                    "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
374                    "encoding": "utf8"
375                }),
376                log_template: None,
377            },
378            ActionDefinition {
379                name: "wait_for_more".to_string(),
380                description: "Wait for more data before responding".to_string(),
381                parameters: vec![],
382                example: json!({
383                    "type": "wait_for_more"
384                }),
385                log_template: None,
386            },
387        ]
388    }
389    fn protocol_name(&self) -> &'static str {
390        "Tor"
391    }
392    /// Clone the statics the client actually emits, rather than rebuilding them.
393    ///
394    /// The two rebuilt copies carried `{"type": "placeholder", ...}` — an example
395    /// `execute_action` refuses outright as `Unknown Tor client action: placeholder` — and
396    /// no parameters at all. `get_event_types()` is the copy the model is shown, so it was
397    /// given no fields and one example that cannot work, while the correct `send_tor_data`
398    /// examples sat unused in the statics three screens above. The bootstrap event below
399    /// was already cloned properly, which made the drift visible in the same function.
400    fn get_event_types(&self) -> Vec<EventType> {
401        vec![
402            TOR_CLIENT_CONNECTED_EVENT.clone(),
403            TOR_CLIENT_DATA_RECEIVED_EVENT.clone(),
404            // Emitted but never advertised until now: mod.rs raises it once the Tor
405            // directory bootstrap finishes, and nothing told the model it existed, so no
406            // handler could be written for the one event that says the circuit is usable.
407            TOR_BOOTSTRAP_COMPLETE_EVENT.clone(),
408        ]
409    }
410    fn stack_name(&self) -> &'static str {
411        "Tor>TCP"
412    }
413    fn keywords(&self) -> Vec<&'static str> {
414        vec!["tor", "tor client", "onion", "anonymous", "privacy"]
415    }
416    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
417        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
418
419        ProtocolMetadataV2::builder()
420            .state(DevelopmentState::Experimental)
421            .implementation("Arti Tor client (pure Rust Tor implementation)")
422            .llm_control("Full control over data sent/received through Tor circuits")
423            .e2e_testing("Connect to onion services or regular hosts through Tor")
424            .notes(
425                "REACHES THE PUBLIC INTERNET ONLY ON EXPLICIT OPT-IN. arti's \
426                 create_bootstrapped() contacts the real Tor directory authorities BEFORE it \
427                 looks at the requested address (~14s), so merely opening this client used to \
428                 make outbound connections to third parties whatever the destination was. It \
429                 now refuses to start unless the caller passes either `directory_server` (a \
430                 directory to bootstrap from, e.g. a local `127.0.0.1:9001` tor_relay) or \
431                 `allow_public_tor_network: true`. Passing both is refused rather than \
432                 guessed. Bootstrap is 10-30s even when permitted.",
433            )
434            .build()
435    }
436    fn description(&self) -> &'static str {
437        "Tor client for anonymous connections through the Tor network"
438    }
439    fn example_prompt(&self) -> &'static str {
440        "Connect to example.onion:80 through Tor and send HTTP GET request"
441    }
442    fn group_name(&self) -> &'static str {
443        "VPN & Tunneling"
444    }
445    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
446        use crate::llm::actions::StartupExamples;
447        use serde_json::json;
448
449        StartupExamples::new(
450            // LLM mode: LLM controls Tor connection.
451            //
452            // Every example carries a bootstrap choice, because without one the client refuses
453            // to start. An example a model can copy verbatim into a refusal is worse than no
454            // example. The public network is the opt-in here; swap in
455            // `{"directory_server": "127.0.0.1:9001"}` to bootstrap from a local tor_relay.
456            json!({
457                "type": "open_client",
458                "remote_addr": "example.com:80",
459                "base_stack": "tor",
460                "startup_params": {"allow_public_tor_network": true},
461                "instruction": "Connect to the destination through Tor and send an HTTP GET request"
462            }),
463            // Script mode: Code-based Tor handling
464            json!({
465                "type": "open_client",
466                "remote_addr": "example.com:80",
467                "base_stack": "tor",
468                "startup_params": {"allow_public_tor_network": true},
469                "event_handlers": [{
470                    "event_pattern": "tor_connected",
471                    "handler": {
472                        "type": "script",
473                        "language": "python",
474                        "code": "<tor_client_handler>"
475                    }
476                }]
477            }),
478            // Static mode: Fixed Tor action
479            json!({
480                "type": "open_client",
481                "remote_addr": "example.com:80",
482                "base_stack": "tor",
483                "startup_params": {"allow_public_tor_network": true},
484                "event_handlers": [
485                    {
486                        "event_pattern": "tor_connected",
487                        "handler": {
488                            "type": "static",
489                            "actions": [{
490                                "type": "send_tor_data",
491                                "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
492                                "encoding": "utf8"
493                            }]
494                        }
495                    },
496                    {
497                        "event_pattern": "tor_data_received",
498                        "handler": {
499                            "type": "static",
500                            "actions": [{
501                                "type": "disconnect"
502                            }]
503                        }
504                    }
505                ]
506            }),
507        )
508    }
509}
510
511// Implement Client trait (client-specific functionality)
512impl Client for TorClientProtocol {
513    fn connect(
514        &self,
515        ctx: crate::protocol::ConnectContext,
516    ) -> std::pin::Pin<
517        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
518    > {
519        Box::pin(async move {
520            use crate::client::tor::TorClient;
521            TorClient::connect_with_llm_actions(
522                ctx.remote_addr,
523                ctx.llm_client,
524                ctx.state,
525                ctx.status_tx,
526                ctx.client_id,
527                ctx.startup_params,
528            )
529            .await
530        })
531    }
532    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
533        let action_type = action
534            .get("type")
535            .and_then(|v| v.as_str())
536            .context("Missing 'type' field in action")?;
537
538        match action_type {
539            "send_tor_data" => Ok(ClientActionResult::SendData(decode_outbound_data(&action)?)),
540            "disconnect" => Ok(ClientActionResult::Disconnect),
541            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
542
543            // Directory query actions (processed async in connect loop)
544            #[cfg(unix)]
545            "get_consensus_info" | "list_relays" | "search_relays" => {
546                Ok(ClientActionResult::Custom {
547                    name: action_type.to_string(),
548                    data: action.clone(),
549                })
550            }
551
552            _ => Err(anyhow::anyhow!(
553                "Unknown Tor client action: {}",
554                action_type
555            )),
556        }
557    }
558}