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:
@@ -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"`):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user