Add opt-in, security-gated SSH-agent forwarding

Item 3: SSH-agent forwarding, double-gated (client -A/forward_agent AND server
allow_agent_forwarding) and off by default on both ends.

Wire: new ForwardingKind::Agent (appended, existing bincode discriminants
unchanged). Bumped protocol VERSION 2 -> 3 so a pre-agent peer answers with a
clear version-mismatch reject instead of a deserialize error.

Client: -A / --forward-agent flag; requires local SSH_AUTH_SOCK. Requests an
Agent forwarding during native auth (agent forwarding requires native auth) and,
on a server-initiated StreamOpen carrying the reserved @dosh-agent sentinel,
splices the stream into the local agent unix socket (separate agent_writers map,
TCP forwarding paths untouched). Rejects agent StreamOpens unless it opted in, so
a server cannot reach the agent without consent.

Server: when the client opted in and allow_agent_forwarding is set, binds a
per-session proxy unix socket (dir 0700, socket 0600) before the shell spawns,
exports its path as the session's SSH_AUTH_SOCK, and tunnels each connection back
to the client over the agent sentinel stream. Applies only to freshly spawned
shells (documented). Socket cleaned up on auth/forward failure and when the
accept loop ends. SECURITY rationale documented in comments.

Tests: 2 end-to-end integration tests (full chain identities round-trip via a
PTY-hosted client + looping fake agent; and "no -A => no proxy socket"). Docs
updated (README forwarding + disconnect-status note, spec §12.1). fmt + full test
suite green (122 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DuProcess
2026-06-14 11:10:44 -04:00
parent 6b2933eb05
commit 9a52d65beb
7 changed files with 585 additions and 16 deletions
+13 -3
View File
@@ -139,9 +139,13 @@ dosh --session logs palav
Press `Ctrl-]` to detach the current client while leaving the server session alive. Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client. Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings, If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. The old in-band status overlay was removed and attempts ticket-based reconnect. While the link is silent it shows a single,
because writing directly over the terminal could corrupt TUIs; a non-destructive non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
status surface is tracked as a public-readiness item. — reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
`DOSH_DISCONNECT_STATUS=0`.
If SSH and UDP use different public names, specify the UDP address: If SSH and UDP use different public names, specify the UDP address:
@@ -283,6 +287,12 @@ servers:
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic - **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal. bulk transfers do not lag the terminal.
- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your
local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir,
mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only
active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it
applies to freshly spawned shells (not an already-running attached/prewarmed
session, the same constraint ssh/mosh have).
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks. - **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`): Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`):
+19
View File
@@ -444,6 +444,7 @@ CLI:
dosh -L [bind_host:]listen_port:target_host:target_port host dosh -L [bind_host:]listen_port:target_host:target_port host
dosh -R [bind_host:]listen_port:target_host:target_port host dosh -R [bind_host:]listen_port:target_host:target_port host
dosh -D [bind_host:]listen_port host dosh -D [bind_host:]listen_port host
dosh -A host # forward the local ssh-agent (opt-in, server-gated)
``` ```
Stream packet types: Stream packet types:
@@ -473,6 +474,24 @@ Forwarding rules:
- Stream packet scheduling must enforce terminal priority; a large port-forward copy - Stream packet scheduling must enforce terminal priority; a large port-forward copy
cannot make shell keystrokes lag. cannot make shell keystrokes lag.
### 12.1 SSH-Agent Forwarding (opt-in, security-gated)
- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and**
server `allow_agent_forwarding = true`. Off by default on both ends.
- The client requests a `ForwardingKind::Agent` entry during auth. If the server
policy allows it, the server binds a per-session proxy unix socket in a
process-private directory (dir 0700, socket 0600) and exports its path as the
spawned shell's `SSH_AUTH_SOCK`.
- Each connection from a remote process to that proxy socket is tunneled to the
client over a server-initiated `StreamOpen` carrying the reserved target sentinel
`@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK`
(a unix socket, not a TCP target). Data/window/close reuse the existing stream
packets unchanged.
- Only applies to a freshly spawned shell; an already-running attached/prewarmed
session keeps its existing environment (same constraint as ssh/mosh).
- SECURITY: forwarding exposes the local agent to the remote host for the session's
lifetime, which is why it is double-gated and never the default.
## 13. Diagnostics And Operations ## 13. Diagnostics And Operations
Native v1 must include operations commands: Native v1 must include operations commands:
+116 -4
View File
@@ -39,11 +39,17 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::net::{TcpListener, TcpStream, UdpSocket, UnixStream};
use tokio::sync::mpsc; use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Sentinel `target_host` the server uses on a server-initiated `StreamOpen` that
/// represents an SSH-agent connection (rather than a TCP target). The client
/// splices these into its local `SSH_AUTH_SOCK` instead of dialing TCP. The
/// `:0` port and this reserved name are never valid TCP forward targets.
const AGENT_STREAM_SENTINEL: &str = "@dosh-agent";
/// Current terminal size, with a sane fallback. /// Current terminal size, with a sane fallback.
/// ///
/// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it /// `crossterm::size()` returns `Err` when stdout is not a TTY (piped), but it
@@ -89,6 +95,10 @@ struct Args {
remote_forward: Vec<String>, remote_forward: Vec<String>,
#[arg(short = 'D', long = "dynamic-forward")] #[arg(short = 'D', long = "dynamic-forward")]
dynamic_forward: Vec<String>, dynamic_forward: Vec<String>,
/// Forward the local ssh-agent (SSH_AUTH_SOCK) to the remote session.
/// SECURITY: opt-in only; the server must also set allow_agent_forwarding.
#[arg(short = 'A', long = "forward-agent")]
forward_agent: bool,
#[arg(short = 'N', long)] #[arg(short = 'N', long)]
forward_only: bool, forward_only: bool,
#[arg(short = 'f', long)] #[arg(short = 'f', long)]
@@ -200,8 +210,28 @@ async fn main() -> Result<()> {
let local_forwards = parse_local_forwards(&args.local_forward)?; let local_forwards = parse_local_forwards(&args.local_forward)?;
let remote_forwards = parse_remote_forwards(&args.remote_forward)?; let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?; let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?;
let forwarding_requested = // SSH-agent forwarding (opt-in via -A / forward_agent). SECURITY: this exposes
!local_forwards.is_empty() || !remote_forwards.is_empty() || !dynamic_forwards.is_empty(); // your local agent to the remote host for the session's lifetime; only ever
// active on explicit opt-in AND when the server's allow_agent_forwarding is
// set. We capture the LOCAL agent socket path now so the run loop can splice
// each server-initiated agent stream into it.
let forward_agent = args.forward_agent || config.forward_agent;
let agent_sock = if forward_agent {
match std::env::var_os("SSH_AUTH_SOCK") {
Some(path) if !path.is_empty() => Some(PathBuf::from(path)),
_ => {
return Err(anyhow!(
"agent forwarding requested but SSH_AUTH_SOCK is not set"
));
}
}
} else {
None
};
let forwarding_requested = !local_forwards.is_empty()
|| !remote_forwards.is_empty()
|| !dynamic_forwards.is_empty()
|| forward_agent;
let predict = args.predict || host.predict.unwrap_or(config.predict); let predict = args.predict || host.predict.unwrap_or(config.predict);
let session = select_session(args.session.as_deref(), args.new); let session = select_session(args.session.as_deref(), args.new);
let mode = if args.forward_only { let mode = if args.forward_only {
@@ -317,6 +347,7 @@ async fn main() -> Result<()> {
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
false, false,
None,
) )
.await; .await;
} }
@@ -352,6 +383,7 @@ async fn main() -> Result<()> {
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
false, false,
None,
) )
.await; .await;
} }
@@ -385,7 +417,12 @@ async fn main() -> Result<()> {
&mode, &mode,
cols, cols,
rows, rows,
forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards), forwarding_requests(
&local_forwards,
&remote_forwards,
&dynamic_forwards,
forward_agent,
),
resolved_ssh_config.as_ref(), resolved_ssh_config.as_ref(),
cold_requested_env, cold_requested_env,
) )
@@ -412,6 +449,7 @@ async fn main() -> Result<()> {
local_forwards, local_forwards,
dynamic_forwards, dynamic_forwards,
args.forward_only, args.forward_only,
agent_sock,
) )
.await; .await;
} }
@@ -487,6 +525,7 @@ async fn main() -> Result<()> {
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
false, false,
None,
) )
.await .await
} }
@@ -897,6 +936,7 @@ fn forwarding_requests(
local_forwards: &[LocalForward], local_forwards: &[LocalForward],
remote_forwards: &[RemoteForward], remote_forwards: &[RemoteForward],
dynamic_forwards: &[DynamicForward], dynamic_forwards: &[DynamicForward],
forward_agent: bool,
) -> Vec<ForwardingRequest> { ) -> Vec<ForwardingRequest> {
let mut requests = local_forwards let mut requests = local_forwards
.iter() .iter()
@@ -922,6 +962,15 @@ fn forwarding_requests(
target_host: None, target_host: None,
target_port: None, target_port: None,
})); }));
if forward_agent {
requests.push(ForwardingRequest {
kind: ForwardingKind::Agent,
bind_host: None,
listen_port: 0,
target_host: None,
target_port: None,
});
}
requests requests
} }
@@ -2075,6 +2124,7 @@ async fn run_terminal(
local_forwards: Vec<LocalForward>, local_forwards: Vec<LocalForward>,
dynamic_forwards: Vec<DynamicForward>, dynamic_forwards: Vec<DynamicForward>,
forward_only: bool, forward_only: bool,
agent_sock: Option<PathBuf>,
) -> Result<()> { ) -> Result<()> {
let _raw = if forward_only { let _raw = if forward_only {
None None
@@ -2112,6 +2162,10 @@ async fn run_terminal(
start_dynamic_forwards(&dynamic_forwards, forward_tx.clone(), stream_ids).await?; start_dynamic_forwards(&dynamic_forwards, forward_tx.clone(), stream_ids).await?;
signal_background_ready(); signal_background_ready();
let mut stream_writers: HashMap<u64, tokio::net::tcp::OwnedWriteHalf> = HashMap::new(); let mut stream_writers: HashMap<u64, tokio::net::tcp::OwnedWriteHalf> = HashMap::new();
// Write halves for server-initiated SSH-agent streams, spliced into the local
// unix agent socket. Kept separate from the TCP `stream_writers` so the
// existing TCP forwarding paths are untouched.
let mut agent_writers: HashMap<u64, tokio::net::unix::OwnedWriteHalf> = HashMap::new();
let mut pending_socks_replies: HashSet<u64> = HashSet::new(); let mut pending_socks_replies: HashSet<u64> = HashSet::new();
let mut opened_streams: HashSet<u64> = HashSet::new(); let mut opened_streams: HashSet<u64> = HashSet::new();
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new(); let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
@@ -2323,6 +2377,60 @@ async fn run_terminal(
continue; continue;
}; };
last_packet_at = Instant::now(); last_packet_at = Instant::now();
// SSH-agent stream: the server is asking us to splice this
// stream into our LOCAL ssh-agent. Only honor it when we
// actually opted in (agent_sock is Some); otherwise reject,
// so a server cannot reach our agent without consent.
if open.target_host == AGENT_STREAM_SENTINEL {
let Some(sock_path) = agent_sock.clone() else {
send_stream_open_reject(
&socket, addr, &cred, &mut send_seq, open.stream_id,
"agent forwarding not enabled by client".to_string(),
).await?;
continue;
};
match UnixStream::connect(&sock_path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
agent_writers.insert(open.stream_id, writer);
opened_streams.insert(open.stream_id);
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
let forward_tx = remote_forward_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 16 * 1024];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if forward_tx
.send(ForwardEvent::Data {
stream_id: open.stream_id,
bytes: buf[..n].to_vec(),
})
.await
.is_err()
{
return;
}
}
Err(_) => break,
}
}
let _ = forward_tx.send(ForwardEvent::Close {
stream_id: open.stream_id,
}).await;
});
}
Err(err) => {
send_stream_open_reject(
&socket, addr, &cred, &mut send_seq, open.stream_id,
format!("connect local agent: {err}"),
).await?;
}
}
continue;
}
match TcpStream::connect((open.target_host.as_str(), open.target_port)).await { match TcpStream::connect((open.target_host.as_str(), open.target_port)).await {
Ok(stream) => { Ok(stream) => {
let (mut reader, writer) = stream.into_split(); let (mut reader, writer) = stream.into_split();
@@ -2423,6 +2531,8 @@ async fn run_terminal(
let len = data.bytes.len(); let len = data.bytes.len();
if let Some(writer) = stream_writers.get_mut(&data.stream_id) { if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await; let _ = writer.write_all(&data.bytes).await;
} else if let Some(writer) = agent_writers.get_mut(&data.stream_id) {
let _ = writer.write_all(&data.bytes).await;
} }
// Always return flow-control credit so a stream whose local // Always return flow-control credit so a stream whose local
// writer is already gone cannot wedge the server's send window. // writer is already gone cannot wedge the server's send window.
@@ -2467,6 +2577,7 @@ async fn run_terminal(
stream_send_credit.remove(&close.stream_id); stream_send_credit.remove(&close.stream_id);
stream_pending_data.remove(&close.stream_id); stream_pending_data.remove(&close.stream_id);
stream_writers.remove(&close.stream_id); stream_writers.remove(&close.stream_id);
agent_writers.remove(&close.stream_id);
} }
_ => {} _ => {}
} }
@@ -2509,6 +2620,7 @@ async fn run_terminal(
stream_send_credit.remove(&stream_id); stream_send_credit.remove(&stream_id);
stream_pending_data.remove(&stream_id); stream_pending_data.remove(&stream_id);
stream_writers.remove(&stream_id); stream_writers.remove(&stream_id);
agent_writers.remove(&stream_id);
send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?; send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?;
} }
None if forward_only => break, None if forward_only => break,
+178 -8
View File
@@ -21,13 +21,24 @@ use dosh::protocol::{
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session}; use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::net::{TcpListener, TcpStream, UdpSocket, UnixListener};
use tokio::sync::mpsc; use tokio::sync::mpsc;
const STREAM_INITIAL_WINDOW: usize = 1024 * 1024; const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// Sentinel `target_host` sent to the client on a server-initiated `StreamOpen`
/// that carries an SSH-agent connection (the client splices it into its local
/// `SSH_AUTH_SOCK` rather than dialing TCP). Must match the client's constant.
const AGENT_STREAM_SENTINEL: &str = "@dosh-agent";
/// Monotonic counter for unique agent proxy socket filenames within this process.
static AGENT_SOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
/// How long a pending native handshake (ClientHello awaiting UserAuth) is kept /// How long a pending native handshake (ClientHello awaiting UserAuth) is kept
/// before it is swept by the cleanup task. /// before it is swept by the cleanup task.
const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30; const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30;
@@ -765,6 +776,41 @@ async fn handle_native_user_auth(
return Ok(()); return Ok(());
} }
// SSH-agent forwarding (opt-in + policy-gated). When the client requested it
// AND the server allows it, bind a per-session proxy unix socket now so its
// path can be exported as the remote shell's SSH_AUTH_SOCK before the PTY is
// spawned. The accept loop is started after the client_id exists below.
// SECURITY: only ever reached on explicit client opt-in and with
// `allow_agent_forwarding` enabled; the socket is private to this user.
let agent_allowed = {
let locked = state.lock().expect("server state poisoned");
locked.config.allow_agent_forwarding
};
let agent_listener =
if agent_allowed && agent_forwarding_requested(&req.auth.requested_forwardings) {
match bind_agent_proxy_socket() {
Ok(pair) => Some(pair),
Err(err) => {
eprintln!("agent forwarding setup failed, continuing without it: {err:#}");
None
}
}
} else {
None
};
// Env handed to ensure_session: base requested env plus SSH_AUTH_SOCK when
// agent forwarding is active. Note: only applies to a freshly spawned shell;
// an already-running (prewarmed/attached) session keeps its existing env, the
// same constraint ssh/mosh have.
let mut session_env = pending.client.requested_env.clone();
if let Some((_, ref path)) = agent_listener {
session_env.retain(|e| e.name != "SSH_AUTH_SOCK");
session_env.push(EnvVar {
name: "SSH_AUTH_SOCK".to_string(),
value: path.to_string_lossy().to_string(),
});
}
let attached: Result<( let attached: Result<(
[u8; 16], [u8; 16],
[u8; 16], [u8; 16],
@@ -793,13 +839,7 @@ async fn handle_native_user_auth(
if !locked.sessions.contains_key(&session_name) && !locked.config.create_on_attach { if !locked.sessions.contains_key(&session_name) && !locked.config.create_on_attach {
return Err(anyhow!("session does not exist")); return Err(anyhow!("session does not exist"));
} }
locked.ensure_session( locked.ensure_session(&session_name, cols, rows, &mode, &session_env)?;
&session_name,
cols,
rows,
&mode,
&pending.client.requested_env,
)?;
let session_key = pending.session_key; let session_key = pending.session_key;
let key_id = protocol::session_key_id(&session_key); let key_id = protocol::session_key_id(&session_key);
let attach_ticket_psk = crypto::random_32(); let attach_ticket_psk = crypto::random_32();
@@ -877,6 +917,10 @@ async fn handle_native_user_auth(
) = match attached { ) = match attached {
Ok(attached) => attached, Ok(attached) => attached,
Err(err) => { Err(err) => {
// Auth/session setup failed: clean up the agent socket we bound early.
if let Some((_, path)) = agent_listener {
let _ = std::fs::remove_file(&path);
}
return send_reject_to_client(socket, peer, packet.header.conn_id, &format!("{err:#}")) return send_reject_to_client(socket, peer, packet.header.conn_id, &format!("{err:#}"))
.await; .await;
} }
@@ -890,9 +934,16 @@ async fn handle_native_user_auth(
) )
.await .await
{ {
if let Some((_, path)) = agent_listener {
let _ = std::fs::remove_file(&path);
}
remove_client(state, client_id); remove_client(state, client_id);
return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string()).await; return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string()).await;
} }
// Now that the client exists, start accepting forwarded agent connections.
if let Some((listener, path)) = agent_listener {
spawn_agent_forward(state, socket, client_id, listener, path);
}
let ok = NativeAuthOk { let ok = NativeAuthOk {
client_id, client_id,
@@ -1793,6 +1844,123 @@ fn allocate_server_stream_id(state: &Arc<Mutex<ServerState>>) -> u64 {
stream_id stream_id
} }
/// Whether the client requested SSH-agent forwarding during auth.
fn agent_forwarding_requested(forwardings: &[ForwardingRequest]) -> bool {
forwardings.iter().any(|f| f.kind == ForwardingKind::Agent)
}
/// Bind the per-session SSH-agent proxy unix socket, returning its `UnixListener`
/// and path. SECURITY: the socket lives in a process-private directory created
/// 0700 and the socket itself is chmod 0600, so only this user can connect — the
/// forwarded agent is never world-reachable. Called only when the client opted in
/// AND `allow_agent_forwarding` is set; the path is then exported as the remote
/// session's `SSH_AUTH_SOCK`.
fn bind_agent_proxy_socket() -> Result<(UnixListener, PathBuf)> {
let dir = std::env::temp_dir().join(format!("dosh-agent-{}", std::process::id()));
std::fs::create_dir_all(&dir).with_context(|| format!("create agent dir {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod agent dir {}", dir.display()))?;
let n = AGENT_SOCK_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("agent.{}.{n}.sock", std::process::id()));
// Stale socket from a crashed prior run with the same name: remove first.
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path)
.with_context(|| format!("bind agent socket {}", path.display()))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod agent socket {}", path.display()))?;
Ok((listener, path))
}
/// Run the accept loop for a forwarded SSH-agent: each connection from a remote
/// process to the proxy socket is tunneled to the client (which splices it into
/// the user's real ssh-agent) over a Dosh stream using the agent sentinel target.
/// The socket file is removed when the loop ends (client gone / session closed).
fn spawn_agent_forward(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
client_id: [u8; 16],
listener: UnixListener,
socket_path: PathBuf,
) {
let state = Arc::clone(state);
let socket = Arc::clone(socket);
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
// Stop accepting once the client has gone away.
{
let locked = state.lock().expect("server state poisoned");
if locked.lookup_client(&client_id).is_none() {
break;
}
}
let stream_id = allocate_server_stream_id(&state);
let (mut reader, mut writer) = stream.into_split();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(1024);
{
let mut locked = state.lock().expect("server state poisoned");
if let Some(client) = locked.client_mut(&client_id) {
client.stream_writers.insert(stream_id, writer_tx);
client
.stream_send_credit
.insert(stream_id, STREAM_INITIAL_WINDOW);
} else {
break;
}
}
tokio::spawn(async move {
while let Some(bytes) = writer_rx.recv().await {
if writer.write_all(&bytes).await.is_err() {
break;
}
}
});
if let Err(err) = send_stream_open_to_client(
&state,
&socket,
client_id,
stream_id,
AGENT_STREAM_SENTINEL.to_string(),
0,
)
.await
{
eprintln!("agent forward open error: {err:#}");
break;
}
let state = Arc::clone(&state);
let socket = Arc::clone(&socket);
tokio::spawn(async move {
let mut buf = [0u8; 16 * 1024];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if let Err(err) = send_stream_data_to_client(
&state,
&socket,
client_id,
stream_id,
buf[..n].to_vec(),
)
.await
{
eprintln!("agent forward data error: {err:#}");
break;
}
}
Err(_) => break,
}
}
let _ = send_stream_close_to_client(&state, &socket, client_id, stream_id).await;
});
}
let _ = std::fs::remove_file(&socket_path);
});
}
fn is_loopback_bind(host: &str) -> bool { fn is_loopback_bind(host: &str) -> bool {
matches!(host, "127.0.0.1" | "localhost" | "::1") matches!(host, "127.0.0.1" | "localhost" | "::1")
} }
@@ -1823,6 +1991,8 @@ fn stream_open_allowed(
} }
ForwardingKind::Dynamic => true, ForwardingKind::Dynamic => true,
ForwardingKind::Remote => false, ForwardingKind::Remote => false,
// Agent forwarding never authorizes a client-initiated TCP open.
ForwardingKind::Agent => false,
}); });
anyhow::ensure!( anyhow::ensure!(
allowed, allowed,
+10
View File
@@ -91,6 +91,12 @@ pub enum ForwardingKind {
Local, Local,
Remote, Remote,
Dynamic, Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -513,6 +519,10 @@ impl AuthorizedKey {
"authorized key permitopen= cannot authorize remote or dynamic forwarding" "authorized key permitopen= cannot authorize remote or dynamic forwarding"
); );
} }
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
} }
} }
} }
+6 -1
View File
@@ -5,7 +5,12 @@ use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const MAGIC: &[u8; 4] = b"DOSH"; pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 2; // v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 3;
pub const HEADER_LEN: usize = 58; pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a /// Stable, user-facing reason string the server puts in an `AttachReject` when a
+243
View File
@@ -44,6 +44,17 @@ fn write_server_config_with_remote_forwarding(
config config
} }
fn write_server_config_with_agent_forwarding(
dir: &tempfile::TempDir,
port: u16,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("allow_agent_forwarding = true\n");
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_timeout( fn write_server_config_with_timeout(
dir: &tempfile::TempDir, dir: &tempfile::TempDir,
port: u16, port: u16,
@@ -326,6 +337,40 @@ fn fake_ssh_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey
write_agent_packet(&mut stream, &response).unwrap(); write_agent_packet(&mut stream, &response).unwrap();
} }
/// A fake ssh-agent that serves repeated connections and only answers
/// REQUEST_IDENTITIES (enough to prove the forwarded socket reaches the real
/// local agent). Returns the comment string it advertises so the test can match.
fn start_fake_ssh_agent_looping(socket_path: &Path, comment: &'static str) {
let signing = SigningKey::from_bytes(&[77u8; 32]);
let public = VerifyingKey::from(&signing).to_bytes();
let key_blob = ssh_ed25519_public_blob(&public);
let listener = UnixListener::bind(socket_path).unwrap();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let key_blob = key_blob.clone();
thread::spawn(move || {
while let Ok(request) = read_agent_packet(&mut stream) {
// Only the identities request (11) is exercised.
if request.first() == Some(&11) {
let mut response = Vec::new();
response.push(12); // IDENTITIES_ANSWER
response.extend_from_slice(&1u32.to_be_bytes());
write_ssh_string(&mut response, &key_blob);
write_ssh_string(&mut response, comment.as_bytes());
if write_agent_packet(&mut stream, &response).is_err() {
break;
}
} else {
// Unknown request: reply AGENT_FAILURE (5).
let _ = write_agent_packet(&mut stream, &[5]);
}
}
});
}
});
}
fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> { fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 4]; let mut len = [0u8; 4];
stream.read_exact(&mut len)?; stream.read_exact(&mut len)?;
@@ -1557,3 +1602,201 @@ fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() {
reject.reason reject.reason
); );
} }
/// End-to-end SSH-agent forwarding: with -A opt-in AND allow_agent_forwarding on,
/// the server exports a proxy SSH_AUTH_SOCK into the session and tunnels a
/// connection from a "remote process" (here, the test connecting to the proxy
/// socket) all the way back to the client's local ssh-agent. We drive an
/// identities request through the chain and verify the real agent's answer comes
/// back, proving server-proxy -> Dosh stream -> client unix splice -> local agent.
#[test]
fn native_agent_forwarding_round_trip() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_agent_forwarding(&dir, port);
write_native_client_auth(&dir, &config);
// Local fake agent the client will splice forwarded connections into.
let agent_sock = dir.path().join("local-agent.sock");
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
// Controlled TMPDIR so the server's proxy socket path is deterministic and
// isolated to this test (std::env::temp_dir honors TMPDIR).
let tmpdir = dir.path().join("tmp");
fs::create_dir_all(&tmpdir).unwrap();
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
let mut server = Command::new(server_bin)
.arg("serve")
.arg("--config")
.arg(&config)
.env("HOME", dir.path())
.env("TMPDIR", &tmpdir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
// Spawn the client inside a real PTY so it can enter raw mode and stay
// attached to an interactive session (agent forwarding needs a spawned shell).
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
cmd.args([
"--auth",
"native",
"--no-cache",
"-A",
"--session",
"agent-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&port.to_string(),
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// The server's proxy socket is created at session spawn; its path is
// deterministic: TMPDIR/dosh-agent-<server_pid>/agent.<server_pid>.0.sock.
let server_pid = server.id();
let proxy_sock = tmpdir
.join(format!("dosh-agent-{server_pid}"))
.join(format!("agent.{server_pid}.0.sock"));
// Wait for the client to authenticate and the server to spawn the shell +
// bind the proxy socket, then connect as the "remote process" and run an
// identities request through the forwarded chain.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let answer = loop {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = server.kill();
let _ = server.wait();
panic!("agent proxy socket {proxy_sock:?} never became usable");
}
if proxy_sock.exists()
&& let Ok(mut stream) = UnixStream::connect(&proxy_sock)
{
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
if write_agent_packet(&mut stream, &[11]).is_ok()
&& let Ok(answer) = read_agent_packet(&mut stream)
{
break answer;
}
}
thread::sleep(Duration::from_millis(100));
};
let _ = child.kill();
let _ = child.wait();
let _ = server.kill();
let _ = server.wait();
// The answer must be an IDENTITIES_ANSWER (12) carrying our agent's comment,
// proving the bytes traversed the full forwarding chain to the local agent.
assert_eq!(
answer.first(),
Some(&12),
"expected IDENTITIES_ANSWER from the forwarded agent, got {:?}",
answer.first()
);
assert!(
answer
.windows(b"forwarded-agent-key".len())
.any(|w| w == b"forwarded-agent-key"),
"forwarded agent answer should carry the local agent's key comment"
);
}
/// Agent forwarding stays off unless the client opts in: with the server allowing
/// it but no -A, no proxy socket is created.
#[test]
fn native_agent_forwarding_requires_client_opt_in() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_agent_forwarding(&dir, port);
write_native_client_auth(&dir, &config);
let agent_sock = dir.path().join("local-agent.sock");
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
let tmpdir = dir.path().join("tmp");
fs::create_dir_all(&tmpdir).unwrap();
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
let mut server = Command::new(server_bin)
.arg("serve")
.arg("--config")
.arg(&config)
.env("HOME", dir.path())
.env("TMPDIR", &tmpdir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
// Note: NO -A flag.
cmd.args([
"--auth",
"native",
"--no-cache",
"--session",
"no-agent-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&port.to_string(),
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// Give the client time to authenticate and the shell to spawn.
thread::sleep(Duration::from_secs(2));
let server_pid = server.id();
let agent_dir = tmpdir.join(format!("dosh-agent-{server_pid}"));
let _ = child.kill();
let _ = child.wait();
let _ = server.kill();
let _ = server.wait();
// No client opt-in -> the server must not have created any agent socket dir.
assert!(
!agent_dir.exists(),
"agent proxy dir {agent_dir:?} must not exist without client -A opt-in"
);
}