Skip to main content

netget/server/tls/
actions.rs

1//! TLS 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;
13
14/// TLS protocol action handler
15pub struct TlsProtocol {}
16
17impl TlsProtocol {
18    pub fn new() -> Self {
19        Self {}
20    }
21}
22
23// Implement Protocol trait (common functionality)
24impl Protocol for TlsProtocol {
25    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
26        vec![
27                crate::llm::actions::ParameterDefinition {
28                    name: "send_first".to_string(),
29                    type_hint: "boolean".to_string(),
30                    description: "Set true when the server speaks first after the handshake (a banner or prompt). It is the only way tls_connection_opened is raised: then nothing the peer sends is read until that event has been answered, a silent answer is logged as a missing greeting, and a failed one closes the connection. Left false (the default), no connect event is raised and a connection costs no model call until the client sends something.".to_string(),
31                    required: false,
32                    example: serde_json::json!(false),
33                    default: None,
34                },
35                crate::llm::actions::ParameterDefinition {
36                    name: "cert_path".to_string(),
37                    type_hint: "string".to_string(),
38                    description: "Path to TLS certificate file (PEM format). If not provided, a self-signed certificate will be generated.".to_string(),
39                    required: false,
40                    example: serde_json::json!("/path/to/cert.pem"),
41                    default: None,
42                },
43                crate::llm::actions::ParameterDefinition {
44                    name: "key_path".to_string(),
45                    type_hint: "string".to_string(),
46                    description: "Path to TLS private key file (PEM format). Required if cert_path is provided.".to_string(),
47                    required: false,
48                    example: serde_json::json!("/path/to/key.pem"),
49                    default: None,
50                },
51                // Three read deadlines, not two, because the handshake wait and the wait for
52                // the first application record face different peers: one that has not proved
53                // it speaks TLS, and one that has. See src/server/tls/mod.rs.
54                crate::llm::actions::ParameterDefinition {
55                    name: "handshake_timeout_secs".to_string(),
56                    type_hint: "number".to_string(),
57                    description: "Seconds a peer that has opened a TCP socket may take to \
58                                  complete the TLS handshake. Default 60. Every real client \
59                                  sends ClientHello immediately and finishes in one \
60                                  round-trip, NetGet's own TLS client included, and nothing in \
61                                  this phase involves the model - so this one does not need to \
62                                  be generous."
63                        .to_string(),
64                    required: false,
65                    example: serde_json::json!(60),
66                    default: Some(serde_json::json!(super::HANDSHAKE_READ_TIMEOUT.as_secs())),
67                },
68                crate::llm::actions::ParameterDefinition {
69                    name: "first_byte_timeout_secs".to_string(),
70                    type_hint: "number".to_string(),
71                    description: "Seconds a peer that has COMPLETED the handshake may send no \
72                                  application record before the server closes it. Default 300, \
73                                  matching the window a `manual` rule gives a human to answer \
74                                  one event. The peer is often NetGet's own TLS client, which \
75                                  handshakes inside connect() and then writes no application \
76                                  bytes until an action or [ send message ] says to. Lower it \
77                                  (60 was the old value) for a listener exposed to strangers."
78                        .to_string(),
79                    required: false,
80                    example: serde_json::json!(300),
81                    default: Some(serde_json::json!(super::FIRST_RECORD_READ_TIMEOUT.as_secs())),
82                },
83                crate::llm::actions::ParameterDefinition {
84                    name: "idle_timeout_secs".to_string(),
85                    type_hint: "number".to_string(),
86                    description: "Seconds a connection that has already carried application \
87                                  data may go without a further record before the server \
88                                  closes it. Default 300. TLS is a carrier, so what counts as \
89                                  idle is a property of whatever rides on it - which is why \
90                                  this is yours to set. Time spent waiting on an answer of \
91                                  ours is not counted against the peer."
92                        .to_string(),
93                    required: false,
94                    example: serde_json::json!(300),
95                    default: Some(serde_json::json!(super::IDLE_AFTER_DATA_TIMEOUT.as_secs())),
96                },
97            ]
98    }
99    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
100        vec![]
101    }
102    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
103        vec![
104            send_tls_data_action(),
105            wait_for_more_action(),
106            close_this_connection_action(),
107        ]
108    }
109    fn protocol_name(&self) -> &'static str {
110        "TLS"
111    }
112    fn get_event_types(&self) -> Vec<EventType> {
113        get_tls_event_types()
114    }
115    fn stack_name(&self) -> &'static str {
116        "ETH>IP>TCP>TLS"
117    }
118    fn keywords(&self) -> Vec<&'static str> {
119        vec!["tls", "ssl", "secure", "encrypted"]
120    }
121    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
122        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
123
124        ProtocolMetadataV2::builder()
125            .state(DevelopmentState::Experimental)
126            .implementation("TLS transport layer using tokio-rustls; self-signed certificate by default, or cert_path/key_path")
127            // Not a record size — rustls bounds those at 2^14 by the spec. This is what one
128            // connection may accumulate while an answer is in flight, which is the number a
129            // peer actually controls: see `MAX_QUEUED_BYTES`.
130            .max_inbound_bytes(crate::server::tls::MAX_QUEUED_BYTES)
131            .llm_control("Full control over application protocol on top of TLS; text or hex payloads")
132            .e2e_testing("openssl s_client / native TLS client")
133            .notes("Generic TLS server for custom protocols - LLM implements application layer. No client certificate authentication (no mTLS)")
134            .build()
135    }
136    fn description(&self) -> &'static str {
137        "Generic TLS server for implementing custom encrypted protocols"
138    }
139    fn example_prompt(&self) -> &'static str {
140        "Listen on port 8443 via TLS; implement a simple chat protocol over encrypted connection"
141    }
142    fn group_name(&self) -> &'static str {
143        "Core"
144    }
145
146    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
147        use crate::llm::actions::StartupExamples;
148
149        // Deterministic: reply "OK" to every decrypted record, no LLM call.
150        let script = r#"import json, sys
151data = json.load(sys.stdin)
152event = data["event"]
153if data["event_type_id"] == "tls_data_received":
154    actions = [{"type": "send_tls_data", "data": "OK\r\n"}]
155else:
156    actions = []
157print(json.dumps({"actions": actions}))"#;
158
159        StartupExamples::new(
160            json!({
161                "type": "open_server",
162                "port": 8443,
163                "base_stack": "tls",
164                "instruction": "Secure TLS server for custom encrypted protocols"
165            }),
166            json!({
167                "type": "open_server",
168                "port": 8443,
169                "base_stack": "tls",
170                "event_handlers": [{
171                    "event_pattern": "tls_data_received",
172                    "handler": {
173                        "type": "script",
174                        "language": "python",
175                        "code": script
176                    }
177                }]
178            }),
179            json!({
180                "type": "open_server",
181                "port": 8443,
182                "base_stack": "tls",
183                "event_handlers": [
184                    {
185                        "event_pattern": "tls_connection_opened",
186                        "handler": {
187                            "type": "static",
188                            "actions": [{
189                                "type": "send_tls_data",
190                                "data": "220 Welcome to secure server\r\n"
191                            }]
192                        }
193                    },
194                    {
195                        "event_pattern": "tls_data_received",
196                        "handler": {
197                            "type": "static",
198                            "actions": [{
199                                "type": "send_tls_data",
200                                "data": "OK\r\n"
201                            }]
202                        }
203                    }
204                ]
205            }),
206        )
207    }
208}
209
210// Implement Server trait (server-specific functionality)
211impl Server for TlsProtocol {
212    fn spawn(
213        &self,
214        ctx: crate::protocol::SpawnContext,
215    ) -> std::pin::Pin<
216        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
217    > {
218        Box::pin(async move {
219            // Extract send_first from startup_params
220            let send_first = ctx
221                .startup_params
222                .as_ref()
223                .map(|p| p.get_optional_bool("send_first"))
224                .transpose()?
225                .flatten()
226                .unwrap_or(false);
227
228            // Extract custom TLS config if provided
229            let tls_config = if let Some(ref params) = ctx.startup_params {
230                let cert_path = params.get_optional_string("cert_path")?;
231                let key_path = params.get_optional_string("key_path")?;
232
233                match (cert_path, key_path) {
234                    (Some(cert), Some(key)) => {
235                        // Load custom certificates from files
236                        Some(crate::server::tls_cert_manager::load_tls_config_from_files(
237                            &cert, &key,
238                        )?)
239                    }
240                    (Some(_), None) | (None, Some(_)) => {
241                        return Err(anyhow::anyhow!(
242                            "Both cert_path and key_path must be provided together"
243                        ));
244                    }
245                    (None, None) => None, // Use default self-signed certificate
246                }
247            } else {
248                None
249            };
250
251            // All three read deadlines are the operator's to choose: who is on the other end
252            // and what rides on this carrier are the only things that decide them, and only
253            // the operator knows either.
254            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
255                Ok(ctx
256                    .startup_params
257                    .as_ref()
258                    .map(|p| p.get_optional_u64(name))
259                    .transpose()?
260                    .flatten())
261            };
262            let handshake_timeout_secs = secs("handshake_timeout_secs")?;
263            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
264            let idle_timeout_secs = secs("idle_timeout_secs")?;
265
266            use crate::server::tls::TlsServer;
267            TlsServer::spawn_with_llm_actions(
268                ctx.legacy_listen_addr(),
269                ctx.llm_client,
270                ctx.state,
271                ctx.status_tx,
272                send_first,
273                ctx.server_id,
274                tls_config,
275                handshake_timeout_secs,
276                first_byte_timeout_secs,
277                idle_timeout_secs,
278            )
279            .await
280        })
281    }
282    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
283        let action_type = action
284            .get("type")
285            .and_then(|v| v.as_str())
286            .context("Missing 'type' field in action")?;
287
288        match action_type {
289            "send_tls_data" => self.execute_send_tls_data(action),
290            "wait_for_more" => Ok(ActionResult::WaitForMore),
291            "close_this_connection" => Ok(ActionResult::CloseConnection),
292            // The dashboard's `[ disconnect this peer ]` injects a bare
293            // `{"type": "close_connection"}` (`src/tui/actions.rs`) whatever the protocol calls
294            // its own close verb, so a server that advertises only `close_this_connection`
295            // answers that button with "Unknown TLS action". Accepted as an alias rather than
296            // advertised — `close_this_connection` stays the one name the model is offered.
297            "close_connection" => Ok(ActionResult::CloseConnection),
298            _ => Err(anyhow::anyhow!("Unknown TLS action: {action_type}")),
299        }
300    }
301}
302
303impl TlsProtocol {
304    /// Execute send_tls_data sync action
305    fn execute_send_tls_data(&self, action: serde_json::Value) -> Result<ActionResult> {
306        let data = action
307            .get("data")
308            .and_then(|v| v.as_str())
309            .context("Missing 'data' parameter")?;
310
311        Ok(ActionResult::Output(decode_outbound_data(data, &action)?))
312    }
313}
314
315/// Turn the `data` field of an outbound action into the exact bytes to put on
316/// the wire, honouring the action's optional `encoding` field.
317///
318/// - `encoding` absent or `"utf8"`: the string's UTF-8 bytes are sent verbatim.
319/// - `encoding` = `"hex"`: `data` is decoded as hex, so `"48656c6c6f"` sends the
320///   five bytes `Hello`.
321///
322/// `tls_data_received` hands binary payloads to the model as hex, so without this
323/// the round trip was asymmetric: a model echoing back what it was given put the
324/// literal ASCII hex digits on the wire.
325///
326/// There is deliberately no auto-detection: `"48656c6c6f"` is both valid text and
327/// valid hex, so the caller must say which it means.
328fn decode_outbound_data(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
329    let encoding = action
330        .get("encoding")
331        .and_then(|v| v.as_str())
332        .unwrap_or("utf8");
333
334    match encoding {
335        "utf8" => Ok(data.as_bytes().to_vec()),
336        "hex" => {
337            // Tolerate whitespace/`0x` grouping that models frequently emit.
338            let cleaned: String = data
339                .chars()
340                .filter(|c| !c.is_ascii_whitespace() && *c != ':')
341                .collect();
342            let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
343
344            if cleaned.len() % 2 != 0 {
345                return Err(anyhow::anyhow!(
346                    "Invalid hex in 'data': expected an even number of hex digits, got {} \
347                     ({data:?}). Each byte is two hex digits, e.g. \"48656c6c6f\" = \"Hello\".",
348                    cleaned.len()
349                ));
350            }
351
352            hex::decode(cleaned).map_err(|e| {
353                anyhow::anyhow!(
354                    "Invalid hex in 'data' ({data:?}): {e}. Use only 0-9/a-f, two digits per \
355                     byte, e.g. \"48656c6c6f\" = \"Hello\". To send this string as literal \
356                     text instead, omit 'encoding' or set it to \"utf8\"."
357                )
358            })
359        }
360        other => Err(anyhow::anyhow!(
361            "Unknown 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
362             string's bytes as-is) and \"hex\" (decode the string as hex first)."
363        )),
364    }
365}
366
367/// Action definition for send_tls_data (sync)
368fn send_tls_data_action() -> ActionDefinition {
369    ActionDefinition {
370        name: "send_tls_data".to_string(),
371        description: "Send data over the current TLS connection (TLS encryption is applied automatically)".to_string(),
372        parameters: vec![
373            Parameter {
374                name: "data".to_string(),
375                type_hint: "string".to_string(),
376                description: "Payload to send. Interpreted according to 'encoding'.".to_string(),
377                required: true,
378            },
379            Parameter {
380                name: "encoding".to_string(),
381                type_hint: "string".to_string(),
382                description: "How to interpret 'data': \"utf8\" (default) sends the string's bytes verbatim; \"hex\" decodes it as hex first, so \"48656c6c6f\" sends the 5 bytes \"Hello\". Use \"hex\" for binary protocols - tls_data_received reports binary payloads as hex.".to_string(),
383                required: false,
384            },
385        ],
386        example: json!({
387            "type": "send_tls_data",
388            "data": "Hello over TLS\r\n"
389        }),
390        log_template: Some(
391            LogTemplate::new()
392                .with_info("-> TLS {data_len}B")
393                .with_debug("TLS send: {data_len} bytes"),
394        ),
395    }
396}
397
398/// Action definition for wait_for_more (sync)
399fn wait_for_more_action() -> ActionDefinition {
400    ActionDefinition {
401        name: "wait_for_more".to_string(),
402        description: "Wait for more data before responding (accumulate incomplete protocol data)"
403            .to_string(),
404        parameters: vec![],
405        example: json!({
406            "type": "wait_for_more"
407        }),
408        log_template: Some(LogTemplate::new().with_debug("TLS waiting for more data")),
409    }
410}
411
412/// Action definition for close_this_connection (sync)
413fn close_this_connection_action() -> ActionDefinition {
414    ActionDefinition {
415        name: "close_this_connection".to_string(),
416        description: "Close the current TLS connection".to_string(),
417        parameters: vec![],
418        example: json!({
419            "type": "close_this_connection"
420        }),
421        log_template: Some(LogTemplate::new().with_info("-> TLS connection closed")),
422    }
423}
424
425// ============================================================================
426// TLS Action Constants
427// ============================================================================
428
429pub static SEND_TLS_DATA_ACTION: LazyLock<ActionDefinition> =
430    LazyLock::new(|| send_tls_data_action());
431pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> =
432    LazyLock::new(|| wait_for_more_action());
433pub static CLOSE_THIS_CONNECTION_ACTION: LazyLock<ActionDefinition> =
434    LazyLock::new(|| close_this_connection_action());
435
436// ============================================================================
437// TLS Event Type Constants
438// ============================================================================
439
440/// TLS connection opened event - triggered when TLS handshake completes
441pub static TLS_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
442    EventType::new(
443        "tls_connection_opened",
444        "TLS handshake complete; the client has sent nothing yet. Raised only when the \
445         server was started with send_first, which means the client is owed a greeting: send \
446         the banner or prompt now with send_tls_data. Nothing the client sends is read until \
447         this is answered.",
448        json!({
449            "type": "send_tls_data",
450            "data": "220 Welcome to secure server\r\n"
451        }),
452    )
453    // Data: `connect_event_data()` - nothing received yet, and the answer that calls for.
454    .with_actions(vec![
455        SEND_TLS_DATA_ACTION.clone(),
456        CLOSE_THIS_CONNECTION_ACTION.clone(),
457    ])
458    .with_parameters(crate::protocol::event_type::connect_event_parameters())
459    .with_log_template(
460        LogTemplate::new()
461            .with_info("{client_ip} TLS connected")
462            .with_debug("TLS handshake complete from {client_ip}"),
463    )
464});
465
466/// TLS data received event - triggered when data is received on encrypted connection
467pub static TLS_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
468    EventType::new(
469        "tls_data_received",
470        "Data received on TLS connection (implement your application protocol here)",
471        json!({
472            "type": "send_tls_data",
473            "data": "OK Data received\r\n"
474        }),
475    )
476    .with_parameters(vec![
477        Parameter {
478            name: "data".to_string(),
479            type_hint: "string".to_string(),
480            description: "The data received. Printable payloads arrive as text; anything else arrives hex-encoded. Check 'encoding' to tell which.".to_string(),
481            required: true,
482        },
483        Parameter {
484            name: "encoding".to_string(),
485            type_hint: "string".to_string(),
486            description: "How 'data' is encoded: \"utf8\" (printable text) or \"hex\" (binary). To echo a hex payload back, pass the same encoding to send_tls_data.".to_string(),
487            required: true,
488        },
489    ])
490    .with_actions(vec![
491        SEND_TLS_DATA_ACTION.clone(),
492        WAIT_FOR_MORE_ACTION.clone(),
493        CLOSE_THIS_CONNECTION_ACTION.clone(),
494    ])
495    .with_log_template(
496        LogTemplate::new()
497            .with_info("{client_ip} TLS <- {data_len}B ({duration_ms}ms)")
498            .with_debug("TLS data from {client_ip}: {data_len} bytes")
499            .with_trace("TLS data: {data}"),
500    )
501});
502
503/// Get TLS event types
504pub fn get_tls_event_types() -> Vec<EventType> {
505    vec![
506        TLS_CONNECTION_OPENED_EVENT.clone(),
507        TLS_DATA_RECEIVED_EVENT.clone(),
508    ]
509}