1#![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
33pub const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
35
36pub 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
81pub 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 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
139const 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;
148const DEFAULT_AGENT_SOCKET: &str = "./netget-ssh-agent.sock";
154
155const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
156const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
157
158struct ClientData {
160 memory: String,
161}
162
163pub 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
185pub struct SshAgentClient;
187
188impl SshAgentClient {
189 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 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 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 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 let (read_half, write_half) = stream.into_split();
241 let write_half_arc = Arc::new(Mutex::new(AgentWriter::new(write_half)));
242
243 let client_data = Arc::new(Mutex::new(ClientData {
245 memory: String::new(),
246 }));
247
248 let task_registrar = app_state.clone();
252 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 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 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 ¤t_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 pub fn parse_response(data: &[u8]) -> Result<serde_json::Value> {
398 if data.is_empty() {
399 anyhow::bail!("Empty response");
400 }
401
402 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 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 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 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 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 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 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}