Skip to main content

netget/client/stun/
mod.rs

1//! STUN client implementation for NAT traversal discovery
2pub mod actions;
3
4pub use actions::StunClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info};
11
12use crate::client::llm_budget::call_llm_for_client;
13use crate::client::stun::actions::{
14    STUN_CLIENT_BINDING_RESPONSE_EVENT, STUN_CLIENT_CONNECTED_EVENT,
15};
16use crate::llm::actions::client_trait::ClientActionResult;
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::logging::emit::Log;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{AccessLogOwner, ClientId, ClientStatus};
24
25/// STUN client for discovering external IP/port behind NAT
26pub struct StunClient;
27
28impl StunClient {
29    /// Connect to a STUN server with integrated LLM actions
30    pub async fn connect_with_llm_actions(
31        remote_addr: String,
32        llm_client: OllamaClient,
33        app_state: Arc<AppState>,
34        status_tx: mpsc::UnboundedSender<String>,
35        client_id: ClientId,
36    ) -> Result<SocketAddr> {
37        info!("STUN client {} initializing for {}", client_id, remote_addr);
38
39        // Bind local UDP socket (0.0.0.0:0 for any address/port)
40        let local_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
41        let udp_socket = tokio::net::UdpSocket::bind(local_addr)
42            .await
43            .context("Failed to bind UDP socket")?;
44
45        let bound_addr = udp_socket.local_addr()?;
46        info!(
47            "STUN client {} bound to local address {}",
48            client_id, bound_addr
49        );
50
51        // Store socket and STUN server address in protocol_data
52        app_state
53            .with_client_mut(client_id, |client| {
54                client
55                    .set_protocol_field("stun_server".to_string(), serde_json::json!(remote_addr));
56                client.set_protocol_field(
57                    "local_addr".to_string(),
58                    serde_json::json!(bound_addr.to_string()),
59                );
60            })
61            .await;
62
63        // Update status
64        app_state
65            .update_client_status(client_id, ClientStatus::Connected)
66            .await;
67        Log::new(Some(&status_tx)).info(format!(
68            "STUN client {} ready for {}",
69            client_id, remote_addr
70        ));
71        let _ = status_tx.send("__UPDATE_UI__".to_string());
72
73        // Command channel for injected actions (the dashboard's [ send ]).
74        //
75        // Registered BEFORE the connected-event LLM call below: a dashboard-created client
76        // defaults to a `*` -> manual routing rule, so that call can park for minutes waiting
77        // for a human, and [ send ] must work for the whole park.
78        let command_rx =
79            crate::client::command_support::register_command_channel(&app_state, client_id).await;
80
81        // Call LLM with connected event
82        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
83            let protocol = Arc::new(StunClientProtocol::new());
84            let event = Event::new(
85                &STUN_CLIENT_CONNECTED_EVENT,
86                serde_json::json!({
87                    "local_addr": bound_addr.to_string(),
88                    "stun_server": remote_addr,
89                }),
90            );
91
92            let llm_clone = llm_client.clone();
93            let app_state_clone = app_state.clone();
94            let status_tx_clone = status_tx.clone();
95
96            // Registered with AppState so stop_client can abort this task —
97            // dropping a JoinHandle only detaches it in Tokio.
98            let task_registrar = app_state.clone();
99            let task_handle = tokio::spawn(async move {
100                match call_llm_for_client(
101                    &llm_clone,
102                    &app_state_clone,
103                    client_id.to_string(),
104                    &instruction,
105                    "",
106                    Some(&event),
107                    protocol.as_ref(),
108                    &status_tx_clone,
109                )
110                .await
111                {
112                    Ok(ClientLlmResult {
113                        actions,
114                        memory_updates,
115                    }) => {
116                        // Update memory
117                        if let Some(mem) = memory_updates {
118                            app_state_clone.set_memory_for_client(client_id, mem).await;
119                        }
120
121                        // Execute actions
122                        for action in actions {
123                            if let Err(e) = Self::execute_stun_action(
124                                client_id,
125                                action,
126                                app_state_clone.clone(),
127                                llm_clone.clone(),
128                                status_tx_clone.clone(),
129                            )
130                            .await
131                            {
132                                error!("Failed to execute STUN action: {}", e);
133                            }
134                        }
135                    }
136                    Err(e) => {
137                        error!("LLM error for STUN client {}: {}", client_id, e);
138                    }
139                }
140            });
141            task_registrar
142                .register_client_task(client_id, task_handle)
143                .await;
144        }
145
146        // Command loop. This replaces the bare 5-second "has the client been removed yet?"
147        // watchdog that used to live here: it still performs that check, and it is now also
148        // what makes an injected action reach a running STUN client.
149        // Registered with AppState so stop_client can abort this task —
150        // dropping a JoinHandle only detaches it in Tokio.
151        let task_registrar = app_state.clone();
152        let task_handle = tokio::spawn(async move {
153            Self::command_loop(command_rx, client_id, llm_client, app_state, status_tx).await;
154        });
155        task_registrar
156            .register_client_task(client_id, task_handle)
157            .await;
158
159        Ok(bound_addr)
160    }
161
162    /// Execute a STUN action (internal helper)
163    async fn execute_stun_action(
164        client_id: ClientId,
165        action: serde_json::Value,
166        app_state: Arc<AppState>,
167        llm_client: OllamaClient,
168        status_tx: mpsc::UnboundedSender<String>,
169    ) -> Result<()> {
170        use crate::llm::actions::client_trait::Client;
171        let protocol = Arc::new(StunClientProtocol::new());
172
173        match protocol.as_ref().execute_action(action)? {
174            crate::llm::actions::client_trait::ClientActionResult::Custom { name, data: _ } => {
175                if name == "send_binding_request" {
176                    Self::send_binding_request(client_id, app_state, llm_client, status_tx).await?;
177                }
178            }
179            crate::llm::actions::client_trait::ClientActionResult::Disconnect => {
180                app_state
181                    .update_client_status(client_id, ClientStatus::Disconnected)
182                    .await;
183                Log::new(Some(&status_tx)).info(format!("STUN client {} disconnected", client_id));
184                let _ = status_tx.send("__UPDATE_UI__".to_string());
185            }
186            _ => {}
187        }
188
189        Ok(())
190    }
191
192    /// Send STUN binding request and report the result to the model.
193    pub async fn send_binding_request(
194        client_id: ClientId,
195        app_state: Arc<AppState>,
196        llm_client: OllamaClient,
197        status_tx: mpsc::UnboundedSender<String>,
198    ) -> Result<()> {
199        let discovered = Self::query_external_address(client_id, &app_state, &status_tx).await?;
200        Self::report_binding_response(client_id, &discovered, &app_state, &llm_client, &status_tx)
201            .await;
202        Ok(())
203    }
204
205    /// Run one STUN binding exchange and return what it discovered.
206    ///
207    /// Split out from [`Self::send_binding_request`] so the injected-command path can answer
208    /// the caller as soon as the exchange completes and hand the model its
209    /// `stun_binding_response` event afterwards - a manual routing rule parked on that event
210    /// would otherwise hold the dashboard's \[send\] open for its whole timeout.
211    async fn query_external_address(
212        client_id: ClientId,
213        app_state: &Arc<AppState>,
214        status_tx: &mpsc::UnboundedSender<String>,
215    ) -> Result<StunDiscovery> {
216        // Get STUN server address from client
217        let stun_server = app_state
218            .with_client_mut(client_id, |client| {
219                client
220                    .get_protocol_field("stun_server")
221                    .and_then(|v| v.as_str())
222                    .map(|s| s.to_string())
223            })
224            .await
225            .flatten()
226            .context("No STUN server found")?;
227
228        info!(
229            "STUN client {} sending binding request to {}",
230            client_id, stun_server
231        );
232
233        // Resolve STUN server address (may be hostname:port).
234        //
235        // A literal `IP:port` is NOT handed to the resolver. `tokio::net::lookup_host`
236        // calls `getaddrinfo` unconditionally, and on macOS that goes through libinfo to
237        // mDNSResponder — one system-wide daemon that serialises under concurrency, and
238        // which this repo has measured at **8.25 seconds** for `getaddrinfo("127.0.0.1")`
239        // with ~100 processes asking at once. `stunclient`'s end-to-end timeout is 10s,
240        // so a resolver stall of that size eats the whole budget and surfaces as "the
241        // STUN server did not answer" against a server that was never spoken to. The
242        // same trap is documented for reqwest in the root CLAUDE.md; this is the
243        // `lookup_host` spelling of it.
244        //
245        // A hostname is still left to the resolver: resolving it is the resolver's job.
246        let stun_sock_addr: SocketAddr = match stun_server.parse::<SocketAddr>() {
247            Ok(addr) => addr,
248            Err(_) => tokio::net::lookup_host(&stun_server)
249                .await
250                .context(format!("Failed to resolve STUN server: {}", stun_server))?
251                .next()
252                .context("No addresses found for STUN server")?,
253        };
254
255        // Bind a new UDP socket for the query
256        let local_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
257        let udp_socket = tokio::net::UdpSocket::bind(local_addr)
258            .await
259            .context("Failed to bind UDP socket for STUN query")?;
260
261        // Create STUN client and query external address
262        let stun_client = stunclient::StunClient::new(stun_sock_addr);
263
264        match stun_client.query_external_address_async(&udp_socket).await {
265            Ok(external_addr) => {
266                info!(
267                    "STUN client {} discovered external address: {}",
268                    client_id, external_addr
269                );
270
271                let local_addr = udp_socket.local_addr()?;
272
273                Ok(StunDiscovery {
274                    external_addr,
275                    local_addr,
276                    stun_server,
277                })
278            }
279            Err(e) => {
280                Log::new(Some(status_tx)).error(format!(
281                    "STUN client {} binding request failed: {}",
282                    client_id, e
283                ));
284                Err(e.into())
285            }
286        }
287    }
288
289    /// Hand a completed binding exchange to the model as a `stun_binding_response` event.
290    async fn report_binding_response(
291        client_id: ClientId,
292        discovered: &StunDiscovery,
293        app_state: &Arc<AppState>,
294        llm_client: &OllamaClient,
295        status_tx: &mpsc::UnboundedSender<String>,
296    ) {
297        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
298            return;
299        };
300
301        let protocol = Arc::new(StunClientProtocol::new());
302        let event = Event::new(
303            &STUN_CLIENT_BINDING_RESPONSE_EVENT,
304            serde_json::json!({
305                "external_ip": discovered.external_addr.ip().to_string(),
306                "external_port": discovered.external_addr.port(),
307                "external_addr": discovered.external_addr.to_string(),
308                "local_addr": discovered.local_addr.to_string(),
309                "stun_server": discovered.stun_server,
310            }),
311        );
312
313        let memory = app_state
314            .get_memory_for_client(client_id)
315            .await
316            .unwrap_or_default();
317
318        match call_llm_for_client(
319            llm_client,
320            app_state,
321            client_id.to_string(),
322            &instruction,
323            &memory,
324            Some(&event),
325            protocol.as_ref(),
326            status_tx,
327        )
328        .await
329        {
330            Ok(ClientLlmResult {
331                actions,
332                memory_updates,
333            }) => {
334                // Update memory
335                if let Some(mem) = memory_updates {
336                    app_state.set_memory_for_client(client_id, mem).await;
337                }
338
339                // Execute what the model asked for. These were discarded with a note
340                // saying it was "to avoid recursion" -- a real hazard, but the answer is
341                // to dispatch through a path that raises no event rather than to drop the
342                // answer. A model that read a discovered address and wanted to re-probe
343                // (to see whether the mapping is stable, which is the whole point of
344                // repeated STUN binding requests) was silently ignored.
345                //
346                // `query_external_address` performs the exchange and raises nothing, so
347                // one probe cannot drive an unbounded chain and the async type stays
348                // non-recursive, which is what tokio::spawn's Send bound requires.
349                use crate::llm::actions::client_trait::{Client, ClientActionResult};
350                let protocol = crate::client::stun::actions::StunClientProtocol::new();
351                for action in actions {
352                    match protocol.execute_action(action.clone()) {
353                        Ok(ClientActionResult::Custom { name, .. })
354                            if name == "send_binding_request" =>
355                        {
356                            match Self::query_external_address(client_id, app_state, status_tx)
357                                .await
358                            {
359                                Ok(d) => info!(
360                                    "STUN client {} follow-up binding request: {}",
361                                    client_id, d.external_addr
362                                ),
363                                Err(e) => error!(
364                                    "STUN client {} follow-up binding request failed: {}",
365                                    client_id, e
366                                ),
367                            }
368                        }
369                        Ok(_) => {}
370                        Err(e) => error!(
371                            "STUN client {} rejected its own follow-up action: {}",
372                            client_id, e
373                        ),
374                    }
375                }
376            }
377            Err(e) => {
378                error!("LLM error for STUN client {}: {}", client_id, e);
379            }
380        }
381    }
382
383    /// Drain injected commands, and keep the old client-removal watchdog on the same task.
384    ///
385    /// `send_binding_request` reports `Executed`, never `Sent`: the exchange runs inside
386    /// `stunclient`, which owns its own UDP socket and reports no byte count, so there is no
387    /// truthful number to hand back. The detail string carries the discovered external
388    /// address, which is the thing the caller actually wants to see.
389    async fn command_loop(
390        mut command_rx: mpsc::Receiver<ClientCommand>,
391        client_id: ClientId,
392        llm_client: OllamaClient,
393        app_state: Arc<AppState>,
394        status_tx: mpsc::UnboundedSender<String>,
395    ) {
396        use crate::llm::actions::client_trait::Client;
397        use crate::llm::actions::protocol_trait::Protocol;
398
399        let protocol = StunClientProtocol::new();
400
401        loop {
402            let command = tokio::select! {
403                command = command_rx.recv() => match command {
404                    Some(command) => command,
405                    // Channel closed: the client row (and its handle) is gone.
406                    None => break,
407                },
408                _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
409                    // The watchdog this loop replaced: a removed client leaves nothing to
410                    // command, so stop rather than sitting on a dead handle.
411                    if app_state.get_client(client_id).await.is_none() {
412                        info!("STUN client {} stopped", client_id);
413                        break;
414                    }
415                    continue;
416                }
417            };
418
419            let action = command.action.clone();
420
421            // A successful exchange is reported to the model only after the caller has been
422            // answered, so `discovered` carries it past the reply.
423            let mut discovered: Option<StunDiscovery> = None;
424            let outcome = match protocol.execute_action(action.clone()) {
425                Err(e) => Ok(ClientSendOutcome::Rejected {
426                    error: e.to_string(),
427                }),
428                Ok(ClientActionResult::Custom { name, .. }) if name == "send_binding_request" => {
429                    match Self::query_external_address(client_id, &app_state, &status_tx).await {
430                        Ok(result) => {
431                            let detail = format!(
432                                "binding exchange completed via stunclient: external address {} \
433                                 (byte count not observable - stunclient owns the socket)",
434                                result.external_addr
435                            );
436                            discovered = Some(result);
437                            Ok(ClientSendOutcome::Executed { detail })
438                        }
439                        Err(e) => Err(e),
440                    }
441                }
442                Ok(ClientActionResult::Custom { name, .. }) => Ok(ClientSendOutcome::Executed {
443                    detail: format!("custom result '{name}' is not a STUN client verb"),
444                }),
445                Ok(ClientActionResult::Disconnect) => Ok(ClientSendOutcome::Disconnected),
446                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
447                    detail: "wait_for_more: nothing sent".to_string(),
448                }),
449                Ok(_) => Ok(ClientSendOutcome::Executed {
450                    detail: "action produced no STUN exchange".to_string(),
451                }),
452            };
453
454            let outcome_json = match &outcome {
455                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
456                Err(e) => serde_json::json!({"error": e.to_string()}),
457            };
458            app_state
459                .record_access_log(
460                    AccessLogOwner::Client(client_id.as_u32()),
461                    protocol.protocol_name(),
462                    None,
463                    "injected_action",
464                    action,
465                    vec![outcome_json],
466                )
467                .await;
468
469            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
470            if let Err(e) = &outcome {
471                error!("STUN client {} injected action failed: {}", client_id, e);
472                let _ = status_tx.send(format!(
473                    "[WARN] Client {} injected action failed: {}",
474                    client_id, e
475                ));
476            }
477            let _ = status_tx.send("__UPDATE_UI__".to_string());
478            crate::client::command_support::reply(command, outcome);
479
480            if disconnect {
481                app_state
482                    .update_client_status(client_id, ClientStatus::Disconnected)
483                    .await;
484                Log::new(Some(&status_tx)).info(format!(
485                    "STUN client {} disconnected (injected action)",
486                    client_id
487                ));
488                break;
489            }
490
491            if let Some(result) = discovered {
492                Self::report_binding_response(
493                    client_id,
494                    &result,
495                    &app_state,
496                    &llm_client,
497                    &status_tx,
498                )
499                .await;
500            }
501        }
502
503        app_state.remove_client_handle(client_id).await;
504        let _ = status_tx.send("__UPDATE_UI__".to_string());
505    }
506}
507
508/// What one STUN binding exchange discovered.
509struct StunDiscovery {
510    external_addr: SocketAddr,
511    local_addr: SocketAddr,
512    stun_server: String,
513}