use std::io::{ErrorKind, Read};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
#[cfg(feature = "server")]
use std::os::unix::fs::PermissionsExt;
#[cfg(feature = "server")]
use std::os::unix::net::UnixListener;
#[cfg(feature = "server")]
use std::path::Path;
#[cfg(feature = "server")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "server")]
use std::thread::JoinHandle;
#[cfg(feature = "server")]
use std::time::Duration;
#[cfg(feature = "server")]
use purecrypto::rng::{OsRng, RngCore};
#[cfg(feature = "server")]
use crate::error::Result;
#[cfg(feature = "server")]
use crate::server::{AgentForwardContext, AgentForwardHandle, AgentForwardHandler};
use crate::stream::{ChannelEgress, ChannelStream};
#[cfg(feature = "server")]
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(feature = "server")]
struct AgentBinding {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
socket_path: PathBuf,
}
#[cfg(feature = "server")]
impl Drop for AgentBinding {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
let _ = std::fs::remove_file(&self.socket_path);
}
}
#[cfg(feature = "server")]
pub struct DefaultAgentForwardHandler {
parent_dir: Option<PathBuf>,
}
#[cfg(feature = "server")]
impl Default for DefaultAgentForwardHandler {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "server")]
impl DefaultAgentForwardHandler {
pub fn new() -> Self {
Self { parent_dir: None }
}
pub fn with_parent_dir(dir: PathBuf) -> Self {
Self {
parent_dir: Some(dir),
}
}
fn resolve_parent(&self) -> Result<PathBuf> {
if let Some(d) = &self.parent_dir {
return Ok(d.clone());
}
if let Ok(d) = std::env::var("XDG_RUNTIME_DIR")
&& !d.is_empty()
{
return Ok(PathBuf::from(d));
}
ensure_private_tmp_dir()
}
}
#[cfg(feature = "server")]
fn ensure_private_tmp_dir() -> Result<PathBuf> {
let euid = crate::sys::effective_uid()?;
let dir = PathBuf::from(format!("/tmp/puressh-agent-{euid}"));
ensure_private_dir(&dir, euid)?;
Ok(dir)
}
#[cfg(feature = "server")]
fn ensure_private_dir(dir: &Path, euid: u32) -> Result<()> {
use std::os::unix::fs::MetadataExt;
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
return Err(crate::error::Error::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"agent-forward: private dir path exists but is not a real directory",
)));
}
if meta.uid() != euid {
return Err(crate::error::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"agent-forward: private dir is not owned by the current user",
)));
}
if meta.mode() & 0o777 != 0o700 {
return Err(crate::error::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"agent-forward: private dir has unsafe permissions (want 0700)",
)));
}
Ok(())
}
Err(e) if e.kind() == ErrorKind::NotFound => {
std::fs::create_dir(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
Err(e) => Err(crate::error::Error::Io(e)),
}
}
#[cfg(feature = "server")]
impl AgentForwardHandler for DefaultAgentForwardHandler {
fn setup(&self, _user: &str, ctx: AgentForwardContext) -> Result<AgentForwardHandle> {
let parent = self.resolve_parent()?;
let socket_path = mint_socket_path(&parent)?;
if let Ok(meta) = std::fs::symlink_metadata(&socket_path)
&& meta.file_type().is_symlink()
{
return Err(crate::error::Error::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"agent-forward: refusing to bind over a symlink at the per-session socket path",
)));
}
let listener = UnixListener::bind(&socket_path)?;
listener.set_nonblocking(true)?;
let _ = std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600));
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = Arc::clone(&stop);
let handle = thread::spawn(move || {
while !stop_thread.load(Ordering::SeqCst) {
match listener.accept() {
Ok((conn, _peer)) => {
if conn.set_nonblocking(false).is_err() {
continue;
}
match ctx.open_auth_agent() {
Ok(channel_stream) => {
spawn_unix_splice(conn, channel_stream);
}
Err(_) => {
let _ = conn.shutdown(std::net::Shutdown::Both);
}
}
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
thread::sleep(ACCEPT_POLL_INTERVAL);
}
Err(_) => break,
}
}
});
let binding = AgentBinding {
stop,
handle: Some(handle),
socket_path: socket_path.clone(),
};
Ok(AgentForwardHandle {
auth_sock_path: socket_path,
stopper: Box::new(binding),
})
}
}
pub fn splice_unix_stream(uds: UnixStream, stream: ChannelStream) {
spawn_unix_splice(uds, stream)
}
fn spawn_unix_splice(uds: UnixStream, stream: ChannelStream) {
let (chan_rx, chan_tx) = stream.into_raw();
let Ok(uds_in) = uds.try_clone() else {
let _ = chan_tx.send(ChannelEgress::Eof);
let _ = chan_tx.send(ChannelEgress::Close);
return;
};
let uds_out = uds;
let chan_tx_a = chan_tx.clone();
let mut uds_in_a = uds_in;
let a = thread::spawn(move || {
let mut buf = [0u8; 32 * 1024];
loop {
match uds_in_a.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if chan_tx_a
.send(ChannelEgress::Data(buf[..n].to_vec()))
.is_err()
{
break;
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = chan_tx_a.send(ChannelEgress::Eof);
});
let mut uds_out_b = uds_out;
let b = thread::spawn(move || {
super::channel_to_socket(&chan_rx, &mut uds_out_b);
});
thread::spawn(move || {
let _ = a.join();
let _ = b.join();
let _ = chan_tx.send(ChannelEgress::Close);
});
}
#[cfg(feature = "server")]
fn mint_socket_path(parent: &Path) -> Result<PathBuf> {
let mut entropy = [0u8; 8];
OsRng.fill_bytes(&mut entropy);
let suffix = hex_encode(&entropy);
let pid = std::process::id();
Ok(parent.join(format!("puressh-agent.{pid}.{suffix}.sock")))
}
#[deprecated(
since = "0.2.4",
note = "does not bind forwarded agent connections to the session; \
use bound_splice_to_unix_socket_callback"
)]
pub fn splice_to_unix_socket_callback(
path: PathBuf,
) -> Option<Arc<dyn Fn(ChannelStream) + Send + Sync + 'static>> {
if !path.exists() {
return None;
}
Some(Arc::new(move |stream: ChannelStream| {
match UnixStream::connect(&path) {
Ok(uds) => spawn_unix_splice(uds, stream),
Err(_) => close_channel(stream),
}
}))
}
#[deprecated(
since = "0.2.4",
note = "does not bind forwarded agent connections to the session; \
use bound_splice_to_local_agent_callback"
)]
pub fn splice_to_local_agent_callback() -> Option<Arc<dyn Fn(ChannelStream) + Send + Sync + 'static>>
{
let path = local_auth_sock()?;
#[allow(deprecated)]
splice_to_unix_socket_callback(path)
}
#[cfg(feature = "client")]
pub fn bound_splice_to_unix_socket_callback(
path: PathBuf,
binding: Arc<std::sync::OnceLock<crate::client::SessionBinding>>,
) -> Option<Arc<dyn Fn(ChannelStream) + Send + Sync + 'static>> {
if !path.exists() {
return None;
}
Some(Arc::new(move |stream: ChannelStream| {
let Some(b) = binding.get() else {
return close_channel(stream);
};
let Ok(mut agent) = crate::agent::Agent::connect(&path) else {
return close_channel(stream);
};
if agent
.bind_session(&b.host_key, &b.session_id, &b.signature, true)
.is_err()
{
return close_channel(stream);
}
spawn_unix_splice(agent.into_stream(), stream);
}))
}
#[cfg(feature = "client")]
pub fn bound_splice_to_local_agent_callback(
binding: Arc<std::sync::OnceLock<crate::client::SessionBinding>>,
) -> Option<Arc<dyn Fn(ChannelStream) + Send + Sync + 'static>> {
bound_splice_to_unix_socket_callback(local_auth_sock()?, binding)
}
fn local_auth_sock() -> Option<PathBuf> {
let raw = std::env::var("SSH_AUTH_SOCK").ok()?;
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
crate::agent::client::validate_auth_sock(&path).ok()?;
Some(path)
}
fn close_channel(stream: ChannelStream) {
let (_rx, tx) = stream.into_raw();
let _ = tx.send(ChannelEgress::Eof);
let _ = tx.send(ChannelEgress::Close);
}
#[cfg(feature = "server")]
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
struct TestTempDir {
path: PathBuf,
}
impl TestTempDir {
fn new(prefix: &str) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path = std::path::PathBuf::from("/tmp")
.join(format!("p-afwd-{prefix}-{pid:x}-{:x}", nanos as u32));
std::fs::create_dir_all(&path).expect("create tempdir");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestTempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn setup_binds_and_drop_unlinks() {
let dir = TestTempDir::new("setup");
let h = DefaultAgentForwardHandler::with_parent_dir(dir.path().to_path_buf());
let ctx = AgentForwardContext::for_test_no_opens();
let handle = h.setup("u", ctx).expect("setup");
let path = handle.auth_sock_path.clone();
assert!(path.exists(), "socket should exist on disk after setup");
assert!(
path.to_string_lossy()
.starts_with(&format!("{}/puressh-agent.", dir.path().display()))
);
drop(handle);
for _ in 0..50 {
if !path.exists() {
break;
}
thread::sleep(Duration::from_millis(50));
}
assert!(
!path.exists(),
"socket should be unlinked when the handle is dropped (path={path:?})",
);
}
#[test]
fn accepted_connection_is_closed_when_open_fails() {
let dir = TestTempDir::new("closeonfail");
let h = DefaultAgentForwardHandler::with_parent_dir(dir.path().to_path_buf());
let ctx = AgentForwardContext::for_test_no_opens();
let handle = h.setup("u", ctx).expect("setup");
let mut peer = UnixStream::connect(&handle.auth_sock_path).expect("connect");
peer.set_read_timeout(Some(Duration::from_secs(2)))
.expect("read timeout");
let mut buf = [0u8; 1];
let _ = peer.read(&mut buf);
}
#[test]
fn ensure_private_dir_creates_0700() {
use std::os::unix::fs::MetadataExt;
let scratch = TestTempDir::new("epd-create");
let euid = crate::sys::effective_uid().unwrap();
let target = scratch.path().join("priv");
ensure_private_dir(&target, euid).expect("should create");
let meta = std::fs::symlink_metadata(&target).expect("stat");
assert!(meta.file_type().is_dir());
assert_eq!(meta.mode() & 0o777, 0o700, "must be 0700");
assert_eq!(meta.uid(), euid, "must be euid-owned");
ensure_private_dir(&target, euid).expect("reuse should accept");
}
#[test]
fn ensure_private_dir_rejects_loose_mode() {
let scratch = TestTempDir::new("epd-loose");
let euid = crate::sys::effective_uid().unwrap();
let target = scratch.path().join("loose");
std::fs::create_dir(&target).expect("mkdir");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let err = ensure_private_dir(&target, euid).expect_err("0755 must be rejected");
match err {
crate::error::Error::Io(e) => {
assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied)
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn ensure_private_dir_rejects_symlink() {
let scratch = TestTempDir::new("epd-symlink");
let euid = crate::sys::effective_uid().unwrap();
let real = scratch.path().join("real");
std::fs::create_dir(&real).expect("mkdir real");
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).expect("chmod");
let link = scratch.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let err = ensure_private_dir(&link, euid).expect_err("symlink must be rejected");
match err {
crate::error::Error::Io(e) => {
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists)
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn ensure_private_dir_rejects_wrong_owner() {
let scratch = TestTempDir::new("epd-owner");
let target = scratch.path().join("owned");
std::fs::create_dir(&target).expect("mkdir");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)).expect("chmod");
let real_euid = crate::sys::effective_uid().unwrap();
let bogus = real_euid.wrapping_add(1);
let err = ensure_private_dir(&target, bogus).expect_err("wrong owner must be rejected");
match err {
crate::error::Error::Io(e) => {
assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied)
}
other => panic!("unexpected error: {other:?}"),
}
}
}
#[cfg(all(test, feature = "client"))]
mod bound_tests {
use super::*;
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::sync::OnceLock;
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::time::Duration;
const EXTENSION: u8 = 27;
const SUCCESS: u8 = 6;
fn binding() -> crate::client::SessionBinding {
crate::client::SessionBinding {
host_key: b"hostkey".to_vec(),
session_id: b"sessionid".to_vec(),
signature: b"sig".to_vec(),
}
}
fn channel() -> (
ChannelStream,
SyncSender<Option<Vec<u8>>>,
Receiver<ChannelEgress>,
) {
let (in_tx, in_rx) = sync_channel(16);
let (out_tx, out_rx) = sync_channel(16);
(ChannelStream::new(in_rx, out_tx), in_tx, out_rx)
}
fn sock_path(tag: &str) -> PathBuf {
let p = PathBuf::from("/tmp").join(format!("p-abind-{tag}-{:x}", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn read_frame(s: &mut UnixStream) -> Vec<u8> {
let mut len = [0u8; 4];
s.read_exact(&mut len).unwrap();
let mut body = vec![0u8; u32::from_be_bytes(len) as usize];
s.read_exact(&mut body).unwrap();
body
}
#[test]
fn binds_as_forwarded_before_splicing() {
let path = sock_path("ok");
let listener = UnixListener::bind(&path).unwrap();
let cell = Arc::new(OnceLock::new());
let cb = bound_splice_to_unix_socket_callback(path.clone(), cell.clone()).unwrap();
cell.set(binding()).unwrap();
let (stream, in_tx, _out_rx) = channel();
in_tx.send(Some(b"\0\0\0\x01\x0b".to_vec())).unwrap();
let agent = thread::spawn(move || {
let (mut agent, _) = listener.accept().unwrap();
agent
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let bind = read_frame(&mut agent);
agent.write_all(&[0, 0, 0, 1, SUCCESS]).unwrap();
(bind, read_frame(&mut agent))
});
cb(stream);
let (bind, next) = agent.join().unwrap();
let mut w = crate::format::Writer::new();
w.write_u8(EXTENSION);
w.write_string(b"session-bind@openssh.com");
w.write_string(b"hostkey");
w.write_string(b"sessionid");
w.write_string(b"sig");
w.write_u8(1);
assert_eq!(bind, w.as_slice());
assert_eq!(next, [0x0b]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn unbound_channel_is_closed() {
let path = sock_path("unset");
let listener = UnixListener::bind(&path).unwrap();
listener.set_nonblocking(true).unwrap();
let cb =
bound_splice_to_unix_socket_callback(path.clone(), Arc::new(OnceLock::new())).unwrap();
let (stream, _in_tx, out_rx) = channel();
cb(stream);
assert!(matches!(out_rx.recv().unwrap(), ChannelEgress::Eof));
assert!(matches!(out_rx.recv().unwrap(), ChannelEgress::Close));
assert!(listener.accept().is_err());
let _ = std::fs::remove_file(&path);
}
}