Same treatment for the prose the compiler carries: doc and line comments across src/ and tests/, test assertion and error-message strings (prose surfaces: the tunnel-map registration/establish errors, the key-source decode payload, assertion messages), scripts/matrix.sh comment lines, the fuzz sidecar's comments and README (corpus seeds untouched), and the Cargo.toml comment headers plus the crates.io description. The AGENTS.md no-em-dash rule (convention 14) now holds for the whole tracked tree. Verification: repo-wide git grep is clean (single exempt hit: the verification command's own search pattern), cargo test (18 suites, zero failures, default and per-feature builds via scripts/matrix.sh), clippy --all-targets -D warnings and clippy all-features, fmt --check, doc --no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
979 lines
38 KiB
Rust
979 lines
38 KiB
Rust
//! The client-direction real-tool e2e (`w5-e2e-client`): the
|
|
//! reverse-runner shape re-landed against a **real `sshd`**
|
|
//! (`docs/plans/implementation.md` §Wave 5, the real-tool e2e bullet's
|
|
//! client half). The wave-4 client rows ran against the in-process
|
|
//! fake `sshd` double (`tests/common/sshd_double.rs`); the wave-4 gate
|
|
//! named real-`sshd` e2e wave-5 territory explicitly. This binary
|
|
//! dials a spawned OpenSSH server with the shipped crate: exec
|
|
//! (exit-status parity through a real server's reaping), the
|
|
//! direct-tcpip dial (`-L`-shaped), the `-R` leg (the port-0 mint
|
|
//! against a real server's actually-bound reply, the server-initiated
|
|
//! `forwarded-tcpip` open served through the acceptor registry (the
|
|
//! reverse-runner consumer pattern end to end), the cancel form the cancel form
|
|
//! tearing the real listen down, and the decline rows (the sftp
|
|
//! client-side placeholder decline, the server-refused direct-tcpip
|
|
//! open with the connection staying usable, the host-key policy
|
|
//! mismatch hard-decline, the case-1 pin posture against a real
|
|
//! presented key).
|
|
//!
|
|
//! **Placement decision (recorded, the task's acceptance criterion
|
|
//! 1):** a dedicated `tests/e2e_client.rs` binary reusing
|
|
//! `tests/common`, so the wave-4 client binaries (`tests/client_dial.rs`
|
|
//! / `client_ops.rs` / `client_reverse_listen.rs`) keep their
|
|
//! single-writer posture for `w5-decline-matrix`; this binary owns
|
|
//! only its own rows and its in-file `support` fixture module,
|
|
//! disjoint from `tests/e2e_server.rs` (the matrix task's binaries).
|
|
//!
|
|
//! **The sshd fixture shape (decided + recorded):** a **per-row
|
|
//! spawned** real `sshd`; each row's config knobs differ
|
|
//! (`AllowTcpForwarding` yes/no) and each row dials its own server,
|
|
//! so a shared per-binary instance buys nothing: per-row isolation
|
|
//! (own workdir, own minted ed25519 host key, own probed port) beats
|
|
//! the spawn cost. The knob set: publickey-only
|
|
//! (`PubkeyAuthentication yes`, password + keyboard-interactive off),
|
|
//! `StrictModes no` (the fixture's throwaway workdir carries none of
|
|
//! the real home-dir hygiene), `UsePAM no`, `UseDNS no`, `PidFile
|
|
//! none`, the minted host key + the fixture-`alpha`-authorized
|
|
//! `authorized_keys` at absolute paths, and per-row
|
|
//! `AllowTcpForwarding`. Readiness is a bounded TCP probe against the
|
|
//! dialed port; a server that never comes up (binary absent,
|
|
//! `ssh-keygen` absent, spawn refused, privilege-separation directory
|
|
//! missing, config refused) is the row's **reasoned skip** with the
|
|
//! server's own `-e` log as the reason, never silently green. The
|
|
//! dial rides the decrypt-at-dial path over the in-memory vault
|
|
//! double (`VaultKeySource` over `TestVault`, fixture `alpha` sealed),
|
|
//! and the case-1 pin posture (`MemHostKeyPolicy::pin`) accepts only
|
|
//! the fixture's minted host fingerprint.
|
|
//!
|
|
//! Rows are `#[cfg(unix)]` (the fixture spawns processes) and
|
|
//! feature-gated per row group under the established check-gated
|
|
//! **skipped-with-reason** posture. Every real-tool interaction is
|
|
//! deadline-bounded (a decline must surface a typed value to the
|
|
//! dialer, never a hang. Rows carry `Contract stamp:` doc markers
|
|
//! against `client.md` / `reverse-runner.md` (the greppable
|
|
//! `tests/common` discipline).
|
|
|
|
mod common;
|
|
|
|
#[cfg(unix)]
|
|
mod support {
|
|
#![allow(dead_code)]
|
|
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use alkssh::{DialConfig, DialError, HostKeyPolicy, SshConnection, VaultKeySource, dial};
|
|
|
|
use super::common::{MemHostKeyPolicy, TestVault, fixture};
|
|
|
|
/// One shared row deadline; every real-process interaction is
|
|
/// bounded (the POC-1 §5 posture: a decline never hangs).
|
|
pub const ROW_DEADLINE: Duration = Duration::from_secs(30);
|
|
|
|
/// The sshd candidates probed in order (`sbin` paths first, since
|
|
/// the server binary rarely sits on the user's `PATH`).
|
|
const SSHD_CANDIDATES: &[&str] = &[
|
|
"/usr/sbin/sshd",
|
|
"/usr/local/sbin/sshd",
|
|
"/sbin/sshd",
|
|
"/usr/bin/sshd",
|
|
];
|
|
|
|
/// Prints the row's reasoned skip (never silently green).
|
|
pub fn skip(row: &str, reason: &str) {
|
|
eprintln!("SKIPPED (with reason): {row}: {reason}");
|
|
}
|
|
|
|
/// The `sshd` binary's location, probed once; `None` when the
|
|
/// environment carries no server (every row's reasoned skip).
|
|
pub fn sshd_binary() -> Option<PathBuf> {
|
|
SSHD_CANDIDATES
|
|
.iter()
|
|
.map(PathBuf::from)
|
|
.find(|path| path.is_file())
|
|
}
|
|
|
|
/// True when spawning the probe succeeds at all (the minting
|
|
/// tool's own exit codes are its business).
|
|
pub fn keygen_present() -> bool {
|
|
Command::new("ssh-keygen").arg("-h").output().is_ok()
|
|
}
|
|
|
|
/// The per-row fixture workdir, a throwaway directory under the
|
|
/// temp dir, one per row (the wave-5 e2e-server lesson: no shared
|
|
/// workdirs, no cross-row races).
|
|
fn workdir(row: &str) -> PathBuf {
|
|
let work =
|
|
std::env::temp_dir().join(format!("alkssh-e2e-client-{row}-{}", std::process::id()));
|
|
std::fs::create_dir_all(&work).expect("the row workdir creates");
|
|
work
|
|
}
|
|
|
|
/// Mints one ed25519 host key into the workdir (the independent
|
|
/// issuer) and returns its recorded fingerprint (byte-verbatim
|
|
/// from `ssh-keygen -lf`, the same `SHA256:` format the
|
|
/// fingerprint parity contract pins (the policy's pin comparison
|
|
/// is exact string equality by contract).
|
|
fn mint_host_key(work: &Path) -> String {
|
|
let key_path = work.join("hostkey");
|
|
let minted = Command::new("ssh-keygen")
|
|
.args(["-q", "-t", "ed25519", "-N", "", "-C", "alkssh-e2e-fixture"])
|
|
.arg("-f")
|
|
.arg(&key_path)
|
|
.output()
|
|
.expect("ssh-keygen spawns");
|
|
assert!(
|
|
minted.status.success(),
|
|
"the host key mints; stderr: {}",
|
|
String::from_utf8_lossy(&minted.stderr)
|
|
);
|
|
let listed = Command::new("ssh-keygen")
|
|
.arg("-lf")
|
|
.arg(key_path.with_extension("pub"))
|
|
.output()
|
|
.expect("ssh-keygen -lf spawns");
|
|
let text = String::from_utf8_lossy(&listed.stdout).to_string();
|
|
text.split_whitespace()
|
|
.find(|token| token.starts_with("SHA256:"))
|
|
.expect("the fingerprint line carries a SHA256: fingerprint")
|
|
.to_owned()
|
|
}
|
|
|
|
/// The in-process port allocator for the rows' named binds (the
|
|
/// e2e-server posture verbatim: a monotonic counter over a window
|
|
/// below the kernel's ephemeral range, probe-verified per
|
|
/// allocation; parallel rows never collide).
|
|
pub async fn pick_port() -> u16 {
|
|
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(2000);
|
|
loop {
|
|
let raw = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let port = 2000 + (raw % 15_000) as u16;
|
|
match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
|
|
Ok(listener) => {
|
|
drop(listener);
|
|
return port;
|
|
}
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The spawned real `sshd`, the per-row fixture (the shape
|
|
/// recorded in the module docs). The dial rides the
|
|
/// decrypt-at-dial path over the in-memory vault double; the
|
|
/// case-1 pinned policy accepts exactly the minted host key.
|
|
pub struct SshdFixture {
|
|
pub work: PathBuf,
|
|
pub port: u16,
|
|
pub host_fingerprint: String,
|
|
pub username: String,
|
|
child: tokio::process::Child,
|
|
}
|
|
|
|
impl SshdFixture {
|
|
/// A fixture dialing publickey-only with TCP forwarding
|
|
/// enabled (`AllowTcpForwarding yes`), the op rows' server.
|
|
pub async fn spawn(row: &str) -> Result<Self, String> {
|
|
Self::spawn_with(row, true).await
|
|
}
|
|
|
|
/// A fixture with an explicit `AllowTcpForwarding` knob (the
|
|
/// server-refused direct-tcpip row's `no` posture).
|
|
pub async fn spawn_with(row: &str, forwarding: bool) -> Result<Self, String> {
|
|
let Some(server) = sshd_binary() else {
|
|
return Err("no sshd binary in the environment".to_owned());
|
|
};
|
|
if !keygen_present() {
|
|
return Err("ssh-keygen is unavailable".to_owned());
|
|
}
|
|
|
|
let work = workdir(row);
|
|
let host_fingerprint = mint_host_key(&work);
|
|
|
|
let authorized = work.join("authorized_keys");
|
|
std::fs::write(
|
|
&authorized,
|
|
format!("{} alkssh-e2e-user\n", fixture("alpha").blob),
|
|
)
|
|
.expect("the authorized_keys file writes");
|
|
|
|
let port = pick_port().await;
|
|
let config_path = work.join("sshd_config");
|
|
let log_path = work.join("sshd.log");
|
|
std::fs::write(
|
|
&config_path,
|
|
format!(
|
|
"Port {port}\n\
|
|
ListenAddress 127.0.0.1\n\
|
|
HostKey {host}\n\
|
|
AuthorizedKeysFile {authorized}\n\
|
|
PubkeyAuthentication yes\n\
|
|
PasswordAuthentication no\n\
|
|
KbdInteractiveAuthentication no\n\
|
|
PermitRootLogin prohibit-password\n\
|
|
UsePAM no\n\
|
|
UseDNS no\n\
|
|
StrictModes no\n\
|
|
PidFile none\n\
|
|
LogLevel INFO\n\
|
|
AllowTcpForwarding {forwarding}\n\
|
|
AllowAgentForwarding no\n\
|
|
X11Forwarding no\n",
|
|
host = work.join("hostkey").display(),
|
|
authorized = authorized.display(),
|
|
),
|
|
)
|
|
.expect("the sshd_config writes");
|
|
|
|
let log = std::fs::File::create(&log_path).expect("the sshd log file opens");
|
|
let mut child = tokio::process::Command::new(&server)
|
|
.args(["-D", "-e", "-f"])
|
|
.arg(&config_path)
|
|
.kill_on_drop(true)
|
|
.stderr(log)
|
|
.spawn()
|
|
.map_err(|error| format!("sshd refuses to spawn ({error})"))?;
|
|
|
|
let username = Command::new("id")
|
|
.arg("-un")
|
|
.output()
|
|
.ok()
|
|
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
.filter(|user| !user.is_empty())
|
|
.ok_or_else(|| "the connecting user cannot be resolved (id -un)".to_owned())?;
|
|
|
|
let readiness = tokio::time::timeout(Duration::from_secs(15), async {
|
|
loop {
|
|
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
|
.await
|
|
.is_ok()
|
|
{
|
|
return Ok(());
|
|
}
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
return Err(format!(
|
|
"sshd exits at startup (status {status}): {}",
|
|
log_tail(&log_path)
|
|
));
|
|
}
|
|
Ok(None) => {}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"sshd cannot be polled ({error}): {}",
|
|
log_tail(&log_path)
|
|
));
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
})
|
|
.await;
|
|
match readiness {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(reason)) => {
|
|
let _ = child.kill().await;
|
|
return Err(reason);
|
|
}
|
|
Err(_) => {
|
|
let _ = child.kill().await;
|
|
return Err(format!(
|
|
"sshd never opened its listen port: {}",
|
|
log_tail(&log_path)
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
work,
|
|
port,
|
|
host_fingerprint,
|
|
username,
|
|
child,
|
|
})
|
|
}
|
|
|
|
/// The dialed address (the dial config's `host:port` form).
|
|
pub fn address(&self) -> String {
|
|
format!("127.0.0.1:{}", self.port)
|
|
}
|
|
|
|
/// The case-1 pinned policy; it accepts exactly this fixture's
|
|
/// minted host fingerprint at `127.0.0.1` (the pin posture
|
|
/// consumed against a real presented key). A `Some` pin
|
|
/// substitutes another fixture's fingerprint (the mismatch
|
|
/// row's wrong-pin posture).
|
|
pub fn policy(&self, wrong_pin: Option<&'static str>) -> Arc<MemHostKeyPolicy> {
|
|
let policy = Arc::new(MemHostKeyPolicy::new());
|
|
match wrong_pin {
|
|
Some(pin) => policy.pin("127.0.0.1", pin),
|
|
None => policy.pin("127.0.0.1", &self.host_fingerprint),
|
|
}
|
|
policy
|
|
}
|
|
|
|
/// The authenticated dial over the fixture under the given
|
|
/// policy, the decrypt-at-dial path (fixture `alpha` sealed
|
|
/// into the in-memory vault, decrypted and loaded exactly at
|
|
/// the dial), deadline-bounded.
|
|
pub async fn dial_from(
|
|
&self,
|
|
policy: Arc<MemHostKeyPolicy>,
|
|
) -> Result<SshConnection, DialError> {
|
|
let vault = TestVault::unlocked();
|
|
let source = Arc::new(VaultKeySource::new(
|
|
vault.handle(),
|
|
Some(vault.seal(fixture("alpha"))),
|
|
));
|
|
let config = DialConfig::new(
|
|
self.address(),
|
|
self.username.clone(),
|
|
source,
|
|
Arc::clone(&policy) as Arc<dyn HostKeyPolicy>,
|
|
);
|
|
tokio::time::timeout(ROW_DEADLINE, dial(config))
|
|
.await
|
|
.expect("the dial completes within the row deadline")
|
|
}
|
|
|
|
/// The dial the op rows use, pinned to the fixture's own
|
|
/// key, so the authenticated connection is the expected `Ok`.
|
|
pub async fn dial_default(&self) -> SshConnection {
|
|
self.dial_from(self.policy(None))
|
|
.await
|
|
.expect("the pinned dial succeeds against the real server")
|
|
}
|
|
|
|
/// Stops the spawned server and removes the workdir (best
|
|
/// effort; a panicking row leaves both to the OS).
|
|
pub async fn stop(mut self) {
|
|
let _ = self.child.kill().await;
|
|
let _ = self.child.wait().await;
|
|
let _ = std::fs::remove_dir_all(&self.work);
|
|
}
|
|
}
|
|
|
|
/// The server's captured `-e` diagnostics, the skip reason's raw
|
|
/// input, tail-truncated (best effort; a missing log is no text).
|
|
fn log_tail(log: &Path) -> String {
|
|
let Ok(mut file) = std::fs::File::open(log) else {
|
|
return String::new();
|
|
};
|
|
let mut text = String::new();
|
|
if file.read_to_string(&mut text).is_err() {
|
|
return String::new();
|
|
}
|
|
let from = text.len().saturating_sub(512);
|
|
text[from..]
|
|
.lines()
|
|
.rev()
|
|
.take(4)
|
|
.collect::<Vec<_>>()
|
|
.join(" | ")
|
|
}
|
|
|
|
/// A tiny TCP echo server, the direct-tcpip row's real `-L`
|
|
/// target (the bytes round trip through the server-side bridge
|
|
/// and this server, exactly the `-L` topology).
|
|
pub async fn spawn_echo_server() -> u16 {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("the echo listener binds");
|
|
let port = listener.local_addr().expect("local addr").port();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut socket, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
let (mut read, mut write) = socket.split();
|
|
let _ = tokio::io::copy(&mut read, &mut write).await;
|
|
});
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
/// The bounded byte round trip a data leg runs: the request in,
|
|
/// the half-close (the wire EOF), the echo back, the end.
|
|
pub async fn round_trip<S>(mut socket: S, request: &[u8]) -> Vec<u8>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
tokio::time::timeout(ROW_DEADLINE, async move {
|
|
socket.write_all(request).await.expect("the request writes");
|
|
socket.shutdown().await.expect("the half-close writes");
|
|
let mut responded = Vec::new();
|
|
socket
|
|
.read_to_end(&mut responded)
|
|
.await
|
|
.expect("the echo reads to the end");
|
|
responded
|
|
})
|
|
.await
|
|
.expect("the data leg completes within the row deadline")
|
|
}
|
|
}
|
|
|
|
/// The exec row (the `session` build): the family-typed exec through
|
|
/// a real server: stdout rides `data`, stderr rides
|
|
/// `extended_data(1)`, the exit status rides in-band last, and the
|
|
/// server's reaping reports the program's real exit code (a nonzero
|
|
/// exit proves the parity is the server's, not a canned 0).
|
|
#[cfg(all(unix, feature = "session"))]
|
|
mod exec_row {
|
|
use std::time::Duration;
|
|
|
|
use alktty::ControlMessage;
|
|
use alktty::wire::{ChunkReader, STREAM_CTRL_OUT, STREAM_STDERR, STREAM_STDOUT};
|
|
|
|
use super::support;
|
|
|
|
/// Drains the session's chunk plane until the exit chunk;
|
|
/// returns (stdout, stderr, the exit code in-band, if any). The
|
|
/// deadline bounds a wrong row instead of hanging it.
|
|
async fn read_to_exit(mut session: alkssh::RemoteSession) -> (Vec<u8>, Vec<u8>, Option<i32>) {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
let mut exit = None;
|
|
let mut reader = ChunkReader::new(&mut session.output);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let chunk = match tokio::time::timeout_at(deadline, reader.read_chunk()).await {
|
|
Ok(Ok(chunk)) => chunk,
|
|
_ => break,
|
|
};
|
|
match chunk.stream_type {
|
|
STREAM_STDOUT => stdout.extend_from_slice(&chunk.bytes),
|
|
STREAM_STDERR => stderr.extend_from_slice(&chunk.bytes),
|
|
STREAM_CTRL_OUT => {
|
|
if let Ok(ControlMessage::Exit { code }) =
|
|
ControlMessage::from_slice(&chunk.bytes)
|
|
{
|
|
exit = Some(code);
|
|
}
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
(stdout, stderr, exit)
|
|
}
|
|
|
|
/// - *Contract stamp:* `client.md` §op rows (row 1, the exec
|
|
/// parity) + §The dial surface; the wave-4 double-driven exec
|
|
/// row re-landed against a real `sshd`: the family-typed op
|
|
/// (`exec()`, the command verbatim) through the real server's
|
|
/// own session reaping: stdout separated from stderr, the
|
|
/// real exit status reported in-band last (`exit-status(3)`,
|
|
/// parity, not a canned 0), and the case-1 pinned policy's
|
|
/// accept decision on record against the really-presented key.
|
|
#[tokio::test]
|
|
async fn real_sshd_exec_pumps_streams_and_reports_the_exit_status() {
|
|
let fixture = match support::SshdFixture::spawn("exec").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("exec row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let policy = fixture.policy(None);
|
|
let connection = fixture
|
|
.dial_from(policy.clone())
|
|
.await
|
|
.expect("the pinned dial succeeds against the real server");
|
|
|
|
let session = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
connection.exec("printf out-ok && printf err-bad 1>&2 && exit 3"),
|
|
)
|
|
.await
|
|
.expect("the exec opens within the row deadline")
|
|
.expect("the exec opens against the real server");
|
|
|
|
let (stdout, stderr, exit) = read_to_exit(session).await;
|
|
assert_eq!(stdout, b"out-ok", "stdout rode the data stream");
|
|
assert_eq!(stderr, b"err-bad", "stderr rode extended_data(1)");
|
|
assert_eq!(
|
|
exit,
|
|
Some(3),
|
|
"the real server's reaping reports the program's exit status (parity, not a canned code)"
|
|
);
|
|
assert!(
|
|
!connection.is_closed(),
|
|
"the connection outlives a finished exec"
|
|
);
|
|
|
|
let decisions = policy.decisions();
|
|
assert_eq!(
|
|
decisions.len(),
|
|
1,
|
|
"the dial called the policy exactly once, with the real presented key"
|
|
);
|
|
let decision = &decisions[0];
|
|
assert!(decision.accepted, "the pinned accept is the dial's verdict");
|
|
assert_eq!(decision.presented.host, "127.0.0.1");
|
|
assert_eq!(decision.presented.port, fixture.port);
|
|
assert_eq!(
|
|
decision.presented.fingerprint, fixture.host_fingerprint,
|
|
"the presented fingerprint is the minted host key's recorded one"
|
|
);
|
|
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
}
|
|
|
|
/// The direct-tcpip row (the `tunnel` build): the `-L`-shaped dial
|
|
/// through a real server's forwarding: a real TCP connection to a
|
|
/// live loopback echo server, bytes end to end.
|
|
#[cfg(all(unix, feature = "tunnel"))]
|
|
mod direct_tcpip_row {
|
|
use super::support;
|
|
|
|
/// - *Contract stamp:* `client.md` §op rows (row 2, the
|
|
/// direct-tcpip dial); the wave-4 double-driven `-L` row
|
|
/// re-landed against a real server: `tunnel_direct` opens the
|
|
/// channel on the real wire, the plain byte duplex pumps, and
|
|
/// a **live** TCP listener on the far side answers
|
|
/// byte-for-byte (the server's own connect is exercised, not a
|
|
/// canned echo).
|
|
#[tokio::test]
|
|
async fn real_sshd_direct_tcpip_round_trips_a_live_tcp_connection() {
|
|
let fixture = match support::SshdFixture::spawn("direct-tcpip").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("direct-tcpip row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let connection = fixture.dial_default().await;
|
|
let target_port = support::spawn_echo_server().await;
|
|
|
|
let channel = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
connection.tunnel_direct("127.0.0.1", u32::from(target_port)),
|
|
)
|
|
.await
|
|
.expect("the tunnel op completes within the row deadline")
|
|
.expect("the real server serves the direct-tcpip open");
|
|
|
|
let responded = support::round_trip(channel, b"ping-alkssh-e2e\n").await;
|
|
assert_eq!(
|
|
responded, b"ping-alkssh-e2e\n",
|
|
"the bytes cross the real server's forwarding end to end"
|
|
);
|
|
assert!(!connection.is_closed());
|
|
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
}
|
|
|
|
/// The `-R` rows (the `tunnel` build): the port-0 mint against a real
|
|
/// server's actually-bound reply, the server-initiated
|
|
/// `forwarded-tcpip` open served through the acceptor registry (the
|
|
/// reverse-runner consumer pattern end to end), and the cancel form
|
|
/// tearing the real listen down.
|
|
#[cfg(all(unix, feature = "tunnel"))]
|
|
mod reverse_rows {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use alkssh::{ForwardedTcpip, ReverseEndpoint, TcpipForwardedAcceptor};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
use super::support;
|
|
|
|
/// The acceptor double; it records each open's payload fields and
|
|
/// pumps the duplex as an echo (the assembly's pump shape).
|
|
#[derive(Clone)]
|
|
struct AcceptorDouble {
|
|
opens: tokio::sync::mpsc::UnboundedSender<(String, u32, String, u32)>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl TcpipForwardedAcceptor for AcceptorDouble {
|
|
async fn accept(&self, open: ForwardedTcpip) {
|
|
let mut stream = open.stream;
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 4096];
|
|
loop {
|
|
match stream.read(&mut buf).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(count) => {
|
|
if stream.write_all(&buf[..count]).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let _ = self.opens.send((
|
|
open.connected_address,
|
|
open.connected_port,
|
|
open.originator_address,
|
|
open.originator_port,
|
|
));
|
|
}
|
|
}
|
|
|
|
/// - *Contract stamp:* `client.md` §op rows (row 4, the `-R`
|
|
/// listen + ingest) + `reverse-runner.md` (the consumer
|
|
/// pattern); the wave-4 double-driven row re-landed against a
|
|
/// real server: the port-0 mint returns the **actually-bound**
|
|
/// port (a real server's reply, verified by dialing it), the
|
|
/// server-initiated `forwarded-tcpip` open arrives into the
|
|
/// installed acceptor, and the end-to-end consumer pattern
|
|
/// pumps: an external dialer → sshd's listen → the arrival →
|
|
/// the acceptor's duplex → the echo back.
|
|
#[tokio::test]
|
|
async fn real_sshd_port_zero_mint_serves_the_accepted_arrival_and_the_data_leg() {
|
|
let fixture = match support::SshdFixture::spawn("reverse-mint").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("-R mint row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let connection = fixture.dial_default().await;
|
|
let (opens_tx, mut opens_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
connection
|
|
.acceptors()
|
|
.install_tcpip(Arc::new(AcceptorDouble { opens: opens_tx }))
|
|
.expect("the acceptor slot installs");
|
|
|
|
let listener = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
connection.reverse_listen("127.0.0.1", 0),
|
|
)
|
|
.await
|
|
.expect("the listen completes within the row deadline")
|
|
.expect("the real server grants the forward");
|
|
|
|
let bound = match listener.endpoint() {
|
|
ReverseEndpoint::Tcp { address, port } => {
|
|
assert_eq!(
|
|
address, "127.0.0.1",
|
|
"the wire address is the requested one"
|
|
);
|
|
*port
|
|
}
|
|
other => panic!("the tcp endpoint is the mint's shape, got {other:?}"),
|
|
};
|
|
assert!(
|
|
bound != 0,
|
|
"the mint surfaces the peer-picked port, never the requested 0"
|
|
);
|
|
|
|
let mut dialer = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
tokio::net::TcpStream::connect((
|
|
"127.0.0.1",
|
|
u16::try_from(bound).expect("the bound port fits u16"),
|
|
)),
|
|
)
|
|
.await
|
|
.expect("the dialer connects within the row deadline")
|
|
.expect("the minted port is actually bound (a real server's reply, dialable locally)");
|
|
|
|
let (connected_address, connected_port, originator_address, originator_port) =
|
|
tokio::time::timeout(Duration::from_secs(15), opens_rx.recv())
|
|
.await
|
|
.expect("the arrival lands in time")
|
|
.expect("the acceptor sender lives");
|
|
assert_eq!(connected_address, "127.0.0.1");
|
|
assert_eq!(
|
|
connected_port, bound,
|
|
"the arrival echoes the listen request"
|
|
);
|
|
assert_eq!(originator_address, "127.0.0.1");
|
|
assert!(
|
|
originator_port != 0,
|
|
"the originator is the external dialer's ephemeral source port"
|
|
);
|
|
|
|
let responded = support::round_trip(&mut dialer, b"reverse-leg-marker\n").await;
|
|
assert_eq!(
|
|
responded, b"reverse-leg-marker\n",
|
|
"the consumer pattern's data leg round trips through the real server"
|
|
);
|
|
|
|
assert!(!connection.is_closed());
|
|
drop(listener);
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* `client.md` §Lifecycle primitives (the
|
|
/// cancel form); the wave-4 double-driven cancel row
|
|
/// re-landed against a real server: an explicit-port listen is
|
|
/// actually bound (dialable), the listener handle's `cancel()`
|
|
/// tears the **real** listen down (a later dial is refused), a
|
|
/// stateless cancel of the unknown listener is the wire's
|
|
/// `ListenRefused`, and the connection stays usable.
|
|
#[tokio::test]
|
|
async fn real_sshd_cancel_tears_the_listen_down_and_unknown_cancels_refuse() {
|
|
let fixture = match support::SshdFixture::spawn("reverse-cancel").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("-R cancel row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let connection = fixture.dial_default().await;
|
|
let (opens_tx, _opens_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
connection
|
|
.acceptors()
|
|
.install_tcpip(Arc::new(AcceptorDouble { opens: opens_tx }))
|
|
.expect("the acceptor slot installs");
|
|
|
|
let port = support::pick_port().await;
|
|
let listener = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
connection.reverse_listen("127.0.0.1", u32::from(port)),
|
|
)
|
|
.await
|
|
.expect("the listen completes within the row deadline")
|
|
.expect("the real server grants the forward");
|
|
assert_eq!(
|
|
listener.endpoint().bound_port(),
|
|
Some(u32::from(port)),
|
|
"an explicit-port listen surfaces the requested port (the wire's mint data is absent)"
|
|
);
|
|
|
|
let _bound_conn = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
|
)
|
|
.await
|
|
.expect("the probe connects within the row deadline")
|
|
.expect("the explicit port is actually bound before the cancel");
|
|
|
|
tokio::time::timeout(support::ROW_DEADLINE, listener.cancel())
|
|
.await
|
|
.expect("the cancel completes within the row deadline")
|
|
.expect("the real listen tears down");
|
|
|
|
let mut torn_down = false;
|
|
for _ in 0..40 {
|
|
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
|
|
Ok(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
|
Err(_) => {
|
|
torn_down = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
torn_down,
|
|
"the cancel frees the real bound port; connects are refused afterwards"
|
|
);
|
|
|
|
let again = connection
|
|
.reverse_listen_cancel("127.0.0.1", u32::from(port))
|
|
.await;
|
|
assert!(
|
|
matches!(again, Err(alkssh::OpError::ListenRefused)),
|
|
"the real server refuses the unknown listener, got {again:?}"
|
|
);
|
|
assert!(
|
|
!connection.is_closed(),
|
|
"the connection stays usable after every cancel class"
|
|
);
|
|
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
}
|
|
|
|
/// The decline rows: the sftp placeholder (every build) and the
|
|
/// host-key policy mismatch (every build): both dial a real server
|
|
/// and pin typed declines, feature-free.
|
|
#[cfg(unix)]
|
|
mod declines {
|
|
use std::sync::Arc;
|
|
|
|
use alkssh::{DialError, OpError};
|
|
|
|
use super::common::fixture as key_fixture;
|
|
use super::support;
|
|
|
|
/// - *Contract stamp:* OQ-13 + `client.md` §op rows (row 5):
|
|
/// the sftp placeholder declines in **every** build against a
|
|
/// real authenticated connection: typed decline before any
|
|
/// wire op (the real server observes no subsystem request),
|
|
/// connection intact.
|
|
#[tokio::test]
|
|
async fn real_connection_sftp_placeholder_declines_with_the_connection_intact() {
|
|
let fixture = match support::SshdFixture::spawn("sftp-decline").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("sftp decline row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let connection = fixture.dial_default().await;
|
|
let err = tokio::time::timeout(support::ROW_DEADLINE, connection.sftp())
|
|
.await
|
|
.expect("the op completes within the row deadline")
|
|
.expect_err("the OQ-13 placeholder declines against the real connection");
|
|
assert!(matches!(err, OpError::SftpNotServed), "got {err:?}");
|
|
assert!(
|
|
!connection.is_closed(),
|
|
"the pre-wire decline touches no wire state; the connection stays up"
|
|
);
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
|
|
/// - *Contract stamp:* `client.md` §The dial surface (the policy
|
|
/// call at `check_server_key`, OQ-05) + Host-key §case 1: the
|
|
/// pin posture consumed against a real presented key: a
|
|
/// mismatch hard-declines the dial (`DialError::
|
|
/// HostKeyRejected`, no connection), the policy's decision log
|
|
/// records the **real** fingerprint as rejected, and a
|
|
/// correctly-pinned dial to the same server succeeds; the
|
|
/// decline is the policy's decision, not the server's.
|
|
#[tokio::test]
|
|
async fn real_presented_key_mismatch_hard_declines_and_the_right_pin_dials() {
|
|
let fixture = match support::SshdFixture::spawn("host-key-mismatch").await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("host-key mismatch row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let wrong = fixture.policy(Some(key_fixture("delta").fingerprint));
|
|
let declined = fixture
|
|
.dial_from(Arc::clone(&wrong))
|
|
.await
|
|
.expect_err("the mismatch hard-declines the dial");
|
|
assert!(
|
|
matches!(declined, DialError::HostKeyRejected),
|
|
"got {declined:?}"
|
|
);
|
|
|
|
let decisions = wrong.decisions();
|
|
assert_eq!(
|
|
decisions.len(),
|
|
1,
|
|
"the dial called the policy exactly once, with the real presented key"
|
|
);
|
|
let decision = &decisions[0];
|
|
assert!(
|
|
!decision.accepted,
|
|
"the pinned mismatch is the reject verdict"
|
|
);
|
|
assert_eq!(decision.presented.host, "127.0.0.1");
|
|
assert_eq!(decision.presented.port, fixture.port);
|
|
assert_eq!(
|
|
decision.presented.fingerprint, fixture.host_fingerprint,
|
|
"the presented fingerprint is the real server's minted host key"
|
|
);
|
|
|
|
let connection = fixture.dial_default().await;
|
|
assert!(
|
|
!connection.is_closed(),
|
|
"the correctly-pinned dial to the same server succeeds"
|
|
);
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
}
|
|
|
|
/// The server-refused direct-tcpip row (the `tunnel` **and**
|
|
/// `session` builds, the all-features posture): a real server with
|
|
/// `AllowTcpForwarding no` refuses the typed open, and the
|
|
/// connection stays usable (a following exec is served). The session
|
|
/// gate is the stays-usable proof's vehicle (recorded: under
|
|
/// tunnel-only builds the row is absent; the fake-double mirror,
|
|
/// `tests/client_ops.rs`, carries the tunnel-only stays-usable rows).
|
|
#[cfg(all(unix, feature = "tunnel", feature = "session"))]
|
|
mod unfeatured_direct_decline {
|
|
use std::time::Duration;
|
|
|
|
use alkssh::{OpError, OpenDeclineReason};
|
|
|
|
use super::support;
|
|
|
|
/// - *Contract stamp:* `client.md` §op rows (row 2's declined
|
|
/// mirror) + §Correctness facts (a decline is channel-scoped,
|
|
/// auth intact, connection usable); the wave-4 typed-open
|
|
/// decline row re-landed against a real server's own refusal:
|
|
/// `tunnel_direct` against `AllowTcpForwarding no` →
|
|
/// `OpenDeclined(Prohibited)` (the `administratively
|
|
/// prohibited` class), and a following exec is served; the
|
|
/// connection outlives the decline end to end.
|
|
#[tokio::test]
|
|
async fn real_server_refused_direct_tcpip_declines_and_the_connection_stays_usable() {
|
|
let fixture = match support::SshdFixture::spawn_with("direct-decline", false).await {
|
|
Ok(fixture) => fixture,
|
|
Err(reason) => {
|
|
support::skip("direct-tcpip decline row", &reason);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let connection = fixture.dial_default().await;
|
|
|
|
let declined = tokio::time::timeout(
|
|
support::ROW_DEADLINE,
|
|
connection.tunnel_direct("127.0.0.1", 443),
|
|
)
|
|
.await
|
|
.expect("the tunnel op completes within the row deadline")
|
|
.expect_err("the server that forbids forwarding refuses the open");
|
|
assert!(
|
|
matches!(
|
|
declined,
|
|
OpError::OpenDeclined(OpenDeclineReason::Prohibited)
|
|
),
|
|
"the administratively-prohibited class mirrors through, got {declined:?}"
|
|
);
|
|
assert!(
|
|
!connection.is_closed(),
|
|
"the decline is channel-scoped; the connection survives it"
|
|
);
|
|
|
|
let mut session =
|
|
tokio::time::timeout(support::ROW_DEADLINE, connection.exec("echo still-usable"))
|
|
.await
|
|
.expect("the exec opens within the row deadline")
|
|
.expect("the post-decline exec is served; the connection stays usable");
|
|
|
|
let mut stdout = Vec::new();
|
|
let mut reader = alktty::wire::ChunkReader::new(&mut session.output);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, reader.read_chunk()).await {
|
|
Ok(Ok(chunk)) if chunk.stream_type == alktty::wire::STREAM_STDOUT => {
|
|
stdout.extend_from_slice(&chunk.bytes);
|
|
}
|
|
Ok(Ok(chunk)) if chunk.stream_type == alktty::wire::STREAM_CTRL_OUT => {
|
|
break;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
assert_eq!(
|
|
stdout, b"still-usable\n",
|
|
"the exec after the decline answers; auth intact, connection usable"
|
|
);
|
|
|
|
connection.close().await;
|
|
fixture.stop().await;
|
|
}
|
|
}
|