Skip to main content

netget/client/socks5/
actions.rs

1//! SOCKS5 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 the thing a SOCKS5 tunnel 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_socks5_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_socks5_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 through the established SOCKS5 tunnel to \
122     the target server. The 'data' field holds the payload and the optional 'encoding' field \
123     says 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 SOCKS5 action carries.
129fn data_parameter() -> Parameter {
130    Parameter {
131        name: "data".to_string(),
132        type_hint: "string".to_string(),
133        description: "Payload to send through the SOCKS5 tunnel to the target. 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/// SOCKS5 client connected event
160pub static SOCKS5_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
161    EventType::new(
162        "socks5_connected",
163        "SOCKS5 client successfully connected through proxy",
164        json!({
165            "type": "send_socks5_data",
166            "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
167            "encoding": "utf8"
168        }),
169    )
170    .with_parameters(vec![
171        Parameter {
172            name: "proxy_addr".to_string(),
173            type_hint: "string".to_string(),
174            description: "SOCKS5 proxy server address".to_string(),
175            required: true,
176        },
177        Parameter {
178            name: "target_addr".to_string(),
179            type_hint: "string".to_string(),
180            description: "Target server address through proxy".to_string(),
181            required: true,
182        },
183    ])
184});
185
186/// SOCKS5 client data received event
187pub static SOCKS5_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
188    EventType::new(
189        "socks5_data_received",
190        "Data received from target server through SOCKS5 proxy",
191        json!({
192            "type": "send_socks5_data",
193            "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
194            "encoding": "utf8"
195        }),
196    )
197    .with_parameters(vec![
198        Parameter {
199            name: "data".to_string(),
200            type_hint: "string".to_string(),
201            description: "The data received from the target. Read it according to the \
202                          'encoding' field of this event."
203                .to_string(),
204            required: true,
205        },
206        Parameter {
207            name: "encoding".to_string(),
208            type_hint: "string".to_string(),
209            description: "How to read 'data': \"utf8\" means 'data' is the received bytes as \
210                          literal text, \"hex\" means it is those bytes hex-encoded (two hex \
211                          digits per byte, used whenever they are not all printable ASCII). To \
212                          send the same bytes back unchanged, pass this 'data' and 'encoding' \
213                          straight to send_socks5_data."
214                .to_string(),
215            required: true,
216        },
217        Parameter {
218            name: "data_length".to_string(),
219            type_hint: "number".to_string(),
220            description: "Length of the received data in bytes, before any encoding".to_string(),
221            required: true,
222        },
223    ])
224});
225
226/// SOCKS5 client protocol action handler
227pub struct Socks5ClientProtocol;
228
229impl Socks5ClientProtocol {
230    pub fn new() -> Self {
231        Self
232    }
233}
234
235// Implement Protocol trait (common functionality)
236impl Protocol for Socks5ClientProtocol {
237    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
238        vec![
239            ParameterDefinition {
240                name: "target_addr".to_string(),
241                description: "Target server address to connect through SOCKS5 proxy (host:port)"
242                    .to_string(),
243                type_hint: "string".to_string(),
244                required: true,
245                example: json!("example.com:80"),
246                default: None,
247            },
248            ParameterDefinition {
249                name: "auth_username".to_string(),
250                description: "Username for SOCKS5 authentication (optional)".to_string(),
251                type_hint: "string".to_string(),
252                required: false,
253                example: json!("user"),
254                default: None,
255            },
256            ParameterDefinition {
257                name: "auth_password".to_string(),
258                description: "Password for SOCKS5 authentication (optional)".to_string(),
259                type_hint: "string".to_string(),
260                required: false,
261                example: json!("password"),
262                default: None,
263            },
264        ]
265    }
266    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
267        vec![
268            ActionDefinition {
269                name: "send_socks5_data".to_string(),
270                description: SEND_DATA_DESCRIPTION.to_string(),
271                parameters: vec![data_parameter(), encoding_parameter()],
272                example: json!({
273                    "type": "send_socks5_data",
274                    "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
275                    "encoding": "utf8"
276                }),
277                log_template: None,
278            },
279            ActionDefinition {
280                name: "disconnect".to_string(),
281                description: "Disconnect from SOCKS5 proxy and target server".to_string(),
282                parameters: vec![],
283                example: json!({
284                    "type": "disconnect"
285                }),
286                log_template: None,
287            },
288        ]
289    }
290    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
291        vec![
292            ActionDefinition {
293                name: "send_socks5_data".to_string(),
294                description: format!(
295                    "Send data through the SOCKS5 tunnel in response to data received from the \
296                     target. {SEND_DATA_DESCRIPTION}"
297                ),
298                parameters: vec![data_parameter(), encoding_parameter()],
299                example: json!({
300                    "type": "send_socks5_data",
301                    "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
302                    "encoding": "utf8"
303                }),
304                log_template: None,
305            },
306            ActionDefinition {
307                name: "wait_for_more".to_string(),
308                description: "Wait for more data before responding".to_string(),
309                parameters: vec![],
310                example: json!({
311                    "type": "wait_for_more"
312                }),
313                log_template: None,
314            },
315        ]
316    }
317    fn protocol_name(&self) -> &'static str {
318        "SOCKS5"
319    }
320    /// Clone the statics the client actually emits, rather than rebuilding them.
321    ///
322    /// The two rebuilt copies carried `{"type": "placeholder", ...}` — an example
323    /// `execute_action` refuses outright as `Unknown SOCKS5 client action: placeholder` — and
324    /// no parameters at all. `get_event_types()` is the copy the model is shown, so it was
325    /// given no fields and one example that cannot work, while the correct `send_socks5_data`
326    /// examples sat unused in the statics above. This is the identical drift the Tor client
327    /// had, fixed the identical way.
328    fn get_event_types(&self) -> Vec<EventType> {
329        vec![
330            SOCKS5_CLIENT_CONNECTED_EVENT.clone(),
331            SOCKS5_CLIENT_DATA_RECEIVED_EVENT.clone(),
332        ]
333    }
334    fn stack_name(&self) -> &'static str {
335        "ETH>IP>TCP>SOCKS5"
336    }
337    fn keywords(&self) -> Vec<&'static str> {
338        vec![
339            "socks5",
340            "socks",
341            "proxy",
342            "socks5 client",
343            "connect via socks5",
344        ]
345    }
346    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
347        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
348
349        ProtocolMetadataV2::builder()
350            .state(DevelopmentState::Experimental)
351            .implementation("tokio-socks library for SOCKS5 protocol")
352            .llm_control(
353                "Full control over target address, authentication, and data flow through proxy",
354            )
355            .e2e_testing("Dante or SS5 SOCKS5 server for testing")
356            .build()
357    }
358    fn description(&self) -> &'static str {
359        "SOCKS5 client for connecting to servers through a SOCKS5 proxy"
360    }
361    fn example_prompt(&self) -> &'static str {
362        "Connect to example.com:80 through SOCKS5 proxy at localhost:1080"
363    }
364    fn group_name(&self) -> &'static str {
365        "Proxy"
366    }
367    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
368        use crate::llm::actions::StartupExamples;
369        use serde_json::json;
370
371        StartupExamples::new(
372            // LLM mode: LLM controls SOCKS5 tunnel
373            json!({
374                "type": "open_client",
375                "remote_addr": "localhost:1080",
376                "base_stack": "socks5",
377                "startup_params": {
378                    "target_addr": "example.com:80"
379                },
380                "instruction": "Send an HTTP GET request through the SOCKS5 tunnel"
381            }),
382            // Script mode: Code-based SOCKS5 handling
383            json!({
384                "type": "open_client",
385                "remote_addr": "localhost:1080",
386                "base_stack": "socks5",
387                "startup_params": {
388                    "target_addr": "example.com:80"
389                },
390                "event_handlers": [{
391                    "event_pattern": "socks5_data_received",
392                    "handler": {
393                        "type": "script",
394                        "language": "python",
395                        "code": "<socks5_client_handler>"
396                    }
397                }]
398            }),
399            // Static mode: Fixed data send through tunnel
400            json!({
401                "type": "open_client",
402                "remote_addr": "localhost:1080",
403                "base_stack": "socks5",
404                "startup_params": {
405                    "target_addr": "example.com:80"
406                },
407                "event_handlers": [
408                    {
409                        "event_pattern": "socks5_connected",
410                        "handler": {
411                            "type": "static",
412                            "actions": [{
413                                "type": "send_socks5_data",
414                                "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
415                                "encoding": "utf8"
416                            }]
417                        }
418                    },
419                    {
420                        "event_pattern": "socks5_data_received",
421                        "handler": {
422                            "type": "static",
423                            "actions": [{
424                                "type": "disconnect"
425                            }]
426                        }
427                    }
428                ]
429            }),
430        )
431    }
432}
433
434// Implement Client trait (client-specific functionality)
435impl Client for Socks5ClientProtocol {
436    fn connect(
437        &self,
438        ctx: crate::protocol::ConnectContext,
439    ) -> std::pin::Pin<
440        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
441    > {
442        Box::pin(async move {
443            use crate::client::socks5::Socks5Client;
444            Socks5Client::connect_with_llm_actions(
445                ctx.remote_addr,
446                ctx.llm_client,
447                ctx.state,
448                ctx.status_tx,
449                ctx.client_id,
450                ctx.startup_params,
451            )
452            .await
453        })
454    }
455    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
456        let action_type = action
457            .get("type")
458            .and_then(|v| v.as_str())
459            .context("Missing 'type' field in action")?;
460
461        match action_type {
462            "send_socks5_data" => Ok(ClientActionResult::SendData(decode_outbound_data(&action)?)),
463            "disconnect" => Ok(ClientActionResult::Disconnect),
464            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
465            _ => Err(anyhow::anyhow!(
466                "Unknown SOCKS5 client action: {}",
467                action_type
468            )),
469        }
470    }
471}