Skip to content

Commit 7dc92d7

Browse files
authored
Merge pull request #44 from wiktor-k/client
Add SSH agent client with an example
2 parents 59648f8 + f43e74c commit 7dc92d7

File tree

7 files changed

+274
-36
lines changed

7 files changed

+274
-36
lines changed

Cargo.lock

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ byteorder = "1.4.3"
2323
async-trait = { version = "0.1.77", optional = true }
2424
futures = { version = "0.3.30", optional = true }
2525
log = { version = "0.4.6", optional = true }
26-
tokio = { version = "1", optional = true, features = ["rt", "net"] }
26+
tokio = { version = "1", optional = true, features = ["rt", "net", "time"] }
2727
tokio-util = { version = "0.7.1", optional = true, features = ["codec"] }
28-
service-binding = { version = "^2" }
28+
service-binding = { version = "^2.1" }
2929
ssh-encoding = { version = "0.2.0" }
3030
ssh-key = { version = "0.6.6", features = ["rsa", "alloc"] }
3131
thiserror = "1.0.58"

examples/ssh-agent-client.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use service_binding::Binding;
2+
use ssh_agent_lib::client::connect;
3+
4+
#[tokio::main]
5+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
6+
#[cfg(unix)]
7+
let mut client =
8+
connect(Binding::FilePath(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?).await?;
9+
10+
#[cfg(windows)]
11+
let mut client =
12+
connect(Binding::NamedPipe(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?).await?;
13+
14+
eprintln!(
15+
"Identities that this agent knows of: {:#?}",
16+
client.request_identities().await?
17+
);
18+
19+
Ok(())
20+
}

src/agent.rs

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ impl ListeningSocket for NamedPipeListener {
9191
/// This type is implemented by agents that want to handle incoming SSH agent
9292
/// connections.
9393
#[async_trait]
94-
pub trait Session: 'static + Sync + Send + Sized {
94+
pub trait Session: 'static + Sync + Send + Unpin {
9595
/// Request a list of keys managed by this session.
9696
async fn request_identities(&mut self) -> Result<Vec<Identity>, AgentError> {
9797
Err(AgentError::from(ProtoError::UnsupportedCommand {
@@ -215,37 +215,36 @@ pub trait Session: 'static + Sync + Send + Sized {
215215
}
216216
Ok(Response::Success)
217217
}
218+
}
218219

219-
#[doc(hidden)]
220-
async fn handle_socket<S>(
221-
&mut self,
222-
mut adapter: Framed<S::Stream, Codec<Request, Response>>,
223-
) -> Result<(), AgentError>
224-
where
225-
S: ListeningSocket + fmt::Debug + Send,
226-
{
227-
loop {
228-
if let Some(incoming_message) = adapter.try_next().await? {
229-
log::debug!("Request: {incoming_message:?}");
230-
let response = match self.handle(incoming_message).await {
231-
Ok(message) => message,
232-
Err(AgentError::ExtensionFailure) => {
233-
log::error!("Extension failure handling message");
234-
Response::ExtensionFailure
235-
}
236-
Err(e) => {
237-
log::error!("Error handling message: {:?}", e);
238-
Response::Failure
239-
}
240-
};
241-
log::debug!("Response: {response:?}");
220+
async fn handle_socket<S>(
221+
mut session: impl Session,
222+
mut adapter: Framed<S::Stream, Codec<Request, Response>>,
223+
) -> Result<(), AgentError>
224+
where
225+
S: ListeningSocket + fmt::Debug + Send,
226+
{
227+
loop {
228+
if let Some(incoming_message) = adapter.try_next().await? {
229+
log::debug!("Request: {incoming_message:?}");
230+
let response = match session.handle(incoming_message).await {
231+
Ok(message) => message,
232+
Err(AgentError::ExtensionFailure) => {
233+
log::error!("Extension failure handling message");
234+
Response::ExtensionFailure
235+
}
236+
Err(e) => {
237+
log::error!("Error handling message: {:?}", e);
238+
Response::Failure
239+
}
240+
};
241+
log::debug!("Response: {response:?}");
242242

243-
adapter.send(response).await?;
244-
} else {
245-
// Reached EOF of the stream (client disconnected),
246-
// we can close the socket and exit the handler.
247-
return Ok(());
248-
}
243+
adapter.send(response).await?;
244+
} else {
245+
// Reached EOF of the stream (client disconnected),
246+
// we can close the socket and exit the handler.
247+
return Ok(());
249248
}
250249
}
251250
}
@@ -265,10 +264,10 @@ pub trait Agent: 'static + Sync + Send + Sized {
265264
loop {
266265
match socket.accept().await {
267266
Ok(socket) => {
268-
let mut session = self.new_session();
267+
let session = self.new_session();
269268
tokio::spawn(async move {
270269
let adapter = Framed::new(socket, Codec::<Request, Response>::default());
271-
if let Err(e) = session.handle_socket::<S>(adapter).await {
270+
if let Err(e) = handle_socket::<S>(session, adapter).await {
272271
log::error!("Agent protocol error: {:?}", e);
273272
}
274273
});

src/client.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//! SSH agent client support.
2+
3+
use std::fmt;
4+
5+
use futures::{SinkExt, TryStreamExt};
6+
use ssh_key::Signature;
7+
use tokio::io::{AsyncRead, AsyncWrite};
8+
use tokio_util::codec::Framed;
9+
10+
use crate::{
11+
codec::Codec,
12+
error::AgentError,
13+
proto::{
14+
AddIdentity, AddIdentityConstrained, AddSmartcardKeyConstrained, Extension, Identity,
15+
ProtoError, RemoveIdentity, Request, Response, SignRequest, SmartcardKey,
16+
},
17+
};
18+
19+
/// SSH agent client
20+
#[derive(Debug)]
21+
pub struct Client<Stream>
22+
where
23+
Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Unpin + 'static,
24+
{
25+
adapter: Framed<Stream, Codec<Response, Request>>,
26+
}
27+
28+
impl<Stream> Client<Stream>
29+
where
30+
Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Unpin + 'static,
31+
{
32+
/// Create a new SSH agent client wrapping a given socket.
33+
pub fn new(socket: Stream) -> Self {
34+
let adapter = Framed::new(socket, Codec::default());
35+
Self { adapter }
36+
}
37+
}
38+
39+
/// Wrap a stream into an SSH agent client.
40+
pub async fn connect(
41+
stream: service_binding::Stream,
42+
) -> Result<std::pin::Pin<Box<dyn crate::agent::Session>>, Box<dyn std::error::Error>> {
43+
match stream {
44+
#[cfg(unix)]
45+
service_binding::Stream::Unix(stream) => {
46+
let stream = tokio::net::UnixStream::from_std(stream)?;
47+
Ok(Box::pin(Client::new(stream)))
48+
}
49+
service_binding::Stream::Tcp(stream) => {
50+
let stream = tokio::net::TcpStream::from_std(stream)?;
51+
Ok(Box::pin(Client::new(stream)))
52+
}
53+
#[cfg(windows)]
54+
service_binding::Stream::NamedPipe(pipe) => {
55+
use tokio::net::windows::named_pipe::ClientOptions;
56+
let stream = loop {
57+
// https://docs.rs/windows-sys/latest/windows_sys/Win32/Foundation/constant.ERROR_PIPE_BUSY.html
58+
const ERROR_PIPE_BUSY: u32 = 231u32;
59+
60+
// correct way to do it taken from
61+
// https://docs.rs/tokio/latest/tokio/net/windows/named_pipe/struct.NamedPipeClient.html
62+
match ClientOptions::new().open(&pipe) {
63+
Ok(client) => break client,
64+
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => (),
65+
Err(e) => Err(e)?,
66+
}
67+
68+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
69+
};
70+
Ok(Box::pin(Client::new(stream)))
71+
}
72+
#[cfg(not(windows))]
73+
service_binding::Stream::NamedPipe(_) => Err(ProtoError::IO(std::io::Error::other(
74+
"Named pipes supported on Windows only",
75+
))
76+
.into()),
77+
}
78+
}
79+
80+
#[async_trait::async_trait]
81+
impl<Stream> crate::agent::Session for Client<Stream>
82+
where
83+
Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static,
84+
{
85+
async fn request_identities(&mut self) -> Result<Vec<Identity>, AgentError> {
86+
if let Response::IdentitiesAnswer(identities) =
87+
self.handle(Request::RequestIdentities).await?
88+
{
89+
Ok(identities)
90+
} else {
91+
Err(ProtoError::UnexpectedResponse.into())
92+
}
93+
}
94+
95+
async fn sign(&mut self, request: SignRequest) -> Result<Signature, AgentError> {
96+
if let Response::SignResponse(response) = self.handle(Request::SignRequest(request)).await?
97+
{
98+
Ok(response)
99+
} else {
100+
Err(ProtoError::UnexpectedResponse.into())
101+
}
102+
}
103+
104+
async fn add_identity(&mut self, identity: AddIdentity) -> Result<(), AgentError> {
105+
if let Response::Success = self.handle(Request::AddIdentity(identity)).await? {
106+
Ok(())
107+
} else {
108+
Err(ProtoError::UnexpectedResponse.into())
109+
}
110+
}
111+
112+
async fn add_identity_constrained(
113+
&mut self,
114+
identity: AddIdentityConstrained,
115+
) -> Result<(), AgentError> {
116+
if let Response::Success = self.handle(Request::AddIdConstrained(identity)).await? {
117+
Ok(())
118+
} else {
119+
Err(ProtoError::UnexpectedResponse.into())
120+
}
121+
}
122+
123+
async fn remove_identity(&mut self, identity: RemoveIdentity) -> Result<(), AgentError> {
124+
if let Response::Success = self.handle(Request::RemoveIdentity(identity)).await? {
125+
Ok(())
126+
} else {
127+
Err(ProtoError::UnexpectedResponse.into())
128+
}
129+
}
130+
131+
async fn remove_all_identities(&mut self) -> Result<(), AgentError> {
132+
if let Response::Success = self.handle(Request::RemoveAllIdentities).await? {
133+
Ok(())
134+
} else {
135+
Err(ProtoError::UnexpectedResponse.into())
136+
}
137+
}
138+
139+
async fn add_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> {
140+
if let Response::Success = self.handle(Request::AddSmartcardKey(key)).await? {
141+
Ok(())
142+
} else {
143+
Err(ProtoError::UnexpectedResponse.into())
144+
}
145+
}
146+
147+
async fn add_smartcard_key_constrained(
148+
&mut self,
149+
key: AddSmartcardKeyConstrained,
150+
) -> Result<(), AgentError> {
151+
if let Response::Success = self
152+
.handle(Request::AddSmartcardKeyConstrained(key))
153+
.await?
154+
{
155+
Ok(())
156+
} else {
157+
Err(ProtoError::UnexpectedResponse.into())
158+
}
159+
}
160+
161+
async fn remove_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> {
162+
if let Response::Success = self.handle(Request::RemoveSmartcardKey(key)).await? {
163+
Ok(())
164+
} else {
165+
Err(ProtoError::UnexpectedResponse.into())
166+
}
167+
}
168+
169+
async fn lock(&mut self, key: String) -> Result<(), AgentError> {
170+
if let Response::Success = self.handle(Request::Lock(key)).await? {
171+
Ok(())
172+
} else {
173+
Err(ProtoError::UnexpectedResponse.into())
174+
}
175+
}
176+
177+
async fn unlock(&mut self, key: String) -> Result<(), AgentError> {
178+
if let Response::Success = self.handle(Request::Unlock(key)).await? {
179+
Ok(())
180+
} else {
181+
Err(ProtoError::UnexpectedResponse.into())
182+
}
183+
}
184+
185+
async fn extension(&mut self, extension: Extension) -> Result<Option<Extension>, AgentError> {
186+
match self.handle(Request::Extension(extension)).await? {
187+
Response::Success => Ok(None),
188+
Response::ExtensionResponse(response) => Ok(Some(response)),
189+
_ => Err(ProtoError::UnexpectedResponse.into()),
190+
}
191+
}
192+
193+
async fn handle(&mut self, message: Request) -> Result<Response, AgentError> {
194+
self.adapter.send(message).await?;
195+
if let Some(response) = self.adapter.try_next().await? {
196+
Ok(response)
197+
} else {
198+
Err(ProtoError::IO(std::io::Error::other("server disconnected")).into())
199+
}
200+
}
201+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub mod proto;
77

88
#[cfg(feature = "agent")]
99
pub mod agent;
10+
#[cfg(feature = "agent")]
11+
pub mod client;
1012
#[cfg(feature = "codec")]
1113
pub mod codec;
1214
pub mod error;

src/proto/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ pub enum ProtoError {
2929
/// Command code that was unsupported.
3030
command: u8,
3131
},
32+
33+
/// The client expected a different response.
34+
#[error("Unexpected response received")]
35+
UnexpectedResponse,
3236
}
3337

3438
/// Protocol result.

0 commit comments

Comments
 (0)