Skip to main content

netget/client/ssh_agent/
mod.rs

1//! SSH Agent client implementation
2//!
3//! Platform: Unix/Linux/macOS (uses Unix domain sockets)
4#![cfg(unix)]
5
6pub mod actions;
7
8pub use actions::SshAgentClientProtocol;
9
10use anyhow::{Context, Result};
11use bytes::{BufMut, Bytes, BytesMut};
12use futures::StreamExt;
13use std::net::SocketAddr;
14use std::path::PathBuf;
15use std::sync::Arc;
16use tokio::io::{AsyncRead, AsyncWriteExt};
17use tokio::net::UnixStream;
18use tokio::sync::{mpsc, Mutex};
19use tokio_util::codec::{FramedRead, LengthDelimitedCodec};
20use tracing::{error, info};
21
22use crate::client::llm_budget::call_llm_for_client;
23use crate::client::ssh_agent::actions::{
24    SSH_AGENT_CLIENT_CONNECTED_EVENT, SSH_AGENT_CLIENT_RESPONSE_RECEIVED_EVENT,
25};
26use crate::llm::actions::client_trait::ClientActionResult;
27use crate::llm::ollama_client::OllamaClient;
28use crate::llm::ClientLlmResult;
29use crate::protocol::Event;
30use crate::state::app_state::AppState;
31use crate::state::{ClientId, ClientStatus};
32
33/// Bound server-controlled SSH agent frame lengths before allocating their bodies.
34pub const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
35
36/// Decode complete SSH agent packets, stripping their four-byte big-endian length.
37/// Keeping framing in the reader preserves partial packets across command-channel
38/// select arms and separates multiple responses received in one socket read.
39pub fn response_reader<R: AsyncRead + Unpin>(reader: R) -> AgentResponseReader<R> {
40    response_reader_with_timeout(reader, crate::client::response_reader::RESPONSE_DEADLINE)
41}
42
43pub fn response_reader_with_timeout<R: AsyncRead + Unpin>(
44    reader: R,
45    timeout: std::time::Duration,
46) -> AgentResponseReader<R> {
47    AgentResponseReader {
48        timeout,
49        inner: FramedRead::new(
50            ProgressReader {
51                inner: reader,
52                received: false,
53            },
54            LengthDelimitedCodec::builder()
55                .max_frame_length(MAX_RESPONSE_BYTES)
56                .new_codec(),
57        ),
58        deadline: None,
59        ended: false,
60    }
61}
62
63struct ProgressReader<R> {
64    inner: R,
65    received: bool,
66}
67impl<R: AsyncRead + Unpin> AsyncRead for ProgressReader<R> {
68    fn poll_read(
69        self: std::pin::Pin<&mut Self>,
70        cx: &mut std::task::Context<'_>,
71        buf: &mut tokio::io::ReadBuf<'_>,
72    ) -> std::task::Poll<std::io::Result<()>> {
73        let this = self.get_mut();
74        let before = buf.filled().len();
75        let result = std::pin::Pin::new(&mut this.inner).poll_read(cx, buf);
76        this.received |= buf.filled().len() > before;
77        result
78    }
79}
80
81/// Keeps an absolute partial-frame deadline across cancellation of `next()`.
82pub struct AgentResponseReader<R> {
83    timeout: std::time::Duration,
84    inner: FramedRead<ProgressReader<R>, LengthDelimitedCodec>,
85    deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
86    ended: bool,
87}
88impl<R: AsyncRead + Unpin> futures::Stream for AgentResponseReader<R> {
89    type Item = std::io::Result<BytesMut>;
90    fn poll_next(
91        self: std::pin::Pin<&mut Self>,
92        cx: &mut std::task::Context<'_>,
93    ) -> std::task::Poll<Option<Self::Item>> {
94        use std::future::Future;
95        let this = self.get_mut();
96        if this.ended {
97            return std::task::Poll::Ready(None);
98        }
99        // Enforce the original deadline even when the final bytes became ready
100        // while a caller had cancelled (and was not polling) `next()`.
101        if this
102            .deadline
103            .as_mut()
104            .is_some_and(|timer| timer.as_mut().poll(cx).is_ready())
105        {
106            this.ended = true;
107            return std::task::Poll::Ready(Some(Err(std::io::Error::new(
108                std::io::ErrorKind::TimedOut,
109                "SSH agent partial frame deadline exceeded",
110            ))));
111        }
112        let had_buffer = !this.inner.read_buffer().is_empty();
113        match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
114            std::task::Poll::Ready(value) => {
115                this.deadline = None;
116                this.inner.get_mut().received = false;
117                return std::task::Poll::Ready(value);
118            }
119            std::task::Poll::Pending => {}
120        }
121        if (had_buffer || this.inner.get_ref().received) && this.deadline.is_none() {
122            this.deadline = Some(Box::pin(tokio::time::sleep(this.timeout)));
123        }
124        if this
125            .deadline
126            .as_mut()
127            .is_some_and(|timer| timer.as_mut().poll(cx).is_ready())
128        {
129            this.ended = true;
130            return std::task::Poll::Ready(Some(Err(std::io::Error::new(
131                std::io::ErrorKind::TimedOut,
132                "SSH agent partial frame deadline exceeded",
133            ))));
134        }
135        std::task::Poll::Pending
136    }
137}
138
139/// SSH Agent message types
140const SSH_AGENTC_REQUEST_IDENTITIES: u8 = 11;
141const SSH_AGENTC_SIGN_REQUEST: u8 = 13;
142const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
143const SSH_AGENTC_REMOVE_IDENTITY: u8 = 18;
144const SSH_AGENTC_REMOVE_ALL_IDENTITIES: u8 = 19;
145
146const SSH_AGENT_FAILURE: u8 = 5;
147const SSH_AGENT_SUCCESS: u8 = 6;
148/// Where an SSH Agent client connects when no address is given.
149///
150/// Matches the `socket_path` default of NetGet's own SSH Agent server, so the pair works out
151/// of the box against fabricated keys. It is emphatically not `$SSH_AUTH_SOCK` - see
152/// `connect_with_llm_actions`.
153const DEFAULT_AGENT_SOCKET: &str = "./netget-ssh-agent.sock";
154
155const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
156const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
157
158/// Memory is shared with the connected/response event flow, never held across a model call.
159struct ClientData {
160    memory: String,
161}
162
163/// A framed writer that permanently loses its transport after an incomplete write.
164/// Taking the transport out before awaiting also covers caller cancellation: its
165/// drop closes the owned Unix write half, and subsequent commands cannot append
166/// a new frame into an unfinished one.
167pub struct AgentWriter<W> {
168    transport: Option<W>,
169}
170impl<W: tokio::io::AsyncWrite + Unpin> AgentWriter<W> {
171    pub fn new(transport: W) -> Self {
172        Self {
173            transport: Some(transport),
174        }
175    }
176
177    async fn shutdown(&mut self) -> std::io::Result<()> {
178        if let Some(mut transport) = self.transport.take() {
179            transport.shutdown().await?;
180        }
181        Ok(())
182    }
183}
184
185/// SSH Agent client that connects to an SSH agent
186pub struct SshAgentClient;
187
188impl SshAgentClient {
189    /// Connect to an SSH Agent with integrated LLM actions
190    pub async fn connect_with_llm_actions(
191        remote_addr: String,
192        llm_client: OllamaClient,
193        app_state: Arc<AppState>,
194        status_tx: mpsc::UnboundedSender<String>,
195        client_id: ClientId,
196    ) -> Result<SocketAddr> {
197        // Resolve the socket path.
198        //
199        // This deliberately does NOT fall back to `$SSH_AUTH_SOCK`. It used to, and that was
200        // the single most dangerous default in this protocol: with no address given, an
201        // LLM-driven client silently attached to the operator's *live* ssh-agent and could
202        // then enumerate their real identities and ask the agent to sign arbitrary bytes with
203        // their real private keys. Nothing about the request said that was happening, and the
204        // model - not the operator - chose what to sign.
205        //
206        // The default is now NetGet's own agent socket, matching the `socket_path` default in
207        // `src/server/ssh_agent/`, so an empty address pairs the client with a NetGet server
208        // whose keys are fabricated. Pointing this at a real agent is still possible and is
209        // still a legitimate thing to do - it just has to be asked for by name.
210        let socket_path = if remote_addr.is_empty() {
211            PathBuf::from(DEFAULT_AGENT_SOCKET)
212        } else {
213            PathBuf::from(&remote_addr)
214        };
215
216        info!(
217            "SSH Agent client {} connecting to {:?}",
218            client_id, socket_path
219        );
220
221        // Connect to Unix socket
222        let stream = UnixStream::connect(&socket_path).await.context(format!(
223            "Failed to connect to SSH Agent at {:?}",
224            socket_path
225        ))?;
226
227        info!("SSH Agent client {} connected", client_id);
228
229        // Update client state
230        app_state
231            .update_client_status(client_id, ClientStatus::Connected)
232            .await;
233        let _ = status_tx.send(format!(
234            "[CLIENT] SSH Agent client {} connected to {:?}",
235            client_id, socket_path
236        ));
237        let _ = status_tx.send("__UPDATE_UI__".to_string());
238
239        // Split stream
240        let (read_half, write_half) = stream.into_split();
241        let write_half_arc = Arc::new(Mutex::new(AgentWriter::new(write_half)));
242
243        // Initialize client data
244        let client_data = Arc::new(Mutex::new(ClientData {
245            memory: String::new(),
246        }));
247
248        // Spawn read loop
249        // Registered with AppState so stop_client can abort this task —
250        // dropping a JoinHandle only detaches it in Tokio.
251        let task_registrar = app_state.clone();
252        // Command channel: lets the dashboard inject actions into this loop
253        // via AppState::send_to_client (see client/command_support.rs).
254        let mut command_rx =
255            crate::client::command_support::register_command_channel(&app_state, client_id).await;
256
257        let command_writer = write_half_arc.clone();
258        let command_state = app_state.clone();
259        let command_status = status_tx.clone();
260        let command_task = tokio::spawn(async move {
261            while let Some(command) = command_rx.recv().await {
262                if Self::handle_injected_command(
263                    &command_writer,
264                    command,
265                    client_id,
266                    &command_state,
267                    &command_status,
268                )
269                .await
270                {
271                    command_state
272                        .update_client_status(client_id, ClientStatus::Disconnected)
273                        .await;
274                    command_state.remove_client_handle(client_id).await;
275                    break;
276                }
277            }
278        });
279        app_state
280            .register_client_task(client_id, command_task)
281            .await;
282        let task_handle = tokio::spawn(async move {
283            let protocol = Arc::new(SshAgentClientProtocol::new());
284            let connected = Event::new(
285                &SSH_AGENT_CLIENT_CONNECTED_EVENT,
286                serde_json::json!({"socket_path": socket_path.to_string_lossy()}),
287            );
288            let result: Result<()> = async {
289                if Self::process_event(
290                    &connected,
291                    client_id,
292                    &llm_client,
293                    &app_state,
294                    &status_tx,
295                    &protocol,
296                    &write_half_arc,
297                    &client_data,
298                )
299                .await?
300                {
301                    return Ok(());
302                }
303                let mut reader = response_reader(read_half);
304                // Frame processing is serial. FramedRead and socket backpressure retain
305                // complete/partial frames while an event handler runs; no queue is discarded.
306                while let Some(frame) = reader.next().await {
307                    let data = Self::parse_response(&frame?)?;
308                    let event = Event::new(&SSH_AGENT_CLIENT_RESPONSE_RECEIVED_EVENT, data);
309                    if Self::process_event(
310                        &event,
311                        client_id,
312                        &llm_client,
313                        &app_state,
314                        &status_tx,
315                        &protocol,
316                        &write_half_arc,
317                        &client_data,
318                    )
319                    .await?
320                    {
321                        break;
322                    }
323                }
324                Ok(())
325            }
326            .await;
327            let status = match result {
328                Ok(()) => ClientStatus::Disconnected,
329                Err(error) => {
330                    error!("SSH agent client {} failed: {}", client_id, error);
331                    ClientStatus::Error(error.to_string())
332                }
333            };
334            app_state.update_client_status(client_id, status).await;
335            app_state.remove_client_handle(client_id).await;
336            let _ = status_tx.send("__UPDATE_UI__".into());
337        });
338        task_registrar
339            .register_client_task(client_id, task_handle)
340            .await;
341
342        // Return dummy socket address (Unix sockets don't have IP addresses)
343        Ok("127.0.0.1:0".parse().unwrap())
344    }
345
346    #[allow(clippy::too_many_arguments)]
347    async fn process_event(
348        event: &Event,
349        client_id: ClientId,
350        llm: &OllamaClient,
351        state: &Arc<AppState>,
352        status: &mpsc::UnboundedSender<String>,
353        protocol: &Arc<SshAgentClientProtocol>,
354        writer: &Arc<Mutex<AgentWriter<tokio::net::unix::OwnedWriteHalf>>>,
355        memory: &Arc<Mutex<ClientData>>,
356    ) -> Result<bool> {
357        use crate::llm::actions::client_trait::Client;
358        let Some(instruction) = state.get_instruction_for_client(client_id).await else {
359            return Ok(false);
360        };
361        let current_memory = memory.lock().await.memory.clone();
362        let ClientLlmResult {
363            actions,
364            memory_updates,
365        } = call_llm_for_client(
366            llm,
367            state,
368            client_id.to_string(),
369            &instruction,
370            &current_memory,
371            Some(event),
372            protocol.as_ref(),
373            status,
374        )
375        .await?;
376        if let Some(value) = memory_updates {
377            memory.lock().await.memory = value;
378        }
379        for action in actions {
380            match protocol.execute_action(action)? {
381                ClientActionResult::Custom { name, data } => {
382                    Self::handle_custom_action(&name, data, client_id, writer, state, status)
383                        .await?
384                }
385                ClientActionResult::Disconnect => {
386                    writer.lock().await.shutdown().await?;
387                    return Ok(true);
388                }
389                ClientActionResult::NoAction | ClientActionResult::WaitForMore => {}
390                _ => anyhow::bail!("unsupported SSH agent action result"),
391            }
392        }
393        Ok(false)
394    }
395
396    /// Parse SSH Agent response message
397    pub fn parse_response(data: &[u8]) -> Result<serde_json::Value> {
398        if data.is_empty() {
399            anyhow::bail!("Empty response");
400        }
401
402        // SSH Agent wire format: [uint32: length][byte: type][data...]
403        // Assume length prefix already consumed by reader
404        let msg_type = data[0];
405        let mut cursor = &data[1..];
406
407        match msg_type {
408            SSH_AGENT_SUCCESS => Ok(serde_json::json!({
409                "response_type": "success",
410                "response_data": {}
411            })),
412            SSH_AGENT_FAILURE => Ok(serde_json::json!({
413                "response_type": "failure",
414                "response_data": {}
415            })),
416            SSH_AGENT_IDENTITIES_ANSWER => {
417                let num_keys = Self::read_uint32(&mut cursor)?;
418                let mut identities = Vec::new();
419
420                for _ in 0..num_keys {
421                    let key_blob = Self::read_string(&mut cursor)?;
422                    let comment = Self::read_string(&mut cursor)?;
423
424                    identities.push(serde_json::json!({
425                        "public_key_blob_hex": hex::encode(key_blob),
426                        "comment": String::from_utf8_lossy(comment),
427                    }));
428                }
429
430                Ok(serde_json::json!({
431                    "response_type": "identities",
432                    "response_data": {
433                        "count": num_keys,
434                        "identities": identities,
435                    }
436                }))
437            }
438            SSH_AGENT_SIGN_RESPONSE => {
439                let signature_blob = Self::read_string(&mut cursor)?;
440
441                Ok(serde_json::json!({
442                    "response_type": "signature",
443                    "response_data": {
444                        "signature_hex": hex::encode(signature_blob),
445                    }
446                }))
447            }
448            _ => {
449                anyhow::bail!("Unknown SSH Agent response type: {}", msg_type);
450            }
451        }
452    }
453
454    /// Encode every action through one implementation shared by injected and model actions.
455    pub fn encode_custom_action(action_name: &str, data: &serde_json::Value) -> Result<Bytes> {
456        let mut message = BytesMut::new();
457        match action_name {
458            "request_identities" => message.put_u8(SSH_AGENTC_REQUEST_IDENTITIES),
459            "remove_all_identities" => message.put_u8(SSH_AGENTC_REMOVE_ALL_IDENTITIES),
460            "sign_request" => {
461                let key = hex::decode(
462                    data["public_key_blob_hex"]
463                        .as_str()
464                        .context("Missing public_key_blob_hex")?,
465                )?;
466                let payload = hex::decode(data["data_hex"].as_str().context("Missing data_hex")?)?;
467                let flags = match data.get("flags") {
468                    None => 0,
469                    Some(value) => u32::try_from(
470                        value
471                            .as_u64()
472                            .context("flags must be an unsigned integer")?,
473                    )
474                    .context("flags exceed u32")?,
475                };
476                message.put_u8(SSH_AGENTC_SIGN_REQUEST);
477                Self::write_string(&mut message, &key)?;
478                Self::write_string(&mut message, &payload)?;
479                message.put_u32(flags);
480            }
481            "add_identity" => {
482                let key_type = data["key_type"].as_str().context("Missing key_type")?;
483                let key = hex::decode(
484                    data["public_key_blob_hex"]
485                        .as_str()
486                        .context("Missing public_key_blob_hex")?,
487                )?;
488                let private = hex::decode(
489                    data["private_key_blob_hex"]
490                        .as_str()
491                        .context("Missing private_key_blob_hex")?,
492                )?;
493                message.put_u8(SSH_AGENTC_ADD_IDENTITY);
494                Self::write_string(&mut message, key_type.as_bytes())?;
495                Self::write_string(&mut message, &key)?;
496                Self::write_string(&mut message, &private)?;
497                Self::write_string(
498                    &mut message,
499                    data["comment"].as_str().unwrap_or("").as_bytes(),
500                )?;
501            }
502            "remove_identity" => {
503                let key = hex::decode(
504                    data["public_key_blob_hex"]
505                        .as_str()
506                        .context("Missing public_key_blob_hex")?,
507                )?;
508                message.put_u8(SSH_AGENTC_REMOVE_IDENTITY);
509                Self::write_string(&mut message, &key)?;
510            }
511            _ => anyhow::bail!("Unknown custom action: {action_name}"),
512        }
513        if message.len() > MAX_RESPONSE_BYTES {
514            anyhow::bail!("SSH agent request exceeds byte cap");
515        }
516        Ok(message.freeze())
517    }
518
519    async fn handle_custom_action(
520        name: &str,
521        data: serde_json::Value,
522        _client_id: ClientId,
523        writer: &Arc<Mutex<AgentWriter<tokio::net::unix::OwnedWriteHalf>>>,
524        _state: &Arc<AppState>,
525        _status: &mpsc::UnboundedSender<String>,
526    ) -> Result<()> {
527        Self::send_message(Self::encode_custom_action(name, &data)?, writer).await?;
528        Ok(())
529    }
530
531    /// Execute an injected action on the actual agent socket and report its wire outcome.
532    pub async fn handle_injected_command<W: tokio::io::AsyncWrite + Unpin>(
533        writer: &Arc<Mutex<AgentWriter<W>>>,
534        command: crate::state::client_handles::ClientCommand,
535        client_id: ClientId,
536        state: &Arc<AppState>,
537        status: &mpsc::UnboundedSender<String>,
538    ) -> bool {
539        use crate::llm::actions::client_trait::Client;
540        use crate::state::client_handles::ClientSendOutcome;
541        let action = command.action.clone();
542        let outcome: Result<ClientSendOutcome> = async {
543            match SshAgentClientProtocol.execute_action(action.clone())? {
544                ClientActionResult::Custom { name, data } => {
545                    let bytes_sent =
546                        Self::send_message(Self::encode_custom_action(&name, &data)?, writer)
547                            .await?;
548                    Ok(ClientSendOutcome::Sent { bytes_sent })
549                }
550                ClientActionResult::Disconnect => {
551                    writer.lock().await.shutdown().await?;
552                    Ok(ClientSendOutcome::Disconnected)
553                }
554                ClientActionResult::NoAction | ClientActionResult::WaitForMore => {
555                    Ok(ClientSendOutcome::Executed {
556                        detail: "no agent packet requested".into(),
557                    })
558                }
559                _ => anyhow::bail!("unsupported SSH agent action result"),
560            }
561        }
562        .await;
563        let disconnected = matches!(&outcome, Ok(ClientSendOutcome::Disconnected));
564        let detail = match &outcome {
565            Ok(value) => serde_json::to_value(value).unwrap_or_default(),
566            Err(error) => serde_json::json!({"error": error.to_string()}),
567        };
568        state
569            .record_access_log(
570                crate::state::AccessLogOwner::Client(client_id.as_u32()),
571                "ssh-agent",
572                None,
573                "injected_action",
574                action,
575                vec![detail],
576            )
577            .await;
578        crate::client::command_support::reply(command, outcome);
579        let _ = status.send("__UPDATE_UI__".into());
580        disconnected
581    }
582
583    async fn send_message<W: tokio::io::AsyncWrite + Unpin>(
584        data: Bytes,
585        writer: &Arc<Mutex<AgentWriter<W>>>,
586    ) -> Result<usize> {
587        Self::send_message_with_timeout(
588            data,
589            writer,
590            crate::client::response_reader::RESPONSE_DEADLINE,
591        )
592        .await
593    }
594
595    /// Bound the complete queued write, including waiting for the writer lock.
596    pub async fn send_message_with_timeout<W: tokio::io::AsyncWrite + Unpin>(
597        data: Bytes,
598        writer: &Arc<Mutex<AgentWriter<W>>>,
599        timeout: std::time::Duration,
600    ) -> Result<usize> {
601        if data.len() > MAX_RESPONSE_BYTES {
602            anyhow::bail!("SSH agent request exceeds byte cap");
603        }
604        let length = u32::try_from(data.len()).context("SSH agent packet length exceeds u32")?;
605        let mut packet = BytesMut::with_capacity(data.len() + 4);
606        packet.put_u32(length);
607        packet.extend_from_slice(&data);
608        tokio::time::timeout(timeout, async {
609            let mut writer = writer.lock().await;
610            let mut transport = writer
611                .transport
612                .take()
613                .context("SSH agent writer is unusable after an interrupted write or disconnect")?;
614            transport.write_all(&packet).await?;
615            writer.transport = Some(transport);
616            Ok::<_, anyhow::Error>(packet.len())
617        })
618        .await
619        .context("SSH agent write deadline exceeded")?
620    }
621
622    /// Read SSH wire format string (uint32 length + bytes)
623    fn read_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
624        if cursor.len() < 4 {
625            anyhow::bail!("Not enough data to read string length");
626        }
627        let len = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
628        *cursor = &cursor[4..];
629
630        if cursor.len() < len {
631            anyhow::bail!("Not enough data to read string of length {}", len);
632        }
633        let result = &cursor[..len];
634        *cursor = &cursor[len..];
635        Ok(result)
636    }
637
638    /// Read SSH wire format uint32
639    fn read_uint32(cursor: &mut &[u8]) -> Result<u32> {
640        if cursor.len() < 4 {
641            anyhow::bail!("Not enough data to read uint32");
642        }
643        let result = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]);
644        *cursor = &cursor[4..];
645        Ok(result)
646    }
647
648    /// Write SSH wire format string (uint32 length + bytes)
649    fn write_string(buffer: &mut BytesMut, data: &[u8]) -> Result<()> {
650        if data.len()
651            > MAX_RESPONSE_BYTES
652                .saturating_sub(buffer.len())
653                .saturating_sub(4)
654        {
655            anyhow::bail!("SSH agent request exceeds byte cap");
656        }
657        buffer.put_u32(u32::try_from(data.len()).context("SSH string length exceeds u32")?);
658        buffer.put_slice(data);
659        Ok(())
660    }
661}