Skip to main content

netget/server/tor_relay/
actions.rs

1//! Tor Relay protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13use tracing::debug;
14
15/// Tor Relay protocol action handler
16pub struct TorRelayProtocol;
17
18impl TorRelayProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22
23    fn execute_detect_relay_cell(&self, action: serde_json::Value) -> Result<ActionResult> {
24        let message = action
25            .get("message")
26            .and_then(|v| v.as_str())
27            .context("Missing 'message' parameter")?;
28
29        debug!("Tor Relay: {}", message);
30
31        Ok(ActionResult::Custom {
32            name: "tor_relay_log".to_string(),
33            data: json!({
34                "logged": true,
35                "message": message
36            }),
37        })
38    }
39
40    /// Build a DESTROY cell (tor-spec 5.4).
41    ///
42    /// Layout is CircID(4) | Command(4) | Reason(1), padded to the 514-byte v4 cell size.
43    /// The result is written straight to the TLS stream, so it has to be a whole cell -
44    /// a short write desynchronises the peer's cell framing for the rest of the connection.
45    fn execute_send_destroy(&self, action: serde_json::Value) -> Result<ActionResult> {
46        let circuit_id = action
47            .get("circuit_id")
48            .and_then(|v| v.as_str())
49            .context("Missing 'circuit_id' parameter")?;
50
51        let circuit_id = u32::from_str_radix(circuit_id.trim_start_matches("0x"), 16)
52            .with_context(|| format!("circuit_id '{}' is not a hex circuit ID", circuit_id))?;
53
54        let reason = action
55            .get("reason")
56            .and_then(|v| v.as_u64())
57            .unwrap_or(1 /* PROTOCOL */) as u8;
58
59        debug!(
60            "Tor Relay sending DESTROY for circuit 0x{:08x} (reason {})",
61            circuit_id, reason
62        );
63
64        let mut cell = Vec::with_capacity(CELL_LEN);
65        cell.extend_from_slice(&circuit_id.to_be_bytes());
66        cell.push(CELL_COMMAND_DESTROY);
67        cell.push(reason);
68        cell.resize(CELL_LEN, 0);
69
70        Ok(ActionResult::Output(cell))
71    }
72}
73
74// Implement Protocol trait (common functionality)
75impl Protocol for TorRelayProtocol {
76    /// No async actions.
77    ///
78    /// Seven used to be declared here - set_relay_type, configure_exit_policy,
79    /// list_active_circuits, disconnect_circuit, list_active_streams, close_stream and
80    /// get_relay_statistics. None of them did anything: execute_action returned a Custom
81    /// result reading "implementation in server logic" and no such server logic existed.
82    /// Relay state lives in the per-server CircuitManager, which execute_action (sync) cannot
83    /// reach and could not await if it could. They were removed rather than left advertised.
84    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
85        vec![]
86    }
87    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
88        tor_relay_response_actions()
89    }
90    fn protocol_name(&self) -> &'static str {
91        "Tor Relay"
92    }
93    fn get_event_types(&self) -> Vec<EventType> {
94        get_tor_relay_event_types()
95    }
96    fn stack_name(&self) -> &'static str {
97        "ETH>IP>TCP>TLS>TorRelay"
98    }
99    fn description(&self) -> &'static str {
100        "Partial Tor OR-protocol relay (not interoperable with real Tor clients)"
101    }
102    fn example_prompt(&self) -> &'static str {
103        "Start a Tor exit relay on port 9001 allowing connections to localhost"
104    }
105    fn keywords(&self) -> Vec<&'static str> {
106        vec![
107            "tor_relay",
108            "tor-relay",
109            "onion router",
110            "guard",
111            "exit",
112            "middle",
113            "circuit",
114        ]
115    }
116    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
117        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
118
119        ProtocolMetadataV2::builder()
120            .state(DevelopmentState::Experimental)
121            .well_known_port(9001)
122            .implementation("Custom partial Tor OR protocol: TLS 1.3, ntor server handshake (Curve25519/HMAC-SHA256/HKDF), per-circuit AES-128-CTR, stream multiplexing to TCP targets, SENDME windows. Cells are framed by length, so VERSIONS and other variable-length cells no longer desynchronise the reader, and VERSIONS is answered - but the rest of the link handshake (CERTS/AUTH_CHALLENGE/NETINFO) is still neither sent nor parsed. The relay logs its identity fingerprint and onion public key at startup, which a peer needs to run ntor at all.")
123            .llm_control("Two events: circuit created, and RELAY commands the relay does not implement. Actions: log, DESTROY a circuit, close the connection. Everything on the data path - BEGIN, DATA, END, SENDME, exit policy - is decided in Rust with no LLM involvement.")
124            .e2e_testing("tests/server/tor_relay/e2e_test.rs, 2 LLM calls, not #[ignore]d. A Tor client written from tor-spec in the test file (independent of the server's own circuit.rs) runs VERSIONS, then a full ntor CREATE2/CREATED2 whose server AUTH value it recomputes and checks, then RELAY/BEGIN to a localhost HTTP server, then RELAY/DATA out and back - asserting the HTTP body decrypts correctly with the backward key. Not tested: a real tor or Arti binary, which still cannot use this relay because the link handshake stops after VERSIONS; cell digests; EXTEND; multi-hop.")
125            .notes("OPEN EXIT BY DESIGN, AND UNRESTRICTED: the destination of every RELAY/BEGIN is chosen by the peer, and there is no exit policy, allow-list, deny-list or network restriction of any kind. Loopback, link-local (including 169.254.169.254, the cloud instance-metadata endpoint) and every RFC 1918 range are all reachable, so anyone who completes a circuit can reach whatever this host can -- an SSRF pivot into the operator's private network, and an exit someone else's traffic can be laundered through. The model is not consulted about it either: BEGIN, DATA, END, SENDME and exit target selection are all decided in Rust, so `filter_mode`-style gating does not exist here even in the weak form socks5 and proxy have. Two bounds do apply, and they are on resources rather than on destination: a BEGIN's connect is abandoned after 10s (it is awaited inline in the cell loop, so an unbounded one froze the whole session for the OS timeout), and a circuit may hold at most 64 streams, answered past that with END/RESOURCE_LIMIT. Do not expose this to an untrusted network. Separately, it is NOT interoperable with real Tor and is not a usable relay: the link handshake stops after VERSIONS (no CERTS/AUTH_CHALLENGE/NETINFO); the relay cell digest field is never computed or verified (tor-spec wants a running SHA-1, and no SHA-1 dependency is available here); no EXTEND, so it can only ever be a single-hop endpoint. Was rated Stable and 'production-ready' - it is neither.")
126            .build()
127    }
128    fn group_name(&self) -> &'static str {
129        "Network Services"
130    }
131
132    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
133        use crate::llm::actions::StartupExamples;
134        use serde_json::json;
135
136        // Deterministic: note every relay cell seen on a circuit, no LLM call.
137        let script = r#"import json, sys
138data = json.load(sys.stdin)
139event = data["event"]
140if data["event_type_id"] == "tor_relay_relay_cell":
141    actions = [{"type": "detect_relay_cell", "message": "relay cell observed"}]
142else:
143    actions = []
144print(json.dumps({"actions": actions}))"#;
145
146        StartupExamples::new(
147            // LLM mode: LLM handles Tor relay operations
148            json!({
149                "type": "open_server",
150                "port": 9001,
151                "base_stack": "tor-relay",
152                "instruction": "Act as Tor exit relay allowing connections to localhost"
153            }),
154            // Script mode: Code-based Tor relay handling
155            json!({
156                "type": "open_server",
157                "port": 9001,
158                "base_stack": "tor-relay",
159                "event_handlers": [{
160                    "event_pattern": "tor_relay_relay_cell",
161                    "handler": {
162                        "type": "script",
163                        "language": "python",
164                        "code": script
165                    }
166                }]
167            }),
168            // Static mode: Fixed Tor relay responses
169            json!({
170                "type": "open_server",
171                "port": 9001,
172                "base_stack": "tor-relay",
173                "event_handlers": [{
174                    "event_pattern": "tor_relay_relay_cell",
175                    "handler": {
176                        "type": "static",
177                        "actions": [{
178                            "type": "detect_relay_cell",
179                            "message": "Cell detected and logged"
180                        }]
181                    }
182                }]
183            }),
184        )
185    }
186}
187
188// Implement Server trait (server-specific functionality)
189impl Server for TorRelayProtocol {
190    fn spawn(
191        &self,
192        ctx: crate::protocol::SpawnContext,
193    ) -> std::pin::Pin<
194        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
195    > {
196        Box::pin(async move {
197            use crate::server::tor_relay::TorRelayServer;
198            TorRelayServer::spawn_with_llm_actions(
199                ctx.legacy_listen_addr(),
200                ctx.llm_client,
201                ctx.state,
202                ctx.status_tx,
203                ctx.server_id,
204            )
205            .await
206        })
207    }
208    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
209        let action_type = action
210            .get("type")
211            .and_then(|v| v.as_str())
212            .context("Missing 'type' field in action")?;
213
214        match action_type {
215            "detect_relay_cell" => self.execute_detect_relay_cell(action),
216            "send_destroy" => self.execute_send_destroy(action),
217            "close_connection" => Ok(ActionResult::CloseConnection),
218            // Writes nothing to the wire — that is the point of it. The doc comment on
219            // `tor_relay_log_action` claimed "`execute_action` has always handled it"; it did
220            // not, so advertising the verb handed the model a name it was then rejected for
221            // using. `NoAction` is the honest result: the observation is already in the access
222            // log by the time this runs.
223            "tor_relay_log" => Ok(ActionResult::NoAction),
224            _ => Err(anyhow::anyhow!("Unknown Tor Relay action: {}", action_type)),
225        }
226    }
227}
228
229// ============================================================================
230// Cell constants (tor-spec 3, "Cell Packet format")
231// ============================================================================
232
233/// Fixed-length cell size for link protocol v4 (CircID 4 + Command 1 + payload 509).
234pub const CELL_LEN: usize = 514;
235/// DESTROY command byte.
236pub const CELL_COMMAND_DESTROY: u8 = 4;
237
238// ============================================================================
239// Action Definitions
240// ============================================================================
241
242fn detect_relay_cell_action() -> ActionDefinition {
243    ActionDefinition {
244        name: "detect_relay_cell".to_string(),
245        description: "Record an observation about this cell. Logs only - sends nothing on the \
246                      wire."
247            .to_string(),
248        parameters: vec![Parameter {
249            name: "message".to_string(),
250            type_hint: "string".to_string(),
251            description: "Log message describing the cell".to_string(),
252            required: true,
253        }],
254        example: json!({
255            "type": "detect_relay_cell",
256            "message": "RELAY cell detected from circuit 0x12345"
257        }),
258        log_template: Some(
259            LogTemplate::new()
260                .with_info("-> Tor RELAY cell")
261                .with_debug("Tor detect_relay_cell: {message}"),
262        ),
263    }
264}
265
266fn send_destroy_action() -> ActionDefinition {
267    ActionDefinition {
268        name: "send_destroy".to_string(),
269        description: "Tear down a circuit by sending it a DESTROY cell".to_string(),
270        parameters: vec![
271            Parameter {
272                name: "circuit_id".to_string(),
273                type_hint: "string".to_string(),
274                description: "Circuit ID to destroy, hex (e.g. '0x00000005'). Use the \
275                              circuit_id from the event."
276                    .to_string(),
277                required: true,
278            },
279            Parameter {
280                name: "reason".to_string(),
281                type_hint: "number".to_string(),
282                description: "tor-spec destroy reason: 1 PROTOCOL, 2 INTERNAL, 3 REQUESTED, \
283                              5 HIBERNATING, 8 FINISHED, 9 TIMEOUT (default 1)"
284                    .to_string(),
285                required: false,
286            },
287        ],
288        example: json!({
289            "type": "send_destroy",
290            "circuit_id": "0x00000005",
291            "reason": 3
292        }),
293        log_template: Some(
294            LogTemplate::new()
295                .with_info("-> Tor DESTROY {circuit_id}")
296                .with_debug("Tor send_destroy: circuit={circuit_id} reason={reason}"),
297        ),
298    }
299}
300
301fn close_connection_action() -> ActionDefinition {
302    ActionDefinition {
303        name: "close_connection".to_string(),
304        description: "Close the whole TLS connection immediately, dropping every circuit on it"
305            .to_string(),
306        parameters: vec![],
307        example: json!({
308            "type": "close_connection"
309        }),
310        log_template: Some(
311            LogTemplate::new()
312                .with_info("-> Tor close connection")
313                .with_debug("Tor close_connection: closing immediately"),
314        ),
315    }
316}
317
318/// The actions every Tor relay event accepts.
319///
320/// `call_llm` advertises `EventType::actions`, not `get_sync_actions()`, so each event below
321/// must carry this list or the model is offered nothing.
322fn tor_relay_response_actions() -> Vec<ActionDefinition> {
323    vec![
324        detect_relay_cell_action(),
325        send_destroy_action(),
326        close_connection_action(),
327        tor_relay_log_action(),
328    ]
329}
330
331/// `tor_relay_log`: the only verb this relay has that writes nothing to the wire.
332///
333/// The comment here used to say "`execute_action` has always handled it". It did not — there
334/// was no match arm — so advertising the verb was the *advertised-but-unexecutable* bug rather
335/// than the fix for its mirror. The arm exists now, returning `NoAction`.
336///
337/// It is the only verb this relay has that
338/// writes nothing to the wire -- so it is the answer for a circuit event that needs
339/// observing rather than acting on. Leaving it undeclared meant the model could not ask
340/// for it and had to choose between send_destroy, detect_relay_cell and inventing
341/// something: the mirror of the advertised-but-unexecutable bug, and equally invisible.
342fn tor_relay_log_action() -> ActionDefinition {
343    ActionDefinition {
344        name: "tor_relay_log".to_string(),
345        description: "Record an observation about the circuit without sending anything. \
346                      The correct answer when a cell needs noting rather than answering."
347            .to_string(),
348        parameters: vec![Parameter {
349            name: "message".to_string(),
350            type_hint: "string".to_string(),
351            description: "What to record".to_string(),
352            required: true,
353        }],
354        example: json!({
355            "type": "tor_relay_log",
356            "message": "circuit created"
357        }),
358        log_template: None,
359    }
360}
361
362// ============================================================================
363// Event Type Constants
364// ============================================================================
365
366/// Circuit created event - emitted after a CREATE2 cell completes the ntor handshake.
367pub static TOR_RELAY_CIRCUIT_CREATED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
368    EventType::new(
369        "tor_relay_circuit_created",
370        "Tor circuit created via ntor handshake",
371        json!({
372            "type": "detect_relay_cell",
373            "message": "Circuit created successfully"
374        }),
375    )
376    .with_parameters(vec![
377        Parameter {
378            name: "circuit_id".to_string(),
379            type_hint: "string".to_string(),
380            description: "Circuit ID (hex format)".to_string(),
381            required: true,
382        },
383        Parameter {
384            name: "client_ip".to_string(),
385            type_hint: "string".to_string(),
386            description: "Client IP address".to_string(),
387            required: true,
388        },
389    ])
390    .with_actions(tor_relay_response_actions())
391});
392
393/// RELAY cell event - emitted for relay commands the relay does not handle itself
394/// (EXTEND, TRUNCATE, RESOLVE, DROP and anything unrecognised). BEGIN, BEGIN_DIR, DATA,
395/// END and SENDME are handled natively and do not raise this event.
396pub static TOR_RELAY_RELAY_CELL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
397    EventType::new(
398        "tor_relay_relay_cell",
399        "Tor RELAY cell received carrying a command the relay does not implement",
400        json!({
401            "type": "detect_relay_cell",
402            "message": "EXTEND cell on circuit 0x00000005 - not supported, ignoring"
403        }),
404    )
405    .with_parameters(vec![
406        Parameter {
407            name: "circuit_id".to_string(),
408            type_hint: "string".to_string(),
409            description: "Circuit ID (hex format)".to_string(),
410            required: true,
411        },
412        Parameter {
413            name: "relay_command".to_string(),
414            type_hint: "string".to_string(),
415            description: "RELAY command name (EXTEND, TRUNCATE, RESOLVE, DROP, UNKNOWN, ...)"
416                .to_string(),
417            required: true,
418        },
419        Parameter {
420            name: "stream_id".to_string(),
421            type_hint: "number".to_string(),
422            description: "Stream ID within the circuit".to_string(),
423            required: true,
424        },
425        Parameter {
426            name: "length".to_string(),
427            type_hint: "number".to_string(),
428            description: "Length of RELAY cell data".to_string(),
429            required: true,
430        },
431        Parameter {
432            name: "client_ip".to_string(),
433            type_hint: "string".to_string(),
434            description: "Client IP address".to_string(),
435            required: true,
436        },
437    ])
438    .with_actions(tor_relay_response_actions())
439});
440
441pub fn get_tor_relay_event_types() -> Vec<EventType> {
442    vec![
443        TOR_RELAY_CIRCUIT_CREATED_EVENT.clone(),
444        TOR_RELAY_RELAY_CELL_EVENT.clone(),
445    ]
446}