From 3714c858affb3f3b1cf4f11403d501eaab5b1817 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 19 May 2026 23:00:59 +0800 Subject: [PATCH 01/39] chore(deps): use h3x endpoint branch --- Cargo.lock | 89 ++++++++++++++++++++++++++++++++++++++++++++---------- Cargo.toml | 2 +- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23b9e29..80c4221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,6 +571,21 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dhttp-identity" +version = "0.1.0" +source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#ef4190f83e0c1ca0e6871a58d26566bf8c411599" +dependencies = [ + "bytes", + "futures", + "http", + "ring", + "rustls", + "serde", + "snafu", + "x509-parser", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -608,8 +623,9 @@ dependencies = [ [[package]] name = "dquic" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ + "arc-swap", "bytes", "dashmap", "derive_more", @@ -910,7 +926,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=main#a50961b3e64d63f5e827aa50c2761ab03cc7f626" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#bbe10641b7419263feb252396ec49f589311470b" dependencies = [ "arc-swap", "async-channel", @@ -918,6 +934,7 @@ dependencies = [ "bytes", "dashmap", "derive_more", + "dhttp-identity", "dquic", "either", "futures", @@ -1259,9 +1276,9 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "netdev" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e30af1a5073b82356d9317c18226826370b4288eba2f71c7e84e18bae51b3847" +checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" dependencies = [ "block2", "dispatch2", @@ -1273,6 +1290,8 @@ dependencies = [ "netlink-packet-route", "netlink-sys", "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", "objc2-system-configuration", "once_cell", "plist", @@ -1434,12 +1453,39 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -1451,6 +1497,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" @@ -1647,7 +1703,7 @@ dependencies = [ [[package]] name = "qbase" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bitflags", "bytes", @@ -1671,7 +1727,7 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "qbase", "qevent", @@ -1684,7 +1740,7 @@ dependencies = [ [[package]] name = "qconnection" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "dashmap", @@ -1712,7 +1768,7 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "futures", @@ -1724,7 +1780,7 @@ dependencies = [ [[package]] name = "qevent" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "derive_builder", @@ -1742,7 +1798,7 @@ dependencies = [ [[package]] name = "qinterface" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "dashmap", @@ -1767,7 +1823,7 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -1778,7 +1834,7 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "derive_more", @@ -1796,7 +1852,7 @@ dependencies = [ [[package]] name = "qresolve" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "futures", "qbase", @@ -1807,7 +1863,7 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "async-trait", "bitflags", @@ -1836,12 +1892,13 @@ dependencies = [ [[package]] name = "qudp" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3efd4dda778da20d64c1d38906a234733aeaca3e" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" dependencies = [ "bytes", "cfg-if", "libc", "nix 0.31.2", + "qbase", "socket2", "tokio", "tracing", @@ -2314,7 +2371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index abe766d..8eb42e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ version = "0.2.0" edition = "2024" [dependencies] -h3x = { git = "https://github.com/genmeta/h3x.git", branch = "main", features = [ +h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", features = [ "rpc", "serde", ] } From 4dd2c9d9f72b47ccb419f3998829c698de3ecfd1 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 20 May 2026 04:49:02 +0800 Subject: [PATCH 02/39] chore: update h3x lock --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80c4221..fe35e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,7 +926,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#bbe10641b7419263feb252396ec49f589311470b" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#c40651b9811a423ac30ef36b93f056179f465601" dependencies = [ "arc-swap", "async-channel", @@ -2371,7 +2371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", From 866a0575b8db6ab4f77453037a1c3416c5028fa0 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 20 May 2026 05:21:55 +0800 Subject: [PATCH 03/39] chore: update h3x dependency lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index fe35e98..7bc8554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -926,7 +926,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#c40651b9811a423ac30ef36b93f056179f465601" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#1896114934be67745ae8657bdbafe2edb353c076" dependencies = [ "arc-swap", "async-channel", From 81562feaaf4574f609515c711052eae0310c58f2 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 20 May 2026 14:38:47 +0800 Subject: [PATCH 04/39] chore: update h3x dependency --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bc8554..88f3d67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,7 +574,7 @@ dependencies = [ [[package]] name = "dhttp-identity" version = "0.1.0" -source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#ef4190f83e0c1ca0e6871a58d26566bf8c411599" +source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#3a8cf7c791555b1b55bcacdbaf2f178174c5b6cf" dependencies = [ "bytes", "futures", @@ -623,7 +623,7 @@ dependencies = [ [[package]] name = "dquic" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "arc-swap", "bytes", @@ -926,7 +926,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#1896114934be67745ae8657bdbafe2edb353c076" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#a0e2b38b1987269c4f13e167d2744a354545a6fb" dependencies = [ "arc-swap", "async-channel", @@ -1703,7 +1703,7 @@ dependencies = [ [[package]] name = "qbase" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bitflags", "bytes", @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "qbase", "qevent", @@ -1740,7 +1740,7 @@ dependencies = [ [[package]] name = "qconnection" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "dashmap", @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "futures", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "qevent" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "derive_builder", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "qinterface" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "dashmap", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -1834,7 +1834,7 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "derive_more", @@ -1852,7 +1852,7 @@ dependencies = [ [[package]] name = "qresolve" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "futures", "qbase", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "async-trait", "bitflags", @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "qudp" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#29d11495bfc1dbe2b490327067250f20a4f43940" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" dependencies = [ "bytes", "cfg-if", From c577fc3dc89cdcb8ffddbc556db1da91a864aaab Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 21 May 2026 00:53:01 +0800 Subject: [PATCH 05/39] fix(ipc): set socketpair fds nonblocking --- src/conversation/ipc.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index ff237e9..341c0ca 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -45,6 +45,11 @@ use tracing::Instrument; use crate::protocol::{ConversationHandle, HandleError}; +fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Result { + stream.set_nonblocking(true)?; + UnixStream::from_std(stream) +} + // --------------------------------------------------------------------------- // RPC trait // --------------------------------------------------------------------------- @@ -113,7 +118,7 @@ impl IpcManageStreamHandle { .next() .context(UnexpectedFdCountSnafu { actual: 0_usize })?; let stream = - UnixStream::from_std(std::os::unix::net::UnixStream::from(fd)).context(FromFdSnafu)?; + unix_stream_from_std(std::os::unix::net::UnixStream::from(fd)).context(FromFdSnafu)?; Ok(stream.into_split()) } } @@ -177,13 +182,15 @@ impl IpcManageStreamAdapter { ) -> Result { let (srv, cli) = std::os::unix::net::UnixStream::pair().map_err(|e| to_conn_error(e, "socketpair"))?; + cli.set_nonblocking(true) + .map_err(|e| to_conn_error(e, "set_nonblocking"))?; let fd_id = self .fd_sender .queue_fds(vec![cli.into()].into()) .map_err(|e| to_conn_error(e, "queue_fds"))?; - let srv = UnixStream::from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; + let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; let (srv_read, srv_write) = srv.into_split(); // Spawn bridge tasks independently so they are NOT aborted when this @@ -322,3 +329,23 @@ fn handle_error_to_connection_error(e: HandleError) -> ConnectionError { } .into() } + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::unix_stream_from_std; + + #[tokio::test] + async fn unix_stream_from_std_accepts_default_blocking_socketpair() { + let (left, right) = std::os::unix::net::UnixStream::pair().expect("socketpair"); + let mut left = unix_stream_from_std(left).expect("left stream"); + let mut right = unix_stream_from_std(right).expect("right stream"); + + left.write_all(b"x").await.expect("write"); + let mut buf = [0_u8; 1]; + right.read_exact(&mut buf).await.expect("read"); + + assert_eq!(buf, [b'x']); + } +} From 3a088e29dac5210debc857675df748593f38a6fa Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 22 May 2026 18:07:37 +0800 Subject: [PATCH 06/39] refactor: rename genmeta-ssh crate to dssh --- Cargo.lock | 60 ++++++++++++++++----------------- Cargo.toml | 5 +-- examples/ssh3-client.rs | 47 ++++++++++++++++---------- examples/ssh3-server.rs | 66 +++++++++++++++++++++---------------- examples/ssh3-session.rs | 27 ++++++++------- src/constants.rs | 2 +- src/session/process.rs | 2 +- src/session/signal.rs | 2 +- src/version.rs | 2 +- tests/docker_integration.rs | 2 +- 10 files changed, 119 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88f3d67..602c1c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -639,6 +639,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "dssh" +version = "0.2.0" +dependencies = [ + "base64", + "bytes", + "clap", + "futures", + "h3x", + "http", + "http-body-util", + "libc", + "nix 0.31.2", + "pam-client2", + "peg", + "remoc", + "ring", + "rustls", + "serde", + "serde_json", + "smallvec", + "snafu", + "tempfile", + "tokio", + "tokio-util", + "tower-service", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -812,36 +842,6 @@ dependencies = [ "slab", ] -[[package]] -name = "genmeta-ssh" -version = "0.2.0" -dependencies = [ - "base64", - "bytes", - "clap", - "futures", - "h3x", - "http", - "http-body-util", - "libc", - "nix 0.31.2", - "pam-client2", - "peg", - "remoc", - "ring", - "rustls", - "serde", - "serde_json", - "smallvec", - "snafu", - "tempfile", - "tokio", - "tokio-util", - "tower-service", - "tracing", - "tracing-subscriber", -] - [[package]] name = "getrandom" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 8eb42e7..15b4e26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,9 @@ [package] -name = "genmeta-ssh" -description = "SSH3 core session, channel, and wire-format foundation" +name = "dssh" +description = "DSSH core session, channel, and wire-format foundation" version = "0.2.0" edition = "2024" +repository = "https://github.com/genmeta/dssh" [dependencies] h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", features = [ diff --git a/examples/ssh3-client.rs b/examples/ssh3-client.rs index 97416e5..bec7bd8 100644 --- a/examples/ssh3-client.rs +++ b/examples/ssh3-client.rs @@ -12,7 +12,7 @@ use std::pin::Pin; use std::sync::Arc; use clap::Parser; -use genmeta_ssh::{ +use dssh::{ client::SSH3_CONNECT_PATH, client::encode_basic_auth, constants::{DEFAULT_MAX_MESSAGE_SIZE, SSH_VERSION}, @@ -25,7 +25,10 @@ use genmeta_ssh::{ protocol::{ConversationHandle, Ssh3Protocol}, session::client::ClientSession, }; -use h3x::dquic::H3Client; +use h3x::dquic::{ + H3Endpoint, QuicEndpoint, + client::{ClientQuicConfig, ServerCertVerifierChoice}, +}; use h3x::qpack::field::Protocol; use h3x::quic::GetStreamIdExt; use h3x::stream_id::StreamId; @@ -93,7 +96,7 @@ struct Cli { async fn connect( authority: &str, auth_header: HeaderValue, - client: &H3Client, + client: &H3Endpoint, ) -> Result, Whatever> { let authority_parsed: http::uri::Authority = authority.parse().whatever_context("invalid authority")?; @@ -238,22 +241,32 @@ async fn main() { }; let has_remote_forwards = !cli.remote_forward.is_empty(); - let client: H3Client = { - let mut builder = H3Client::builder() - .without_server_cert_verification() - .without_identity() - .expect("failed to configure TLS"); + let client: H3Endpoint = { + let quic = QuicEndpoint::builder() + .client(ClientQuicConfig { + verifier: ServerCertVerifierChoice::Dangerous, + alpns: vec![b"h3".to_vec()], + ..Default::default() + }) + .build() + .await; // Register Ssh3Protocol so the client can accept incoming channels // from the server (required for remote forwarding -R). - if has_remote_forwards { + let conn_builder = if has_remote_forwards { use h3x::connection::ConnectionBuilder; - let conn_builder = ConnectionBuilder::new(Arc::default()) - .protocol(genmeta_ssh::protocol::Ssh3ProtocolFactory); - builder = builder.with_builder(Arc::new(conn_builder)); - } - - builder.build() + Arc::new( + ConnectionBuilder::new(Arc::default()) + .protocol(dssh::protocol::Ssh3ProtocolFactory), + ) + } else { + Arc::new(h3x::connection::ConnectionBuilder::new(Arc::default())) + }; + + H3Endpoint::builder() + .quic(quic) + .builder(conn_builder) + .build() }; let auth_header = encode_basic_auth(&cli.user, &cli.password); @@ -337,8 +350,8 @@ async fn main() { .expect("session IO relay failed"); let exit_code = match exit { - Some(genmeta_ssh::session::client::ExitResult::Status(code)) => code, - Some(genmeta_ssh::session::client::ExitResult::Signal { signal_name, .. }) => { + Some(dssh::session::client::ExitResult::Status(code)) => code, + Some(dssh::session::client::ExitResult::Signal { signal_name, .. }) => { tracing::info!(%signal_name, "process killed by signal"); 128 } diff --git a/examples/ssh3-server.rs b/examples/ssh3-server.rs index 188bba1..4128ae2 100644 --- a/examples/ssh3-server.rs +++ b/examples/ssh3-server.rs @@ -22,7 +22,7 @@ use std::task::{Context, Poll}; use bytes::Bytes; use clap::Parser; -use genmeta_ssh::{ +use dssh::{ auth::{AuthCredential, parse_authorization_header}, constants::{SSH_VERSION, SSH3_CONNECT_PATH}, conversation::ipc::{IpcManageSessionStreamServerShared, IpcManageStreamAdapter}, @@ -30,12 +30,18 @@ use genmeta_ssh::{ session::{AuthRequest, AuthenticateFn, SessionBootstrap}, }; use h3x::connection::{ConnectionBuilder, ConnectionState}; -use h3x::dquic::H3Servers; +use h3x::dquic::{ + QuicEndpoint, + binds::BindPattern, + cert::handy::{ToCertificate, ToPrivateKey}, + identity::Identity, + server::ServerQuicConfig, +}; use h3x::hyper::server::TowerService; use h3x::ipc::transport::MuxChannel; use h3x::message::stream::MessageStreamError; use h3x::quic::DynConnection; -use h3x::server::Router; +use h3x::server::{Servers, Service as Router}; use h3x::stream_id::StreamId; use http::StatusCode; use http_body_util::{BodyExt, Empty, combinators::UnsyncBoxBody}; @@ -112,26 +118,30 @@ async fn main() { let router = Router::new().connect(SSH3_CONNECT_PATH, service); - let builder = ConnectionBuilder::new(Arc::default()).protocol(Ssh3ProtocolFactory); - - let mut servers: H3Servers<_> = H3Servers::builder() - .without_client_cert_verifier() - .expect("failed to configure TLS") - .with_builder(Arc::new(builder)) - .listen() - .expect("failed to create listener"); - - servers - .add_server( - "localhost", - cert_pem.as_slice(), - key_pem.as_slice(), - None::>, - [format!("inet://{}", cli.bind)], - router, - ) - .await - .expect("failed to add server"); + let builder = Arc::new(ConnectionBuilder::new(Arc::default()).protocol(Ssh3ProtocolFactory)); + let identity = Arc::new(Identity { + name: "localhost".parse().expect("localhost is a valid DNS name"), + certs: Arc::new(cert_pem.as_slice().to_certificate()), + key: Arc::new(key_pem.as_slice().to_private_key()), + ocsp: Arc::new(None), + }); + let bind: BindPattern = format!("inet://{}", cli.bind) + .parse() + .expect("failed to parse bind address"); + let quic = QuicEndpoint::builder() + .maybe_identity(Some(identity)) + .server(ServerQuicConfig { + alpns: vec![b"h3".to_vec()], + ..Default::default() + }) + .bind(Arc::new(vec![bind])) + .build() + .await; + let mut servers = Servers::from_quic_listener() + .listener(quic) + .service(router) + .builder(builder) + .build(); tracing::info!(bind = %cli.bind, "SSH3 server listening"); let err = servers.run().await; @@ -198,7 +208,7 @@ async fn handle_ssh3_connect( .clone(); let ssh3_proto = connection .protocols() - .get::() + .get::() .expect("Ssh3ProtocolFactory not registered"); let handle = match ssh3_proto.register(conversation_id) { Ok(h) => h, @@ -231,9 +241,9 @@ async fn handle_ssh3_connect( /// serving, and calls the child's StartSessionFn. async fn handle_child_process( mut request: http::Request, - handle: genmeta_ssh::protocol::ConversationHandle, + handle: dssh::protocol::ConversationHandle, username: String, - credential: genmeta_ssh::auth::AuthCredential, + credential: dssh::auth::AuthCredential, peer_version: String, conversation_id: StreamId, session_binary: &std::path::Path, @@ -369,14 +379,14 @@ async fn handle_child_process( // Bridge QUIC CONNECT streams ↔ control stream socketpair. tokio::spawn( - genmeta_ssh::conversation::ipc::bridge_message_reader_to_unix( + dssh::conversation::ipc::bridge_message_reader_to_unix( Box::pin(read_stream.into_bytes_stream()), ctrl_write, ) .in_current_span(), ); tokio::spawn( - genmeta_ssh::conversation::ipc::bridge_unix_to_message_writer( + dssh::conversation::ipc::bridge_unix_to_message_writer( ctrl_read, Box::pin(write_stream.into_bytes_sink()), ) diff --git a/examples/ssh3-session.rs b/examples/ssh3-session.rs index 5e73b51..7d95c97 100644 --- a/examples/ssh3-session.rs +++ b/examples/ssh3-session.rs @@ -16,7 +16,7 @@ use std::sync::Arc; -use genmeta_ssh::{ +use dssh::{ auth::AuthCredential, conversation::Conversation, session::{ @@ -51,15 +51,14 @@ async fn main() { let fd_registry = stream.fd_registry(); // Establish remoc channel over MuxSink/MuxStream. - let (conn, mut tx, _rx) = remoc::Connect::framed::< - _, - _, - genmeta_ssh::session::AuthenticateFn, - (), - remoc::codec::Default, - >(remoc::Cfg::default(), sink, stream) - .await - .expect("failed to establish remoc channel"); + let (conn, mut tx, _rx) = + remoc::Connect::framed::<_, _, dssh::session::AuthenticateFn, (), remoc::codec::Default>( + remoc::Cfg::default(), + sink, + stream, + ) + .await + .expect("failed to establish remoc channel"); let conn_handle = AbortOnDropHandle::new(tokio::spawn( conn.instrument(tracing::info_span!("remoc_conn")), )); @@ -80,7 +79,7 @@ async fn main() { } #[cfg(feature = "pam")] AuthCredential::Certificate => { - genmeta_ssh::session::pam::open_session("sshd", &auth_request.username) + dssh::session::pam::open_session("sshd", &auth_request.username) .await .map_err(|e| AuthError::PamFailed { reason: Report::from_error(e).to_string(), @@ -88,12 +87,12 @@ async fn main() { } #[cfg(not(feature = "pam"))] AuthCredential::Certificate => { - let user_info = genmeta_ssh::session::lookup_user(&auth_request.username) + let user_info = dssh::session::lookup_user(&auth_request.username) .await .map_err(|e| AuthError::PamFailed { reason: Report::from_error(e).to_string(), })?; - if let Err(msg) = genmeta_ssh::session::check_nologin(user_info.uid) { + if let Err(msg) = dssh::session::check_nologin(user_info.uid) { return Err(AuthError::PamFailed { reason: msg }); } user_info @@ -147,7 +146,7 @@ async fn main() { let (control_reader, control_writer) = ctrl_unix.into_split(); // Create IPC manage stream handle. - let manage_stream = genmeta_ssh::conversation::ipc::IpcManageStreamHandle::new( + let manage_stream = dssh::conversation::ipc::IpcManageStreamHandle::new( bootstrap.manage_stream, fd_registry, ); diff --git a/src/constants.rs b/src/constants.rs index 045ba7e..9ac0b8a 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,6 +1,6 @@ use h3x::varint::VarInt; -pub const SSH_VERSION: &str = "genmeta-ssh-00"; +pub const SSH_VERSION: &str = "dssh-00"; pub const SUPPORTED_SSH_VERSIONS: &[&str] = &[SSH_VERSION]; diff --git a/src/session/process.rs b/src/session/process.rs index afdef35..4976b9f 100644 --- a/src/session/process.rs +++ b/src/session/process.rs @@ -660,7 +660,7 @@ where if let Some(signal_number) = status.signal() { let signal_name = signal::to_ssh_name(signal_number) .map(Cow::Borrowed) - .unwrap_or_else(|| Cow::Owned(format!("signal-{signal_number}@genmeta-ssh3"))); + .unwrap_or_else(|| Cow::Owned(format!("signal-{signal_number}@dssh"))); writer .notice(&ExitSignalChannelNotice { diff --git a/src/session/signal.rs b/src/session/signal.rs index 7334d80..1750e94 100644 --- a/src/session/signal.rs +++ b/src/session/signal.rs @@ -50,7 +50,7 @@ pub fn deliver(pid: nix::unistd::Pid, signal: Signal) -> Result<(), nix::Error> /// Map a Unix signal number to its SSH name (without "SIG" prefix). /// /// Returns `None` for unrecognized signal numbers. Uses a fallback format -/// `"signal-N@genmeta-ssh3"` for the caller to handle unknown signals. +/// `"signal-N@dssh"` for the caller to handle unknown signals. pub fn to_ssh_name(signal_number: i32) -> Option<&'static str> { use nix::libc; match signal_number { diff --git a/src/version.rs b/src/version.rs index f363ef8..467c6e6 100644 --- a/src/version.rs +++ b/src/version.rs @@ -79,7 +79,7 @@ mod tests { #[test] fn no_match() { - let err = negotiate_version(&headers_with("genmeta-ssh3-99")).unwrap_err(); + let err = negotiate_version(&headers_with("dssh-99")).unwrap_err(); assert!(err.to_string().contains("no supported")); } diff --git a/tests/docker_integration.rs b/tests/docker_integration.rs index 070cdbc..7c1f055 100644 --- a/tests/docker_integration.rs +++ b/tests/docker_integration.rs @@ -28,7 +28,7 @@ fn repo_root() -> PathBuf { /// Build example binaries inside a `rust:1-bookworm` Docker container. /// -/// The workspace root (parent of genmeta-ssh3) is volume-mounted so that +/// The workspace root (parent of dssh) is volume-mounted so that /// path dependencies (h3x, gm-quic, etc.) are available. /// /// Returns the host path to the directory containing the built binaries. From 709c9f2d37d20651ce7a7dcc2ff950404c84a91e Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 26 May 2026 18:33:45 +0800 Subject: [PATCH 07/39] feat(webtransport): add dssh stream manager --- Cargo.toml | 1 + src/lib.rs | 3 + src/webtransport.rs | 450 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 src/webtransport.rs diff --git a/Cargo.toml b/Cargo.toml index 15b4e26..dee2feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ futures = "0.3" [features] client = [] server = ["h3x/ipc", "dep:nix", "dep:libc"] +webtransport = ["h3x/webtransport"] pam = ["server", "dep:pam-client2"] cli = ["dep:peg"] config = ["dep:peg"] diff --git a/src/lib.rs b/src/lib.rs index ad8a877..4059b79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,3 +16,6 @@ pub mod message; pub mod protocol; pub mod session; pub mod version; + +#[cfg(feature = "webtransport")] +pub mod webtransport; diff --git a/src/webtransport.rs b/src/webtransport.rs new file mode 100644 index 0000000..e09d1ec --- /dev/null +++ b/src/webtransport.rs @@ -0,0 +1,450 @@ +//! DSSH over WebTransport stream adaptation. +//! +//! A WebTransport session provides bidirectional streams. DSSH reserves the +//! first field on each WebTransport bidirectional stream for a DSSH stream kind: +//! +//! - [`DSSH_CONTROL_STREAM_KIND`] — the conversation control stream +//! - [`DSSH_CHANNEL_STREAM_KIND`] — SSH channel streams managed by +//! [`ManageSessionStream`](crate::conversation::ManageSessionStream) +//! +//! The WebTransport CONNECT stream is not used as a DSSH control stream. The +//! control stream is an ordinary WebTransport bidirectional stream marked with +//! the control kind. + +use h3x::{ + codec::{DecodeExt, EncodeExt, SinkWriter, StreamReader}, + quic, + varint::VarInt, +}; +use snafu::{ResultExt, Snafu}; +use tokio::io::AsyncWriteExt; + +use crate::conversation::ManageSessionStream; + +/// DSSH-over-WebTransport stream kind for the conversation control stream. +pub const DSSH_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); + +/// DSSH-over-WebTransport stream kind for SSH channel streams. +pub const DSSH_CHANNEL_STREAM_KIND: VarInt = VarInt::from_u32(1); + +/// Stream manager backed by a WebTransport session. +/// +/// `open_stream` / `accept_stream` implement SSH channel stream management. +/// The control stream is handled explicitly through [`Self::open_control`] and +/// [`Self::accept_control`]. +#[derive(Debug)] +pub struct WebTransportStreamManager { + session: S, +} + +impl WebTransportStreamManager { + pub fn new(session: S) -> Self { + Self { session } + } + + pub fn into_inner(self) -> S { + self.session + } + + pub fn session(&self) -> &S { + &self.session + } +} + +impl WebTransportStreamManager +where + S: h3x::webtransport::Session, + S::StreamReader: Unpin, + S::StreamWriter: Unpin, +{ + /// Open the DSSH control stream on this WebTransport session. + pub async fn open_control( + &self, + ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> + { + let (reader, writer) = self + .session + .open_bi() + .await + .context(web_transport_stream_error::OpenBiSnafu)?; + let writer = write_stream_kind(writer, DSSH_CONTROL_STREAM_KIND).await?; + Ok((StreamReader::new(reader), SinkWriter::new(writer))) + } + + /// Accept the DSSH control stream on this WebTransport session. + pub async fn accept_control( + &self, + ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> + { + self.accept_kind(DSSH_CONTROL_STREAM_KIND).await + } + + async fn accept_kind( + &self, + expected: VarInt, + ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> + { + let (reader, writer) = self + .session + .accept_bi() + .await + .context(web_transport_stream_error::AcceptBiSnafu)?; + let mut reader = StreamReader::new(reader); + let actual = reader + .decode_one::() + .await + .context(web_transport_stream_error::DecodeStreamKindSnafu)?; + if actual != expected { + return Err(WebTransportStreamError::UnexpectedStreamKind { kind: actual }); + } + Ok((reader, SinkWriter::new(writer))) + } +} + +impl ManageSessionStream for WebTransportStreamManager +where + S: h3x::webtransport::Session, + S::StreamReader: Unpin, + S::StreamWriter: Unpin, +{ + type StreamReader = StreamReader; + type StreamWriter = SinkWriter; + type Error = WebTransportStreamError; + + async fn open_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + let (reader, writer) = self + .session + .open_bi() + .await + .context(web_transport_stream_error::OpenBiSnafu)?; + let writer = write_stream_kind(writer, DSSH_CHANNEL_STREAM_KIND).await?; + Ok((StreamReader::new(reader), SinkWriter::new(writer))) + } + + async fn accept_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + self.accept_kind(DSSH_CHANNEL_STREAM_KIND).await + } +} + +/// Error returned by [`WebTransportStreamManager`] operations. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum WebTransportStreamError { + #[snafu(display("failed to open webtransport bidirectional stream"))] + OpenBi { + source: h3x::webtransport::OpenStreamError, + }, + + #[snafu(display("failed to accept webtransport bidirectional stream"))] + AcceptBi { source: h3x::webtransport::Closed }, + + #[snafu(display("failed to encode dssh webtransport stream kind"))] + EncodeStreamKind { source: std::io::Error }, + + #[snafu(display("failed to flush dssh webtransport stream kind"))] + FlushStreamKind { source: std::io::Error }, + + #[snafu(display("failed to decode dssh webtransport stream kind"))] + DecodeStreamKind { source: std::io::Error }, + + #[snafu(display("unexpected dssh webtransport stream kind {kind}"))] + UnexpectedStreamKind { kind: VarInt }, +} + +async fn write_stream_kind(writer: W, kind: VarInt) -> Result +where + W: quic::WriteStream + Unpin, +{ + let mut writer = SinkWriter::new(writer); + writer + .encode_one(kind) + .await + .context(web_transport_stream_error::EncodeStreamKindSnafu)?; + AsyncWriteExt::flush(&mut writer) + .await + .context(web_transport_stream_error::FlushStreamKindSnafu)?; + Ok(writer.into_inner()) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::VecDeque, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, + }; + + use bytes::Bytes; + use futures::{Sink, SinkExt, Stream}; + use h3x::quic::{CancelStream, GetStreamId, StopStream}; + use tokio::io::AsyncReadExt; + + use super::*; + + #[derive(Debug, Default)] + struct StreamState { + written: Mutex>, + } + + impl StreamState { + fn written(&self) -> Vec { + self.written.lock().expect("written lock poisoned").clone() + } + } + + #[derive(Debug)] + struct TestReadStream { + chunks: VecDeque, + stream_id: VarInt, + } + + impl Stream for TestReadStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(self.chunks.pop_front().map(Ok)) + } + } + + impl GetStreamId for TestReadStream { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } + } + + impl StopStream for TestReadStream { + fn poll_stop( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[derive(Debug)] + struct TestWriteStream { + state: Arc, + stream_id: VarInt, + } + + impl Sink for TestWriteStream { + type Error = quic::StreamError; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { + self.state + .written + .lock() + .expect("written lock poisoned") + .extend_from_slice(&item); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + impl GetStreamId for TestWriteStream { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } + } + + impl CancelStream for TestWriteStream { + fn poll_cancel( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[derive(Debug, Default)] + struct TestSession { + open_state: Arc, + accept_streams: Mutex>, + } + + impl TestSession { + fn with_accept_bytes(bytes: &'static [u8]) -> Self { + let session = Self::default(); + session + .accept_streams + .lock() + .expect("accept lock poisoned") + .push_back(stream_pair_with_read(bytes, VarInt::from_u32(7))); + session + } + } + + impl h3x::webtransport::Session for TestSession { + type StreamReader = TestReadStream; + type StreamWriter = TestWriteStream; + + fn session_id(&self) -> VarInt { + VarInt::from_u32(4) + } + + async fn open_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> + { + Ok(( + TestReadStream { + chunks: VecDeque::new(), + stream_id: VarInt::from_u32(5), + }, + TestWriteStream { + state: self.open_state.clone(), + stream_id: VarInt::from_u32(5), + }, + )) + } + + async fn open_uni(&self) -> Result { + unreachable!("dssh webtransport manager uses only bidirectional streams") + } + + async fn accept_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::Closed> { + self.accept_streams + .lock() + .expect("accept lock poisoned") + .pop_front() + .ok_or(h3x::webtransport::Closed) + } + + async fn accept_uni(&self) -> Result { + unreachable!("dssh webtransport manager uses only bidirectional streams") + } + } + + fn stream_pair_with_read( + bytes: &'static [u8], + stream_id: VarInt, + ) -> (TestReadStream, TestWriteStream) { + let state = Arc::new(StreamState::default()); + ( + TestReadStream { + chunks: VecDeque::from([Bytes::from_static(bytes)]), + stream_id, + }, + TestWriteStream { state, stream_id }, + ) + } + + #[tokio::test] + async fn open_control_and_channel_prefix_stream_kind() { + let session = TestSession::default(); + let manager = WebTransportStreamManager::new(session); + + manager.open_control().await.expect("open control"); + assert_eq!(manager.session().open_state.written(), vec![0]); + + manager.open_stream().await.expect("open channel"); + assert_eq!(manager.session().open_state.written(), vec![0, 1]); + } + + #[tokio::test] + async fn accept_control_consumes_control_kind_and_leaves_payload() { + let session = TestSession::with_accept_bytes(b"\x00hello"); + let manager = WebTransportStreamManager::new(session); + + let (mut reader, _writer) = manager.accept_control().await.expect("accept control"); + let mut payload = Vec::new(); + reader + .read_to_end(&mut payload) + .await + .expect("read payload"); + + assert_eq!(payload, b"hello"); + } + + #[tokio::test] + async fn accept_stream_consumes_channel_kind_and_leaves_payload() { + let session = TestSession::with_accept_bytes(b"\x01payload"); + let manager = WebTransportStreamManager::new(session); + + let (mut reader, _writer) = manager.accept_stream().await.expect("accept channel"); + let mut payload = Vec::new(); + reader + .read_to_end(&mut payload) + .await + .expect("read payload"); + + assert_eq!(payload, b"payload"); + } + + #[tokio::test] + async fn accept_stream_rejects_unexpected_kind() { + let session = TestSession::with_accept_bytes(b"\x00control"); + let manager = WebTransportStreamManager::new(session); + + let error = match manager.accept_stream().await { + Ok(_) => panic!("control stream is not a channel stream"), + Err(error) => error, + }; + + assert!(matches!( + error, + WebTransportStreamError::UnexpectedStreamKind { kind } + if kind == DSSH_CONTROL_STREAM_KIND + )); + } + + #[tokio::test] + async fn accepted_writer_remains_usable_after_kind_decode() { + let session = TestSession::with_accept_bytes(b"\x01"); + let manager = WebTransportStreamManager::new(session); + + let (_reader, mut writer) = manager.accept_stream().await.expect("accept channel"); + writer + .send(Bytes::from_static(b"reply")) + .await + .expect("write reply"); + SinkExt::flush(&mut writer).await.expect("flush reply"); + } + + #[tokio::test] + async fn decode_kind_error_is_structured() { + let session = TestSession::with_accept_bytes(b""); + let manager = WebTransportStreamManager::new(session); + + let error = match manager.accept_control().await { + Ok(_) => panic!("empty stream cannot carry kind"), + Err(error) => error, + }; + + assert!(matches!( + error, + WebTransportStreamError::DecodeStreamKind { .. } + )); + } +} From 59ea7ec82fdba23706c7e3433b2e741d803ca115 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 10:53:30 +0800 Subject: [PATCH 08/39] fix(webtransport): adapt stream manager to h3x session api --- Cargo.lock | 58 ++++++++++++++++++++++++++++------------- examples/ssh3-server.rs | 25 +++++++++--------- src/webtransport.rs | 42 ++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 602c1c4..ad8c964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,7 +574,7 @@ dependencies = [ [[package]] name = "dhttp-identity" version = "0.1.0" -source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#3a8cf7c791555b1b55bcacdbaf2f178174c5b6cf" +source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#b48f4c4a5ebf9b9a13e9e852bc9e1e796938aec5" dependencies = [ "bytes", "futures", @@ -582,7 +582,7 @@ dependencies = [ "ring", "rustls", "serde", - "snafu", + "snafu 0.9.0", "x509-parser", ] @@ -623,7 +623,7 @@ dependencies = [ [[package]] name = "dquic" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "arc-swap", "bytes", @@ -660,7 +660,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "snafu", + "snafu 0.9.0", "tempfile", "tokio", "tokio-util", @@ -926,7 +926,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#a0e2b38b1987269c4f13e167d2744a354545a6fb" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#4a07393120b46a6013cf9abf37e9cd744859bd6f" dependencies = [ "arc-swap", "async-channel", @@ -953,7 +953,7 @@ dependencies = [ "rustls", "serde", "smallvec", - "snafu", + "snafu 0.9.0", "tokio", "tokio-util", "tower-service", @@ -1703,7 +1703,7 @@ dependencies = [ [[package]] name = "qbase" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bitflags", "bytes", @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "qbase", "qevent", @@ -1740,7 +1740,7 @@ dependencies = [ [[package]] name = "qconnection" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "dashmap", @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "futures", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "qevent" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "derive_builder", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "qinterface" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "dashmap", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -1834,7 +1834,7 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "derive_more", @@ -1852,7 +1852,7 @@ dependencies = [ [[package]] name = "qresolve" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "futures", "qbase", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "async-trait", "bitflags", @@ -1883,6 +1883,7 @@ dependencies = [ "rand 0.10.1", "rustls", "smallvec", + "snafu 0.8.9", "thiserror 2.0.18", "tokio", "tokio-util", @@ -1892,7 +1893,7 @@ dependencies = [ [[package]] name = "qudp" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#feedcaf97e6b2d083f2ae3650a60ab4e3eb367b2" +source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "cfg-if", @@ -2299,13 +2300,34 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive 0.8.9", +] + [[package]] name = "snafu" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" dependencies = [ - "snafu-derive", + "snafu-derive 0.9.0", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/examples/ssh3-server.rs b/examples/ssh3-server.rs index 4128ae2..3811ce4 100644 --- a/examples/ssh3-server.rs +++ b/examples/ssh3-server.rs @@ -37,13 +37,13 @@ use h3x::dquic::{ identity::Identity, server::ServerQuicConfig, }; +use h3x::endpoint::H3Endpoint; use h3x::hyper::server::TowerService; use h3x::ipc::transport::MuxChannel; use h3x::message::stream::MessageStreamError; use h3x::quic::DynConnection; -use h3x::server::{Servers, Service as Router}; use h3x::stream_id::StreamId; -use http::StatusCode; +use http::{Method, StatusCode}; use http_body_util::{BodyExt, Empty, combinators::UnsyncBoxBody}; use remoc::prelude::*; use snafu::Report; @@ -116,8 +116,6 @@ async fn main() { session_binary: cli.session_binary, }); - let router = Router::new().connect(SSH3_CONNECT_PATH, service); - let builder = Arc::new(ConnectionBuilder::new(Arc::default()).protocol(Ssh3ProtocolFactory)); let identity = Arc::new(Identity { name: "localhost".parse().expect("localhost is a valid DNS name"), @@ -137,15 +135,12 @@ async fn main() { .bind(Arc::new(vec![bind])) .build() .await; - let mut servers = Servers::from_quic_listener() - .listener(quic) - .service(router) - .builder(builder) - .build(); - - tracing::info!(bind = %cli.bind, "SSH3 server listening"); - let err = servers.run().await; - tracing::error!(error = %snafu::Report::from_error(&err), "server stopped"); + let mut endpoint = H3Endpoint::builder().quic(quic).builder(builder).build(); + + tracing::info!(bind = %cli.bind, "ssh3 server listening"); + if let Err(error) = endpoint.serve(service).await { + tracing::error!(error = %snafu::Report::from_error(&error), "server stopped"); + } } fn error_response(status: StatusCode) -> Result, Infallible> { @@ -167,6 +162,10 @@ async fn handle_ssh3_connect( request: http::Request, session_binary: PathBuf, ) -> Result, Infallible> { + if request.method() != Method::CONNECT || request.uri().path() != SSH3_CONNECT_PATH { + return error_response(StatusCode::NOT_FOUND); + } + let auth_header = request .headers() .get("authorization") diff --git a/src/webtransport.rs b/src/webtransport.rs index e09d1ec..45f600f 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -136,7 +136,9 @@ pub enum WebTransportStreamError { }, #[snafu(display("failed to accept webtransport bidirectional stream"))] - AcceptBi { source: h3x::webtransport::Closed }, + AcceptBi { + source: h3x::webtransport::AcceptStreamError, + }, #[snafu(display("failed to encode dssh webtransport stream kind"))] EncodeStreamKind { source: std::io::Error }, @@ -177,7 +179,10 @@ mod tests { use bytes::Bytes; use futures::{Sink, SinkExt, Stream}; - use h3x::quic::{CancelStream, GetStreamId, StopStream}; + use h3x::{ + quic::{CancelStream, GetStreamId, StopStream}, + stream_id::StreamId, + }; use tokio::io::AsyncReadExt; use super::*; @@ -307,8 +312,8 @@ mod tests { type StreamReader = TestReadStream; type StreamWriter = TestWriteStream; - fn session_id(&self) -> VarInt { - VarInt::from_u32(4) + fn id(&self) -> StreamId { + StreamId(VarInt::from_u32(4)) } async fn open_bi( @@ -333,15 +338,20 @@ mod tests { async fn accept_bi( &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::Closed> { + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> + { self.accept_streams .lock() .expect("accept lock poisoned") .pop_front() - .ok_or(h3x::webtransport::Closed) + .ok_or(h3x::webtransport::AcceptStreamError::Closed { + source: h3x::webtransport::SessionClosed, + }) } - async fn accept_uni(&self) -> Result { + async fn accept_uni( + &self, + ) -> Result { unreachable!("dssh webtransport manager uses only bidirectional streams") } } @@ -447,4 +457,22 @@ mod tests { WebTransportStreamError::DecodeStreamKind { .. } )); } + + #[tokio::test] + async fn accept_empty_session_preserves_closed_accept_error() { + let session = TestSession::default(); + let manager = WebTransportStreamManager::new(session); + + let error = match manager.accept_stream().await { + Ok(_) => panic!("empty session cannot accept a channel stream"), + Err(error) => error, + }; + + assert!(matches!( + error, + WebTransportStreamError::AcceptBi { + source: h3x::webtransport::AcceptStreamError::Closed { .. } + } + )); + } } From 0db6d2eb0672d817b0f68ff0a466dd1549e62db9 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 10:53:30 +0800 Subject: [PATCH 09/39] feat(webtransport): add dssh conversation helpers --- src/webtransport.rs | 117 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/webtransport.rs b/src/webtransport.rs index 45f600f..22b4a55 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -19,7 +19,7 @@ use h3x::{ use snafu::{ResultExt, Snafu}; use tokio::io::AsyncWriteExt; -use crate::conversation::ManageSessionStream; +use crate::conversation::{Conversation, ManageSessionStream}; /// DSSH-over-WebTransport stream kind for the conversation control stream. pub const DSSH_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); @@ -37,6 +37,13 @@ pub struct WebTransportStreamManager { session: S, } +/// DSSH conversation backed by a WebTransport session. +pub type WebTransportConversation = Conversation< + WebTransportStreamManager, + StreamReader<::StreamReader>, + SinkWriter<::StreamWriter>, +>; + impl WebTransportStreamManager { pub fn new(session: S) -> Self { Self { session } @@ -126,6 +133,70 @@ where } } +/// Error returned when opening a DSSH conversation over WebTransport. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum OpenConversationError { + #[snafu(display("failed to open dssh webtransport control stream"))] + OpenControl { source: WebTransportStreamError }, +} + +/// Error returned when accepting a DSSH conversation over WebTransport. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum AcceptConversationError { + #[snafu(display("failed to accept dssh webtransport control stream"))] + AcceptControl { source: WebTransportStreamError }, +} + +/// Open a DSSH conversation on a WebTransport session. +/// +/// The client side opens an ordinary WebTransport bidirectional stream, writes +/// [`DSSH_CONTROL_STREAM_KIND`] as the first field, then uses that stream as the +/// DSSH conversation control stream. Additional SSH channel streams are managed +/// by the returned [`WebTransportStreamManager`]. +pub async fn open_conversation( + session: S, + peer_version: impl Into, +) -> Result, OpenConversationError> +where + S: h3x::webtransport::Session, + S::StreamReader: Unpin, + S::StreamWriter: Unpin, +{ + let id = session.id(); + let manager = WebTransportStreamManager::new(session); + let (reader, writer) = manager + .open_control() + .await + .context(open_conversation_error::OpenControlSnafu)?; + Ok(Conversation::new(id, peer_version, reader, writer, manager)) +} + +/// Accept a DSSH conversation on a WebTransport session. +/// +/// The server side waits for an ordinary WebTransport bidirectional stream +/// whose first field is [`DSSH_CONTROL_STREAM_KIND`], then uses that stream as +/// the DSSH conversation control stream. Additional SSH channel streams are +/// managed by the returned [`WebTransportStreamManager`]. +pub async fn accept_conversation( + session: S, + peer_version: impl Into, +) -> Result, AcceptConversationError> +where + S: h3x::webtransport::Session, + S::StreamReader: Unpin, + S::StreamWriter: Unpin, +{ + let id = session.id(); + let manager = WebTransportStreamManager::new(session); + let (reader, writer) = manager + .accept_control() + .await + .context(accept_conversation_error::AcceptControlSnafu)?; + Ok(Conversation::new(id, peer_version, reader, writer, manager)) +} + /// Error returned by [`WebTransportStreamManager`] operations. #[derive(Debug, Snafu)] #[snafu(module)] @@ -186,6 +257,7 @@ mod tests { use tokio::io::AsyncReadExt; use super::*; + use crate::constants::SSH_VERSION; #[derive(Debug, Default)] struct StreamState { @@ -475,4 +547,47 @@ mod tests { } )); } + + #[tokio::test] + async fn open_conversation_opens_control_stream_and_preserves_metadata() { + let session = TestSession::default(); + let open_state = session.open_state.clone(); + + let conversation = open_conversation(session, SSH_VERSION) + .await + .expect("conversation opens"); + + assert_eq!(conversation.id(), StreamId(VarInt::from_u32(4))); + assert_eq!(conversation.peer_version(), SSH_VERSION); + assert_eq!(open_state.written(), vec![0]); + } + + #[tokio::test] + async fn accept_conversation_accepts_control_stream_and_preserves_metadata() { + let session = TestSession::with_accept_bytes(b"\x00control"); + + let conversation = accept_conversation(session, SSH_VERSION) + .await + .expect("conversation accepts"); + + assert_eq!(conversation.id(), StreamId(VarInt::from_u32(4))); + assert_eq!(conversation.peer_version(), SSH_VERSION); + } + + #[tokio::test] + async fn accept_conversation_rejects_channel_stream_as_control() { + let session = TestSession::with_accept_bytes(b"\x01channel"); + + let error = match accept_conversation(session, SSH_VERSION).await { + Ok(_) => panic!("channel stream is not a control stream"), + Err(error) => error, + }; + + assert!(matches!( + error, + AcceptConversationError::AcceptControl { + source: WebTransportStreamError::UnexpectedStreamKind { kind } + } if kind == DSSH_CHANNEL_STREAM_KIND + )); + } } From b3ff9adf0cf127c1c43a30fa1da504cec97e22b6 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 11:08:34 +0800 Subject: [PATCH 10/39] feat(webtransport): add dssh connect helpers --- Cargo.lock | 3 +- Cargo.toml | 1 + src/webtransport.rs | 292 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 294 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad8c964..ac932c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,6 +649,7 @@ dependencies = [ "futures", "h3x", "http", + "http-body", "http-body-util", "libc", "nix 0.31.2", @@ -926,7 +927,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#4a07393120b46a6013cf9abf37e9cd744859bd6f" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#dbc8d2dd9e19043f23b7dc589a8f8689144eddb9" dependencies = [ "arc-swap", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index dee2feb..a9e42e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", feature "serde", ] } http = "1" +http-body = "1" http-body-util = "0.1" nix = { version = "0.31", features = [ "term", diff --git a/src/webtransport.rs b/src/webtransport.rs index 22b4a55..95457df 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -11,15 +11,23 @@ //! control stream is an ordinary WebTransport bidirectional stream marked with //! the control kind. +use std::convert::Infallible; + +use bytes::Bytes; use h3x::{ codec::{DecodeExt, EncodeExt, SinkWriter, StreamReader}, quic, varint::VarInt, }; -use snafu::{ResultExt, Snafu}; +use http::{HeaderValue, header::AUTHORIZATION, uri::Authority}; +use http_body_util::{BodyExt, Empty}; +use snafu::{OptionExt, ResultExt, Snafu, ensure}; use tokio::io::AsyncWriteExt; +use crate::constants::{SSH_VERSION, SSH3_CONNECT_PATH}; use crate::conversation::{Conversation, ManageSessionStream}; +use crate::error::NegotiateVersionError; +use crate::version::{SshVersion, negotiate_version, version_response_header}; /// DSSH-over-WebTransport stream kind for the conversation control stream. pub const DSSH_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); @@ -44,6 +52,18 @@ pub type WebTransportConversation = Conversation< SinkWriter<::StreamWriter>, >; +/// DSSH conversation backed by a concrete h3x WebTransport session. +pub type ClientWebTransportConversation = + WebTransportConversation; + +/// Accepted server-side WebTransport session plus the HTTP response that must +/// be returned to complete the Extended CONNECT handshake. +pub struct AcceptedWebTransportSession { + pub response: http::Response>, + pub session: h3x::webtransport::WebTransportSession, + pub peer_version: String, +} + impl WebTransportStreamManager { pub fn new(session: S) -> Self { Self { session } @@ -197,6 +217,172 @@ where Ok(Conversation::new(id, peer_version, reader, writer, manager)) } +/// Error returned when constructing a client-side DSSH WebTransport CONNECT +/// request. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum BuildClientConnectRequestError { + #[snafu(display("failed to build dssh webtransport connect URI"))] + Uri { source: http::uri::InvalidUri }, + #[snafu(display("failed to build dssh webtransport connect request"))] + Request { source: http::Error }, +} + +/// Error returned when opening a client-side DSSH conversation over +/// WebTransport. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ClientConnectConversationError { + #[snafu(display("failed to build dssh webtransport connect request"))] + BuildRequest { + source: BuildClientConnectRequestError, + }, + #[snafu(display("failed to execute dssh webtransport connect request"))] + Execute { + source: h3x::hyper::client::RequestError, + }, + #[snafu(display("failed to validate dssh peer version"))] + PeerVersion { source: NegotiateVersionError }, + #[snafu(display("failed to establish extended connect"))] + Establish { + source: h3x::hyper::extended_connect::EstablishError, + }, + #[snafu(display("successful dssh webtransport connect response was not validated"))] + MissingValidatedPeerVersion, + #[snafu(display("failed to register webtransport session"))] + RegisterSession { + source: h3x::webtransport::RegisterSessionError, + }, + #[snafu(display("failed to open dssh webtransport conversation"))] + OpenConversation { source: OpenConversationError }, +} + +/// Error returned when accepting a server-side DSSH WebTransport session from +/// an Extended CONNECT request. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum AcceptServerSessionError { + #[snafu(display("extended connect path {path} is not the dssh connect path"))] + UnexpectedPath { path: String }, + #[snafu(display("failed to validate dssh peer version"))] + PeerVersion { source: NegotiateVersionError }, + #[snafu(display("failed to accept extended connect"))] + Accept { + source: h3x::hyper::extended_connect::AcceptError, + }, + #[snafu(display("failed to register webtransport session"))] + RegisterSession { + source: h3x::webtransport::RegisterSessionError, + }, +} + +/// Build a DSSH WebTransport Extended CONNECT request. +/// +/// The returned request carries `:protocol = webtransport-h3` through h3x's +/// [`h3x::qpack::field::Protocol`] extension and includes the DSSH +/// `ssh-version` header. Authentication, when present, is carried as a normal +/// HTTP `Authorization` header. +pub fn client_connect_request( + authority: &Authority, + authorization: Option, +) -> Result>, BuildClientConnectRequestError> { + let uri = format!("https://{authority}{SSH3_CONNECT_PATH}") + .parse::() + .context(build_client_connect_request_error::UriSnafu)?; + + let mut builder = http::Request::builder() + .method(http::Method::CONNECT) + .uri(uri) + .header("ssh-version", SSH_VERSION) + .extension(h3x::qpack::field::Protocol::new( + h3x::webtransport::WEBTRANSPORT_H3, + )); + if let Some(value) = authorization { + builder = builder.header(AUTHORIZATION, value); + } + + builder + .body(Empty::::new()) + .context(build_client_connect_request_error::RequestSnafu) +} + +fn peer_version(headers: &http::HeaderMap) -> Result { + negotiate_version(headers) +} + +/// Send a DSSH WebTransport Extended CONNECT request and open the DSSH +/// conversation control stream on the resulting WebTransport session. +pub async fn open_client_conversation( + connection: &h3x::connection::Connection, + authority: &Authority, + authorization: Option, +) -> Result +where + C: h3x::quic::Connection, +{ + let request = client_connect_request(authority, authorization) + .context(client_connect_conversation_error::BuildRequestSnafu)?; + let response = connection + .execute_hyper_request(request) + .await + .context(client_connect_conversation_error::ExecuteSnafu)?; + let peer_version = if response.status().is_success() { + Some( + peer_version(response.headers()) + .context(client_connect_conversation_error::PeerVersionSnafu)?, + ) + } else { + None + }; + let connect = h3x::hyper::extended_connect::establish(response.map(|body| body.boxed_unsync())) + .await + .context(client_connect_conversation_error::EstablishSnafu)?; + let peer_version = peer_version + .context(client_connect_conversation_error::MissingValidatedPeerVersionSnafu)?; + let session = h3x::webtransport::WebTransportSession::try_from(connect) + .context(client_connect_conversation_error::RegisterSessionSnafu)?; + open_conversation(session, peer_version.version_string) + .await + .context(client_connect_conversation_error::OpenConversationSnafu) +} + +/// Accept a DSSH WebTransport Extended CONNECT request after the caller has +/// already made its authentication and authorization decision. +/// +/// The returned HTTP response must be sent back to the peer. Only after that +/// response is on the wire should the server call [`accept_conversation`] in a +/// task that owns the returned session; otherwise client and server can +/// deadlock waiting for each other. +pub async fn accept_server_session( + request: http::Request, +) -> Result +where + B: http_body::Body + Unpin + Send + 'static, +{ + ensure!( + request.uri().path() == SSH3_CONNECT_PATH, + accept_server_session_error::UnexpectedPathSnafu { + path: request.uri().path().to_owned(), + } + ); + let version = + peer_version(request.headers()).context(accept_server_session_error::PeerVersionSnafu)?; + let (mut response, connect) = h3x::hyper::extended_connect::accept(request) + .await + .context(accept_server_session_error::AcceptSnafu)?; + let session = h3x::webtransport::WebTransportSession::try_from(connect) + .context(accept_server_session_error::RegisterSessionSnafu)?; + response + .headers_mut() + .insert("ssh-version", version_response_header(&version)); + + Ok(AcceptedWebTransportSession { + response, + session, + peer_version: version.version_string, + }) +} + /// Error returned by [`WebTransportStreamManager`] operations. #[derive(Debug, Snafu)] #[snafu(module)] @@ -254,6 +440,7 @@ mod tests { quic::{CancelStream, GetStreamId, StopStream}, stream_id::StreamId, }; + use http::HeaderMap; use tokio::io::AsyncReadExt; use super::*; @@ -590,4 +777,107 @@ mod tests { } if kind == DSSH_CHANNEL_STREAM_KIND )); } + + #[test] + fn client_connect_request_uses_webtransport_protocol_and_version() { + let authority: Authority = "example.test:443".parse().expect("authority"); + let authorization = HeaderValue::from_static("Basic abc"); + + let request = client_connect_request(&authority, Some(authorization.clone())) + .expect("request builds"); + + assert_eq!(request.method(), http::Method::CONNECT); + assert_eq!( + request.uri().to_string(), + format!("https://{authority}{SSH3_CONNECT_PATH}") + ); + assert_eq!( + request.headers().get("ssh-version"), + Some(&HeaderValue::from_static(SSH_VERSION)) + ); + assert_eq!(request.headers().get(AUTHORIZATION), Some(&authorization)); + assert_eq!( + request + .extensions() + .get::() + .map(h3x::qpack::field::Protocol::as_str), + Some(h3x::webtransport::WEBTRANSPORT_H3) + ); + } + + #[test] + fn peer_version_rejects_missing_invalid_and_unsupported_values() { + let headers = HeaderMap::new(); + assert!(matches!( + peer_version(&headers), + Err(NegotiateVersionError::MissingSshVersionHeader) + )); + + let mut headers = HeaderMap::new(); + headers.insert( + "ssh-version", + HeaderValue::from_bytes(b"dssh-00\xff").expect("opaque header value"), + ); + assert!(matches!( + peer_version(&headers), + Err(NegotiateVersionError::InvalidSshVersionHeaderValue { .. }) + )); + + let mut headers = HeaderMap::new(); + headers.insert("ssh-version", HeaderValue::from_static("dssh-99")); + assert!(matches!( + peer_version(&headers), + Err(NegotiateVersionError::UnsupportedSshVersion { offered }) if offered == "dssh-99" + )); + + let mut headers = HeaderMap::new(); + headers.insert("ssh-version", HeaderValue::from_static(SSH_VERSION)); + assert_eq!( + peer_version(&headers) + .expect("supported version") + .version_string, + SSH_VERSION + ); + } + + #[tokio::test] + async fn accept_server_session_rejects_wrong_path_before_registering_session() { + let request = http::Request::builder() + .method(http::Method::CONNECT) + .uri("https://example.test/not-dssh") + .header("ssh-version", SSH_VERSION) + .body(Empty::::new()) + .expect("request"); + + let error = match accept_server_session(request).await { + Ok(_) => panic!("wrong path must not be accepted"), + Err(error) => error, + }; + + assert!(matches!( + error, + AcceptServerSessionError::UnexpectedPath { path } if path == "/not-dssh" + )); + } + + #[tokio::test] + async fn accept_server_session_rejects_missing_version_before_registering_session() { + let request = http::Request::builder() + .method(http::Method::CONNECT) + .uri(format!("https://example.test{SSH3_CONNECT_PATH}")) + .body(Empty::::new()) + .expect("request"); + + let error = match accept_server_session(request).await { + Ok(_) => panic!("missing version must not be accepted"), + Err(error) => error, + }; + + assert!(matches!( + error, + AcceptServerSessionError::PeerVersion { + source: NegotiateVersionError::MissingSshVersionHeader + } + )); + } } From 2f47357950ee71828a251a183f8d1955624b1a87 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 11:21:28 +0800 Subject: [PATCH 11/39] refactor(ipc): generalize manage stream adapter --- src/conversation/ipc.rs | 182 ++++++++++++++++++++++++---------------- 1 file changed, 110 insertions(+), 72 deletions(-) diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index 341c0ca..d19432b 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -7,15 +7,16 @@ //! //! # Architecture //! -//! The gateway process wraps a -//! [`ConversationHandle`](crate::protocol::ConversationHandle) in an -//! [`IpcManageStreamAdapter`] and serves the generated -//! [`IpcManageSessionStreamServerShared`]. Each `open_stream` / `accept_stream` -//! call: -//! 1. Opens a real QUIC bidirectional stream (with routing header). +//! The gateway process wraps a [`ManageSessionStream`](super::ManageSessionStream) +//! implementation (for example +//! [`ConversationHandle`](crate::protocol::ConversationHandle) or a +//! WebTransport-backed stream manager) in an [`IpcManageStreamAdapter`] and +//! serves the generated [`IpcManageSessionStreamServerShared`]. Each +//! `open_stream` / `accept_stream` call: +//! 1. Opens a real bidirectional stream through the wrapped manager. //! 2. Creates a Unix socketpair. //! 3. Queues the client-side FD through the [`FdSender`]. -//! 4. Spawns bridge tasks forwarding data between the QUIC streams and the +//! 4. Spawns bridge tasks forwarding data between the managed stream and the //! server-side socketpair half. //! 5. Returns the FD-registry batch ID over RPC. //! @@ -28,14 +29,13 @@ use bytes::{Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use h3x::{ - codec::{BoxReadStream, BoxWriteStream}, ipc::transport::{FdRegistry, FdSender, WaitFdsError}, quic::ConnectionError, varint::VarInt, }; use snafu::{OptionExt, Snafu}; use tokio::{ - io::AsyncWriteExt, + io::{AsyncRead, AsyncWrite, AsyncWriteExt}, net::{ UnixStream, unix::{OwnedReadHalf, OwnedWriteHalf}, @@ -43,8 +43,6 @@ use tokio::{ }; use tracing::Instrument; -use crate::protocol::{ConversationHandle, HandleError}; - fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Result { stream.set_nonblocking(true)?; UnixStream::from_std(stream) @@ -153,10 +151,10 @@ impl super::ManageSessionStream for IpcManageStreamHandle { // Server: IpcManageStreamAdapter // --------------------------------------------------------------------------- -/// Server-side adapter bridging a [`ConversationHandle`] to the +/// Server-side adapter bridging a [`ManageSessionStream`](super::ManageSessionStream) to the /// [`IpcManageSessionStream`] RPC trait. /// -/// Each call opens a real QUIC stream, creates a Unix socketpair, spawns +/// Each call opens a real managed stream, creates a Unix socketpair, spawns /// bridge tasks, and queues the client-side FD through the [`FdSender`]. /// /// Bridge tasks are spawned via [`tokio::spawn`] so they outlive this @@ -164,22 +162,25 @@ impl super::ManageSessionStream for IpcManageStreamHandle { /// (i.e. when the child process drops its half). This is important because /// the adapter may be dropped (via the remoc `ServerShared` lifecycle) /// before the bridge has finished flushing final data — such as SSH -/// exit-status, EOF and Close messages — to the QUIC stream. -pub struct IpcManageStreamAdapter { - handle: ConversationHandle, +/// exit-status, EOF and Close messages — to the managed stream. +pub struct IpcManageStreamAdapter { + manage_stream: M, fd_sender: FdSender, } -impl IpcManageStreamAdapter { - pub fn new(handle: ConversationHandle, fd_sender: FdSender) -> Self { - Self { handle, fd_sender } +impl IpcManageStreamAdapter { + pub fn new(manage_stream: M, fd_sender: FdSender) -> Self { + Self { + manage_stream, + fd_sender, + } } - fn bridge_and_queue( - &self, - reader: BoxReadStream, - writer: BoxWriteStream, - ) -> Result { + fn bridge_and_queue(&self, reader: R, writer: W) -> Result + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { let (srv, cli) = std::os::unix::net::UnixStream::pair().map_err(|e| to_conn_error(e, "socketpair"))?; cli.set_nonblocking(true) @@ -196,29 +197,35 @@ impl IpcManageStreamAdapter { // Spawn bridge tasks independently so they are NOT aborted when this // adapter is dropped. The tasks will terminate on their own once the // Unix socketpair closes (child process exit / fd drop). - tokio::spawn(bridge_quic_reader_to_unix(reader, srv_write).in_current_span()); - tokio::spawn(bridge_unix_to_quic_writer(srv_read, writer).in_current_span()); + tokio::spawn(bridge_reader_to_unix(reader, srv_write).in_current_span()); + tokio::spawn(bridge_unix_to_writer(srv_read, writer).in_current_span()); Ok(fd_id) } } -impl IpcManageSessionStream for IpcManageStreamAdapter { +impl IpcManageSessionStream for IpcManageStreamAdapter +where + M: super::ManageSessionStream + 'static, + M::StreamReader: AsyncRead + Unpin + Send + 'static, + M::StreamWriter: AsyncWrite + Unpin + Send + 'static, + M::Error: Send + Sync + 'static, +{ async fn open_stream(&self) -> Result { let (reader, writer) = self - .handle - .open_raw_stream() + .manage_stream + .open_stream() .await - .map_err(handle_error_to_connection_error)?; + .map_err(manage_stream_error_to_connection_error)?; self.bridge_and_queue(reader, writer) } async fn accept_stream(&self) -> Result { let (reader, writer) = self - .handle - .accept_raw_stream() + .manage_stream + .accept_stream() .await - .map_err(handle_error_to_connection_error)?; + .map_err(manage_stream_error_to_connection_error)?; self.bridge_and_queue(reader, writer) } } @@ -227,43 +234,22 @@ impl IpcManageSessionStream for IpcManageStreamAdapter { // Bridge helpers: QUIC stream ↔ Unix socketpair // --------------------------------------------------------------------------- -/// Forward data from a QUIC read stream to a Unix socket write half. -async fn bridge_quic_reader_to_unix(mut reader: BoxReadStream, mut writer: OwnedWriteHalf) { - while let Some(Ok(chunk)) = reader.next().await { - if writer.write_all(&chunk).await.is_err() { - break; - } - } +/// Forward bytes from an async reader to a Unix socket write half. +pub async fn bridge_reader_to_unix(mut reader: R, mut writer: OwnedWriteHalf) +where + R: AsyncRead + Unpin, +{ + let _ = tokio::io::copy(&mut reader, &mut writer).await; let _ = writer.shutdown().await; } -/// Forward data from a Unix socket read half to a QUIC write stream. -async fn bridge_unix_to_quic_writer(mut reader: OwnedReadHalf, mut writer: BoxWriteStream) { - use tokio::io::AsyncReadExt; - - let mut buf = BytesMut::with_capacity(8192); - loop { - buf.reserve(8192); - match reader.read_buf(&mut buf).await { - Ok(0) => { - tracing::debug!("bridge_unix_to_quic: unix socket EOF"); - break; - } - Err(e) => { - tracing::debug!(error = %e, "bridge_unix_to_quic: unix socket read error"); - break; - } - Ok(n) => { - tracing::trace!(bytes = n, "bridge_unix_to_quic: forwarding to QUIC"); - if writer.send(buf.split().freeze()).await.is_err() { - tracing::debug!("bridge_unix_to_quic: QUIC write failed"); - break; - } - } - } - } - let _ = writer.close().await; - tracing::debug!("bridge_unix_to_quic: closed"); +/// Forward bytes from a Unix socket read half to an async writer. +pub async fn bridge_unix_to_writer(mut reader: OwnedReadHalf, mut writer: W) +where + W: AsyncWrite + Unpin, +{ + let _ = tokio::io::copy(&mut reader, &mut writer).await; + let _ = writer.shutdown().await; } // --------------------------------------------------------------------------- @@ -314,27 +300,79 @@ pub async fn bridge_unix_to_message_writer( // --------------------------------------------------------------------------- fn to_conn_error(err: impl std::fmt::Display, context: &str) -> ConnectionError { - tracing::warn!(%err, context, "IPC manage stream error"); + tracing::warn!(%err, context, "ipc manage stream error"); h3x::quic::ApplicationError { code: h3x::error::Code::from(VarInt::from_u32(0)), - reason: std::borrow::Cow::Owned(format!("IPC {context}: {err}")), + reason: std::borrow::Cow::Owned(format!("ipc {context}: {err}")), } .into() } -fn handle_error_to_connection_error(e: HandleError) -> ConnectionError { +fn manage_stream_error_to_connection_error(error: E) -> ConnectionError +where + E: std::error::Error + Send + Sync + 'static, +{ h3x::quic::ApplicationError { code: h3x::error::Code::from(VarInt::from_u32(0)), - reason: std::borrow::Cow::Owned(snafu::Report::from_error(e).to_string()), + reason: std::borrow::Cow::Owned(snafu::Report::from_error(&error).to_string()), } .into() } #[cfg(test)] mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use h3x::{ipc::transport::MuxChannel, varint::VarInt}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use super::unix_stream_from_std; + use super::{IpcManageSessionStream, IpcManageStreamAdapter, unix_stream_from_std}; + use crate::conversation::ManageSessionStream; + + #[derive(Clone, Debug)] + struct MockManageStream { + open_calls: Arc, + } + + impl ManageSessionStream for MockManageStream { + type StreamReader = tokio::io::Empty; + type StreamWriter = tokio::io::Sink; + type Error = std::io::Error; + + async fn open_stream( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + self.open_calls.fetch_add(1, Ordering::SeqCst); + Ok((tokio::io::empty(), tokio::io::sink())) + } + + async fn accept_stream( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + Ok((tokio::io::empty(), tokio::io::sink())) + } + } + + #[tokio::test] + async fn ipc_adapter_accepts_generic_manage_session_stream() { + let open_calls = Arc::new(AtomicUsize::new(0)); + let manage_stream = MockManageStream { + open_calls: open_calls.clone(), + }; + let (channel, _remote_fd) = MuxChannel::create_pair().expect("mux channel"); + let (sink, _stream) = channel.split().expect("split mux channel"); + let adapter = IpcManageStreamAdapter::new(manage_stream, sink.fd_sender()); + + let fd_id = IpcManageSessionStream::open_stream(&adapter) + .await + .expect("open stream through adapter"); + + assert_eq!(fd_id, VarInt::from_u32(0)); + assert_eq!(open_calls.load(Ordering::SeqCst), 1); + } #[tokio::test] async fn unix_stream_from_std_accepts_default_blocking_socketpair() { From d9fe38afb0ea53e2cacadb79eab4e9515babf986 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 11:33:39 +0800 Subject: [PATCH 12/39] feat(webtransport): parameterize dssh connect path --- src/client.rs | 6 +++--- src/constants.rs | 7 +++++-- src/webtransport.rs | 40 ++++++++++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/client.rs b/src/client.rs index e373232..6fed2d3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -5,9 +5,9 @@ use base64::engine::{Engine, general_purpose::STANDARD}; use http::HeaderValue; -/// Well-known path for SSH3 Extended CONNECT requests. -#[deprecated(note = "use `constants::SSH3_CONNECT_PATH` instead")] -pub use crate::constants::SSH3_CONNECT_PATH; +/// Well-known path for DSSH WebTransport Extended CONNECT requests. +#[deprecated(note = "use `constants::DSSH_CONNECT_PATH` instead")] +pub use crate::constants::DSSH_CONNECT_PATH as SSH3_CONNECT_PATH; /// Encode Basic auth header value: `Basic base64(username:password)`. pub fn encode_basic_auth(username: &str, password: &str) -> HeaderValue { diff --git a/src/constants.rs b/src/constants.rs index 9ac0b8a..0800938 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -8,5 +8,8 @@ pub const CHANNEL_SIGNAL_VALUE: VarInt = VarInt::from_u32(0xaf3627e6); pub const DEFAULT_MAX_MESSAGE_SIZE: VarInt = VarInt::from_u32(1 << 20); -/// Well-known path for SSH3 Extended CONNECT requests. -pub const SSH3_CONNECT_PATH: &str = "/.well-known/ssh3/connect"; +/// Well-known path for DSSH WebTransport Extended CONNECT requests. +pub const DSSH_CONNECT_PATH: &str = "/.well-known/dssh/connect"; + +/// Legacy name for the DSSH WebTransport Extended CONNECT path. +pub const SSH3_CONNECT_PATH: &str = DSSH_CONNECT_PATH; diff --git a/src/webtransport.rs b/src/webtransport.rs index 95457df..f718afd 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -24,7 +24,7 @@ use http_body_util::{BodyExt, Empty}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; use tokio::io::AsyncWriteExt; -use crate::constants::{SSH_VERSION, SSH3_CONNECT_PATH}; +use crate::constants::SSH_VERSION; use crate::conversation::{Conversation, ManageSessionStream}; use crate::error::NegotiateVersionError; use crate::version::{SshVersion, negotiate_version, version_response_header}; @@ -281,12 +281,16 @@ pub enum AcceptServerSessionError { /// The returned request carries `:protocol = webtransport-h3` through h3x's /// [`h3x::qpack::field::Protocol`] extension and includes the DSSH /// `ssh-version` header. Authentication, when present, is carried as a normal -/// HTTP `Authorization` header. +/// HTTP `Authorization` header. `path` is supplied by the caller so gateways +/// can keep their routed SSH location, while clients that want a stable +/// well-known endpoint can pass +/// [`DSSH_CONNECT_PATH`](crate::constants::DSSH_CONNECT_PATH). pub fn client_connect_request( authority: &Authority, + path: &str, authorization: Option, ) -> Result>, BuildClientConnectRequestError> { - let uri = format!("https://{authority}{SSH3_CONNECT_PATH}") + let uri = format!("https://{authority}{path}") .parse::() .context(build_client_connect_request_error::UriSnafu)?; @@ -315,12 +319,13 @@ fn peer_version(headers: &http::HeaderMap) -> Result( connection: &h3x::connection::Connection, authority: &Authority, + path: &str, authorization: Option, ) -> Result where C: h3x::quic::Connection, { - let request = client_connect_request(authority, authorization) + let request = client_connect_request(authority, path, authorization) .context(client_connect_conversation_error::BuildRequestSnafu)?; let response = connection .execute_hyper_request(request) @@ -349,18 +354,23 @@ where /// Accept a DSSH WebTransport Extended CONNECT request after the caller has /// already made its authentication and authorization decision. /// +/// The accepted request path must match `path`. This keeps route ownership with +/// the server or gateway layer instead of forcing every deployment to use the +/// well-known DSSH path. +/// /// The returned HTTP response must be sent back to the peer. Only after that /// response is on the wire should the server call [`accept_conversation`] in a /// task that owns the returned session; otherwise client and server can /// deadlock waiting for each other. pub async fn accept_server_session( request: http::Request, + path: &str, ) -> Result where B: http_body::Body + Unpin + Send + 'static, { ensure!( - request.uri().path() == SSH3_CONNECT_PATH, + request.uri().path() == path, accept_server_session_error::UnexpectedPathSnafu { path: request.uri().path().to_owned(), } @@ -444,7 +454,7 @@ mod tests { use tokio::io::AsyncReadExt; use super::*; - use crate::constants::SSH_VERSION; + use crate::constants::{DSSH_CONNECT_PATH, SSH_VERSION}; #[derive(Debug, Default)] struct StreamState { @@ -779,17 +789,23 @@ mod tests { } #[test] - fn client_connect_request_uses_webtransport_protocol_and_version() { + fn dssh_connect_path_is_well_known_dssh_path() { + assert_eq!(DSSH_CONNECT_PATH, "/.well-known/dssh/connect"); + } + + #[test] + fn client_connect_request_uses_custom_path_webtransport_protocol_and_version() { let authority: Authority = "example.test:443".parse().expect("authority"); let authorization = HeaderValue::from_static("Basic abc"); + let path = "/ssh/yiyue"; - let request = client_connect_request(&authority, Some(authorization.clone())) + let request = client_connect_request(&authority, path, Some(authorization.clone())) .expect("request builds"); assert_eq!(request.method(), http::Method::CONNECT); assert_eq!( request.uri().to_string(), - format!("https://{authority}{SSH3_CONNECT_PATH}") + format!("https://{authority}{path}") ); assert_eq!( request.headers().get("ssh-version"), @@ -849,7 +865,7 @@ mod tests { .body(Empty::::new()) .expect("request"); - let error = match accept_server_session(request).await { + let error = match accept_server_session(request, DSSH_CONNECT_PATH).await { Ok(_) => panic!("wrong path must not be accepted"), Err(error) => error, }; @@ -864,11 +880,11 @@ mod tests { async fn accept_server_session_rejects_missing_version_before_registering_session() { let request = http::Request::builder() .method(http::Method::CONNECT) - .uri(format!("https://example.test{SSH3_CONNECT_PATH}")) + .uri(format!("https://example.test{DSSH_CONNECT_PATH}")) .body(Empty::::new()) .expect("request"); - let error = match accept_server_session(request).await { + let error = match accept_server_session(request, DSSH_CONNECT_PATH).await { Ok(_) => panic!("missing version must not be accepted"), Err(error) => error, }; From 6565a670b50d44e269de69d2b11b2d295de4383c Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 11:45:37 +0800 Subject: [PATCH 13/39] refactor(webtransport): make dssh transport always available --- Cargo.toml | 2 +- src/lib.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a9e42e9..f73e6c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ repository = "https://github.com/genmeta/dssh" h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", features = [ "rpc", "serde", + "webtransport", ] } http = "1" http-body = "1" @@ -50,7 +51,6 @@ futures = "0.3" [features] client = [] server = ["h3x/ipc", "dep:nix", "dep:libc"] -webtransport = ["h3x/webtransport"] pam = ["server", "dep:pam-client2"] cli = ["dep:peg"] config = ["dep:peg"] diff --git a/src/lib.rs b/src/lib.rs index 4059b79..78d9784 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,4 @@ pub mod message; pub mod protocol; pub mod session; pub mod version; - -#[cfg(feature = "webtransport")] pub mod webtransport; From 2a72fc09bc112357fbc853e319eb0dd12800a657 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 12:03:58 +0800 Subject: [PATCH 14/39] refactor(forward): accept typed conversation streams --- src/forward/client.rs | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/forward/client.rs b/src/forward/client.rs index 1c4fee0..5651c81 100644 --- a/src/forward/client.rs +++ b/src/forward/client.rs @@ -82,14 +82,16 @@ impl LocalForward { /// /// This function runs an infinite accept loop and only returns if the /// initial bind fails. - pub async fn run( + pub async fn run( &self, - conversation: Arc>, + conversation: Arc>, ) -> Result where M: ManageSessionStream + 'static, M::StreamReader: 'static, M::StreamWriter: 'static, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { match &self.bind { Endpoint::Tcp { host, port } => { @@ -118,15 +120,17 @@ impl LocalForward { } } - async fn accept_loop_tcp( + async fn accept_loop_tcp( &self, listener: tokio::net::TcpListener, - conversation: Arc>, + conversation: Arc>, ) -> Result where M: ManageSessionStream + 'static, M::StreamReader: 'static, M::StreamWriter: 'static, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { @@ -148,15 +152,17 @@ impl LocalForward { } #[cfg(unix)] - async fn accept_loop_unix( + async fn accept_loop_unix( &self, listener: tokio::net::UnixListener, - conversation: Arc>, + conversation: Arc>, ) -> Result where M: ManageSessionStream + 'static, M::StreamReader: 'static, M::StreamWriter: 'static, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { @@ -178,8 +184,8 @@ impl LocalForward { } /// Open an SSH channel to the connect endpoint and relay data bidirectionally. -async fn open_channel_and_relay( - conversation: Arc>, +async fn open_channel_and_relay( + conversation: Arc>, connect: Endpoint, local_reader: Pin>, local_writer: Pin>, @@ -187,6 +193,8 @@ async fn open_channel_and_relay( M: ManageSessionStream + 'static, M::StreamReader: 'static, M::StreamWriter: 'static, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { let channel_result = match &connect { Endpoint::Tcp { host, port } => { @@ -239,10 +247,15 @@ impl RemoteForward { /// Send a global request to the server to start listening on the remote /// bind endpoint. Returns the established binding info (including any /// server-allocated port). - pub async fn request( + pub async fn request( &self, - conversation: &Conversation, - ) -> Result { + conversation: &Conversation, + ) -> Result + where + M: ManageSessionStream, + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, + { use request_remote_forward_error::*; match &self.bind { @@ -341,13 +354,15 @@ pub async fn connect_locally( /// based on the provided mappings. /// /// Runs until the conversation's channel accept stream ends. -pub async fn accept_forwarded_channels( - conversation: Arc>, +pub async fn accept_forwarded_channels( + conversation: Arc>, mappings: Vec, ) where M: ManageSessionStream + 'static, M::StreamReader: 'static, M::StreamWriter: 'static, + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { From fa2fb702f5608b94d145f80d9512a60e02b64212 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 27 May 2026 12:08:14 +0800 Subject: [PATCH 15/39] refactor(webtransport): remove legacy ssh3 stream protocol --- Cargo.toml | 8 - examples/ssh3-client.rs | 363 --------------------------------- examples/ssh3-server.rs | 431 ---------------------------------------- src/client.rs | 6 +- src/constants.rs | 3 - src/conversation/ipc.rs | 5 +- src/lib.rs | 15 +- src/protocol.rs | 424 --------------------------------------- 8 files changed, 17 insertions(+), 1238 deletions(-) delete mode 100644 examples/ssh3-client.rs delete mode 100644 examples/ssh3-server.rs delete mode 100644 src/protocol.rs diff --git a/Cargo.toml b/Cargo.toml index f73e6c2..70dc6c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,14 +70,6 @@ tower-service = "0.3" tokio-util = "0.7" tracing-subscriber = "0.3" -[[example]] -name = "ssh3-client" -required-features = ["client", "cli"] - -[[example]] -name = "ssh3-server" -required-features = ["server"] - [[example]] name = "ssh3-session" required-features = ["server"] diff --git a/examples/ssh3-client.rs b/examples/ssh3-client.rs deleted file mode 100644 index bec7bd8..0000000 --- a/examples/ssh3-client.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! SSH3 client example. -//! -//! Connects to an SSH3 server, opens a session channel, executes a command -//! (or starts an interactive shell), and relays stdin/stdout. -//! -//! Supports OpenSSH-compatible port forwarding: -//! - `-L` — local forwarding (TCP and Unix socket) -//! - `-R` — remote forwarding (TCP and Unix socket) -//! - `-D` — dynamic SOCKS forwarding - -use std::pin::Pin; -use std::sync::Arc; - -use clap::Parser; -use dssh::{ - client::SSH3_CONNECT_PATH, - client::encode_basic_auth, - constants::{DEFAULT_MAX_MESSAGE_SIZE, SSH_VERSION}, - conversation::{Conversation, channel::SshChannel}, - forward::{ - SessionChannelOpen, - client::{RemoteForwardEstablished, accept_forwarded_channels}, - spec::{DynamicForward, LocalForward, RemoteForward}, - }, - protocol::{ConversationHandle, Ssh3Protocol}, - session::client::ClientSession, -}; -use h3x::dquic::{ - H3Endpoint, QuicEndpoint, - client::{ClientQuicConfig, ServerCertVerifierChoice}, -}; -use h3x::qpack::field::Protocol; -use h3x::quic::GetStreamIdExt; -use h3x::stream_id::StreamId; -use http::{HeaderValue, Method, StatusCode}; -use http_body_util::Empty; -use snafu::{ResultExt, Whatever, ensure_whatever}; -use tokio::io::{AsyncRead, AsyncWrite}; -use tracing::Instrument; - -// ============================================================================ -// CLI -// ============================================================================ - -#[derive(Parser)] -#[command(about = "SSH3 client example")] -struct Cli { - /// Server authority (host:port) - authority: String, - - /// Username for basic auth - #[arg(short, long, default_value = "user")] - user: String, - - /// Password for basic auth - #[arg(short, long, default_value = "pass")] - password: String, - - /// Local port forwarding (OpenSSH-compatible syntax). - /// - /// Examples: - /// -L 8080:remote:80 TCP localhost:8080 → remote:80 - /// -L 0.0.0.0:8080:remote:80 TCP all-interfaces:8080 → remote:80 - /// -L 8080:/tmp/remote.sock TCP localhost:8080 → Unix socket - /// -L /tmp/local.sock:remote:80 Unix socket → TCP remote:80 - #[arg(short = 'L', value_name = "SPEC")] - local_forward: Vec, - - /// Remote port forwarding (OpenSSH-compatible syntax). - /// - /// Examples: - /// -R 8080:localhost:80 TCP *:8080 → localhost:80 - /// -R 0.0.0.0:8080:localhost:80 TCP all-interfaces:8080 → localhost:80 - /// -R 8080 TCP *:8080 (listen-only) - /// -R /tmp/remote.sock:localhost:80 Unix socket → TCP localhost:80 - #[arg(short = 'R', value_name = "SPEC")] - remote_forward: Vec, - - /// Dynamic SOCKS5 forwarding (OpenSSH-compatible syntax). - /// - /// Examples: - /// -D 1080 SOCKS5 on localhost:1080 - /// -D 0.0.0.0:1080 SOCKS5 on all interfaces - #[arg(short = 'D', value_name = "SPEC")] - dynamic_forward: Vec, - - /// Command to execute (omit for interactive shell) - command: Vec, -} - -// ============================================================================ -// Connection -// ============================================================================ - -/// Connect to an SSH3 server via Extended CONNECT. -async fn connect( - authority: &str, - auth_header: HeaderValue, - client: &H3Endpoint, -) -> Result, Whatever> { - let authority_parsed: http::uri::Authority = - authority.parse().whatever_context("invalid authority")?; - - let connection = client - .connect(authority_parsed.clone()) - .await - .whatever_context("failed to establish QUIC connection")?; - - let uri: http::Uri = format!("https://{authority_parsed}{SSH3_CONNECT_PATH}") - .parse() - .whatever_context("invalid URI")?; - - let request = http::Request::builder() - .method(Method::CONNECT) - .uri(uri) - .header("ssh-version", SSH_VERSION) - .header(http::header::AUTHORIZATION, auth_header) - .extension(Protocol::new("ssh3")) - .body(Empty::::new()) - .whatever_context("failed to build HTTP request")?; - - let (mut read_stream, mut write_stream) = connection - .initial_message_stream() - .await - .whatever_context("failed to open initial message stream")?; - - let conversation_id = write_stream - .stream_id() - .await - .whatever_context("failed to get stream ID")? - .into_inner(); - - write_stream - .send_hyper_request(request) - .await - .whatever_context("failed to send Extended CONNECT request")?; - - let mut response = read_stream - .read_hyper_response_parts() - .await - .whatever_context("failed to read HTTP response")?; - - while response.status.is_informational() { - response = read_stream - .read_hyper_response_parts() - .await - .whatever_context("failed to read HTTP response")?; - } - - ensure_whatever!( - response.status != StatusCode::UNAUTHORIZED, - "authentication failed (HTTP 401)" - ); - ensure_whatever!( - response.status == StatusCode::OK, - "unexpected HTTP status: {}", - response.status - ); - - let server_version_header = response.headers.get("ssh-version"); - ensure_whatever!( - server_version_header.is_some(), - "missing ssh-version response header" - ); - let server_version = server_version_header - .unwrap() - .to_str() - .whatever_context("invalid ssh-version header value")? - .to_owned(); - - ensure_whatever!( - server_version == SSH_VERSION, - "server offered unsupported version: {server_version}" - ); - - tracing::info!( - %authority, - conversation_id, - version = %server_version, - "SSH3 connection established" - ); - - let session_id = StreamId::try_from(conversation_id).unwrap(); - - // Try to use the connection's Ssh3Protocol (registered via Ssh3ProtocolFactory). - // This is required for accepting incoming channels (remote forwarding). - // Falls back to a standalone protocol if the factory wasn't registered. - let handle = if let Some(proto) = connection.protocol::() { - proto - .register(session_id) - .whatever_context("failed to register conversation")? - } else { - let conn = connection.clone(); - let protocol = Ssh3Protocol::new(move || { - let conn = conn.clone(); - Box::pin(async move { - use h3x::codec::BoxReadStream; - use h3x::codec::BoxWriteStream; - let (reader, writer) = conn.open_bi().await?; - Ok(( - Box::pin(reader) as BoxReadStream, - Box::pin(writer) as BoxWriteStream, - )) - }) - }); - protocol - .register(session_id) - .whatever_context("failed to register conversation")? - }; - - let control_reader: Pin> = Box::pin(read_stream.into_box_reader()); - let control_writer: Pin> = Box::pin(write_stream.into_box_writer()); - - Ok(Conversation::new( - session_id, - server_version, - control_reader, - control_writer, - handle, - )) -} - -// ============================================================================ -// Main -// ============================================================================ - -#[tokio::main] -async fn main() { - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install default crypto provider"); - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .init(); - let cli = Cli::parse(); - - let command: Option = if cli.command.is_empty() { - None - } else { - Some(cli.command.join(" ")) - }; - - let has_remote_forwards = !cli.remote_forward.is_empty(); - let client: H3Endpoint = { - let quic = QuicEndpoint::builder() - .client(ClientQuicConfig { - verifier: ServerCertVerifierChoice::Dangerous, - alpns: vec![b"h3".to_vec()], - ..Default::default() - }) - .build() - .await; - - // Register Ssh3Protocol so the client can accept incoming channels - // from the server (required for remote forwarding -R). - let conn_builder = if has_remote_forwards { - use h3x::connection::ConnectionBuilder; - Arc::new( - ConnectionBuilder::new(Arc::default()) - .protocol(dssh::protocol::Ssh3ProtocolFactory), - ) - } else { - Arc::new(h3x::connection::ConnectionBuilder::new(Arc::default())) - }; - - H3Endpoint::builder() - .quic(quic) - .builder(conn_builder) - .build() - }; - - let auth_header = encode_basic_auth(&cli.user, &cli.password); - let conversation = Arc::new( - connect(&cli.authority, auth_header, &client) - .await - .expect("SSH3 connect failed"), - ); - - tracing::info!("connected, peer version: {}", conversation.peer_version()); - - // Start local forward listeners (-L). - let mut forward_tasks = tokio::task::JoinSet::new(); - for spec in cli.local_forward { - let conv = conversation.clone(); - let label = spec.to_string(); - forward_tasks.spawn(async move { - let Err(e) = spec - .run(conv) - .instrument(tracing::info_span!("local_forward", %label)) - .await; - tracing::error!(error = %snafu::Report::from_error(&e), "local forward failed"); - }); - } - - // Request remote forwards (-R). - let mut remote_mappings: Vec = Vec::new(); - for spec in &cli.remote_forward { - let established = spec - .request(&conversation) - .await - .unwrap_or_else(|e| panic!("remote forward request failed: {e}")); - remote_mappings.push(established); - } - - // Spawn channel acceptor for remote forwards (-R). - if !remote_mappings.is_empty() { - let conv = conversation.clone(); - forward_tasks.spawn( - accept_forwarded_channels(conv, remote_mappings) - .instrument(tracing::info_span!("channel_acceptor")), - ); - } - - // Dynamic forwards (-D). - if !cli.dynamic_forward.is_empty() { - tracing::error!("dynamic SOCKS5 forwarding (-D) is not yet implemented"); - std::process::exit(1); - } - - // Open a session channel. - let (reader, writer) = conversation - .open_channel(&SessionChannelOpen, DEFAULT_MAX_MESSAGE_SIZE) - .await - .expect("failed to open session channel"); - - let channel = SshChannel::new(reader, writer); - let mut session = ClientSession::new(channel); - - // Send exec or shell request. - match command { - Some(cmd) => { - session - .exec(cmd.as_bytes()) - .await - .expect("exec request failed"); - } - None => { - session.shell().await.expect("shell request failed"); - } - } - - // Relay I/O: stdin → channel, channel events → stdout/stderr. - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); - let stderr = tokio::io::stderr(); - - let exit = session - .run(stdin, stdout, stderr) - .await - .expect("session IO relay failed"); - - let exit_code = match exit { - Some(dssh::session::client::ExitResult::Status(code)) => code, - Some(dssh::session::client::ExitResult::Signal { signal_name, .. }) => { - tracing::info!(%signal_name, "process killed by signal"); - 128 - } - None => 1, - }; - - // Forward tasks are dropped (aborted) when we exit. - std::process::exit(exit_code as i32); -} diff --git a/examples/ssh3-server.rs b/examples/ssh3-server.rs deleted file mode 100644 index 3811ce4..0000000 --- a/examples/ssh3-server.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! SSH3 server (gateway) example. -//! -//! Listens for QUIC connections, handles SSH3 Extended CONNECT requests, -//! and dispatches sessions via privilege-separated child processes. -//! -//! Uses tower service + h3x upgrade pattern: the handler returns an HTTP -//! response, then a spawned task obtains the underlying streams via the -//! upgrade/takeover mechanism for the SSH3 session. -//! -//! Each session spawns a child process (`--session-binary `) that -//! performs PAM authentication and runs the session after dropping -//! privileges. Communication uses remoc RPC over a MuxChannel socketpair, -//! with stream data forwarded via FD-passing Unix socketpairs. - -use std::convert::Infallible; -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; -use std::process::Stdio; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use bytes::Bytes; -use clap::Parser; -use dssh::{ - auth::{AuthCredential, parse_authorization_header}, - constants::{SSH_VERSION, SSH3_CONNECT_PATH}, - conversation::ipc::{IpcManageSessionStreamServerShared, IpcManageStreamAdapter}, - protocol::Ssh3ProtocolFactory, - session::{AuthRequest, AuthenticateFn, SessionBootstrap}, -}; -use h3x::connection::{ConnectionBuilder, ConnectionState}; -use h3x::dquic::{ - QuicEndpoint, - binds::BindPattern, - cert::handy::{ToCertificate, ToPrivateKey}, - identity::Identity, - server::ServerQuicConfig, -}; -use h3x::endpoint::H3Endpoint; -use h3x::hyper::server::TowerService; -use h3x::ipc::transport::MuxChannel; -use h3x::message::stream::MessageStreamError; -use h3x::quic::DynConnection; -use h3x::stream_id::StreamId; -use http::{Method, StatusCode}; -use http_body_util::{BodyExt, Empty, combinators::UnsyncBoxBody}; -use remoc::prelude::*; -use snafu::Report; -use tracing::Instrument; - -type BoxBody = UnsyncBoxBody; - -fn empty_body() -> BoxBody { - UnsyncBoxBody::new(Empty::new().map_err(|n: Infallible| match n {})) -} - -#[derive(Parser)] -#[command(about = "SSH3 server example")] -struct Cli { - /// Path to TLS certificate (PEM) - cert: String, - - /// Path to TLS private key (PEM) - key: String, - - /// Bind address - #[arg(short, long, default_value = "0.0.0.0:443")] - bind: String, - - /// Path to session binary for privilege-separated mode. - /// Each session spawns a child process that handles PAM authentication - /// and runs the session after dropping privileges. - #[arg(long)] - session_binary: PathBuf, -} - -/// Tower service that handles SSH3 Extended CONNECT requests. -/// -/// Wrapped by [`TowerService`] to bridge between h3x's server framework -/// and the tower service interface. -#[derive(Clone)] -struct Ssh3ConnectService { - session_binary: PathBuf, -} - -impl tower_service::Service> for Ssh3ConnectService { - type Response = http::Response; - type Error = Infallible; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, request: http::Request) -> Self::Future { - let session_binary = self.session_binary.clone(); - Box::pin(handle_ssh3_connect(request, session_binary)) - } -} - -#[tokio::main] -async fn main() { - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install default crypto provider"); - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .init(); - let cli = Cli::parse(); - - let cert_pem = std::fs::read(&cli.cert).expect("failed to read certificate"); - let key_pem = std::fs::read(&cli.key).expect("failed to read private key"); - - let service = TowerService(Ssh3ConnectService { - session_binary: cli.session_binary, - }); - - let builder = Arc::new(ConnectionBuilder::new(Arc::default()).protocol(Ssh3ProtocolFactory)); - let identity = Arc::new(Identity { - name: "localhost".parse().expect("localhost is a valid DNS name"), - certs: Arc::new(cert_pem.as_slice().to_certificate()), - key: Arc::new(key_pem.as_slice().to_private_key()), - ocsp: Arc::new(None), - }); - let bind: BindPattern = format!("inet://{}", cli.bind) - .parse() - .expect("failed to parse bind address"); - let quic = QuicEndpoint::builder() - .maybe_identity(Some(identity)) - .server(ServerQuicConfig { - alpns: vec![b"h3".to_vec()], - ..Default::default() - }) - .bind(Arc::new(vec![bind])) - .build() - .await; - let mut endpoint = H3Endpoint::builder().quic(quic).builder(builder).build(); - - tracing::info!(bind = %cli.bind, "ssh3 server listening"); - if let Err(error) = endpoint.serve(service).await { - tracing::error!(error = %snafu::Report::from_error(&error), "server stopped"); - } -} - -fn error_response(status: StatusCode) -> Result, Infallible> { - Ok(http::Response::builder() - .status(status) - .body(empty_body()) - .unwrap()) -} - -fn ok_response() -> Result, Infallible> { - Ok(http::Response::builder() - .status(StatusCode::OK) - .header("ssh-version", SSH_VERSION) - .body(empty_body()) - .unwrap()) -} - -async fn handle_ssh3_connect( - request: http::Request, - session_binary: PathBuf, -) -> Result, Infallible> { - if request.method() != Method::CONNECT || request.uri().path() != SSH3_CONNECT_PATH { - return error_response(StatusCode::NOT_FOUND); - } - - let auth_header = request - .headers() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let credential = match parse_authorization_header(auth_header) { - Ok(c) => c, - Err(e) => { - tracing::warn!(error = %snafu::Report::from_error(&e), "auth parse failed"); - return error_response(StatusCode::UNAUTHORIZED); - } - }; - - let username = match &credential { - AuthCredential::Basic { username, .. } => username.clone(), - AuthCredential::Certificate => { - tracing::warn!("certificate auth not supported in this example"); - return error_response(StatusCode::UNAUTHORIZED); - } - }; - - let peer_version = match request - .headers() - .get("ssh-version") - .and_then(|v| v.to_str().ok()) - { - Some(v) if v == SSH_VERSION => v.to_owned(), - _ => return error_response(StatusCode::BAD_REQUEST), - }; - - let conversation_id = *request - .extensions() - .get::() - .expect("StreamId not in extensions"); - let connection = request - .extensions() - .get::>>() - .expect("ConnectionState not in extensions") - .clone(); - let ssh3_proto = connection - .protocols() - .get::() - .expect("Ssh3ProtocolFactory not registered"); - let handle = match ssh3_proto.register(conversation_id) { - Ok(h) => h, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "register failed"); - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - handle_child_process( - request, - handle, - username, - credential, - peer_version, - conversation_id, - &session_binary, - ) - .await -} - -/// Child-process session: spawn ssh3-session, PAM auth via remoc RFnOnce. -/// -/// Communication uses a MuxChannel socketpair (remoc RPC + FD passing): -/// - remoc channel for RFnOnce exchange and IpcManageSessionStream RPC -/// - FD sideband for control stream and per-stream Unix socketpairs -/// -/// PAM authentication happens synchronously before the response is sent. -/// On success, spawns a task that waits for upgrade, sets up FD-based stream -/// serving, and calls the child's StartSessionFn. -async fn handle_child_process( - mut request: http::Request, - handle: dssh::protocol::ConversationHandle, - username: String, - credential: dssh::auth::AuthCredential, - peer_version: String, - conversation_id: StreamId, - session_binary: &std::path::Path, -) -> Result, Infallible> { - let span = tracing::info_span!("child-session", %conversation_id, user = %username); - - // Create MuxChannel socketpair for parent↔child IPC. - let (parent_mux, child_fd) = match MuxChannel::create_pair() { - Ok(pair) => pair, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "failed to create MuxChannel pair"); - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // Spawn the session child process with the MuxChannel FD on stdin. - let mut child = match tokio::process::Command::new(session_binary) - .stdin(child_fd) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "failed to spawn session binary"); - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // Split MuxChannel and establish remoc connection. - let (sink, stream) = match parent_mux.split() { - Ok(pair) => pair, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "failed to split MuxChannel"); - let _ = child.kill().await; - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - let fd_sender = sink.fd_sender(); - - let (conn, _tx, mut rx) = match remoc::Connect::framed::< - _, - _, - (), - AuthenticateFn, - remoc::codec::Default, - >(remoc::Cfg::default(), sink, stream) - .await - { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "failed to establish remoc channel"); - let _ = child.kill().await; - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - let conn_handle = tokio::spawn(conn.instrument(span.clone())); - - // Receive the AuthenticateFn from the child. - let auth_fn: AuthenticateFn = match rx.recv().await { - Ok(Some(f)) => f, - _ => { - tracing::error!("child did not send AuthenticateFn"); - let _ = child.kill().await; - return error_response(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // Call the child's PAM authentication. - let auth_request = AuthRequest { - username, - credential, - }; - - let start_session_fn = match auth_fn.call(auth_request).await { - Ok(f) => f, - Err(e) => { - tracing::warn!(error = %snafu::Report::from_error(&e), "authentication failed"); - let _ = child.kill().await; - return error_response(StatusCode::UNAUTHORIZED); - } - }; - - // Auth succeeded — spawn session task. The response is returned below, - // and h3x sends it on the wire. The spawned task waits for the upgrade - // to complete (streams become available after the response is sent). - tokio::spawn( - async move { - // Takeover the raw ReadStream/WriteStream for the SSH3 control channel. - let (read_stream, write_stream) = { - use h3x::hyper::upgrade::{self, ReadStream, WriteStream}; - - match ( - upgrade::take::(&mut request).await, - upgrade::take::(&mut request).await, - ) { - (Ok(r), Ok(w)) => (r, w), - (Err(e), _) | (_, Err(e)) => { - tracing::error!(error = %snafu::Report::from_error(&e), "takeover failed"); - let _ = child.kill().await; - return; - } - } - }; - - // Set up control stream via Unix socketpair + FD passing. - let (ctrl_srv, ctrl_cli) = match std::os::unix::net::UnixStream::pair() { - Ok(pair) => pair, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "control socketpair failed"); - let _ = child.kill().await; - return; - } - }; - let ctrl_fd_id = match fd_sender.queue_fds(smallvec::smallvec![ctrl_cli.into()]) { - Ok(id) => id, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "queue control FD failed"); - let _ = child.kill().await; - return; - } - }; - let ctrl_srv = match tokio::net::UnixStream::from_std(ctrl_srv) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %Report::from_error(&e), "control from_std failed"); - let _ = child.kill().await; - return; - } - }; - let (ctrl_read, ctrl_write) = ctrl_srv.into_split(); - - // Bridge QUIC CONNECT streams ↔ control stream socketpair. - tokio::spawn( - dssh::conversation::ipc::bridge_message_reader_to_unix( - Box::pin(read_stream.into_bytes_stream()), - ctrl_write, - ) - .in_current_span(), - ); - tokio::spawn( - dssh::conversation::ipc::bridge_unix_to_message_writer( - ctrl_read, - Box::pin(write_stream.into_bytes_sink()), - ) - .in_current_span(), - ); - - // Serve the stream management bridge via IPC FD passing. - let adapter = IpcManageStreamAdapter::new(handle, fd_sender); - let (ms, mc) = IpcManageSessionStreamServerShared::new(Arc::new(adapter), 1); - tokio::spawn( - async move { - let _ = ms.serve(true).await; - } - .in_current_span(), - ); - - let bootstrap = SessionBootstrap { - manage_stream: mc, - control_fd_id: ctrl_fd_id, - conversation_id, - peer_version, - }; - - tracing::info!(%conversation_id, "calling StartSessionFn in child"); - - match start_session_fn.call(bootstrap).await { - Ok(()) => tracing::info!(%conversation_id, "child session completed"), - Err(e) => tracing::error!( - error = %snafu::Report::from_error(&e), - "child session failed" - ), - } - - // Wait for the child process and remoc connection to finish. - let _ = child.wait().await; - let _ = conn_handle.await; - tracing::info!(%conversation_id, "session ended (child-process)"); - } - .instrument(span), - ); - - ok_response() -} diff --git a/src/client.rs b/src/client.rs index 6fed2d3..fcc74a1 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,14 +1,10 @@ //! SSH3 client utilities. //! -//! Provides constants and helpers for SSH3 client implementations. +//! Provides helpers for SSH3/DSSH client implementations. use base64::engine::{Engine, general_purpose::STANDARD}; use http::HeaderValue; -/// Well-known path for DSSH WebTransport Extended CONNECT requests. -#[deprecated(note = "use `constants::DSSH_CONNECT_PATH` instead")] -pub use crate::constants::DSSH_CONNECT_PATH as SSH3_CONNECT_PATH; - /// Encode Basic auth header value: `Basic base64(username:password)`. pub fn encode_basic_auth(username: &str, password: &str) -> HeaderValue { let encoded = STANDARD.encode(format!("{username}:{password}")); diff --git a/src/constants.rs b/src/constants.rs index 0800938..c2c8b74 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -10,6 +10,3 @@ pub const DEFAULT_MAX_MESSAGE_SIZE: VarInt = VarInt::from_u32(1 << 20); /// Well-known path for DSSH WebTransport Extended CONNECT requests. pub const DSSH_CONNECT_PATH: &str = "/.well-known/dssh/connect"; - -/// Legacy name for the DSSH WebTransport Extended CONNECT path. -pub const SSH3_CONNECT_PATH: &str = DSSH_CONNECT_PATH; diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index d19432b..824651f 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -8,9 +8,8 @@ //! # Architecture //! //! The gateway process wraps a [`ManageSessionStream`](super::ManageSessionStream) -//! implementation (for example -//! [`ConversationHandle`](crate::protocol::ConversationHandle) or a -//! WebTransport-backed stream manager) in an [`IpcManageStreamAdapter`] and +//! implementation (for example a WebTransport-backed stream manager) in an +//! [`IpcManageStreamAdapter`] and //! serves the generated [`IpcManageSessionStreamServerShared`]. Each //! `open_stream` / `accept_stream` call: //! 1. Opens a real bidirectional stream through the wrapped manager. diff --git a/src/lib.rs b/src/lib.rs index 78d9784..13c9eea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,20 @@ pub mod conversation; pub mod error; pub mod forward; pub mod message; -pub mod protocol; pub mod session; pub mod version; pub mod webtransport; + +#[cfg(test)] +mod tests { + #[test] + fn legacy_raw_ssh3_protocol_module_is_not_exported() { + let lib = include_str!("lib.rs"); + let legacy_protocol_export = concat!("pub mod ", "protocol;"); + + assert!( + !lib.contains(legacy_protocol_export), + "dssh transport must stay WebTransport-only" + ); + } +} diff --git a/src/protocol.rs b/src/protocol.rs deleted file mode 100644 index 9aafe2a..0000000 --- a/src/protocol.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! SSH3 protocol layer for h3x stream dispatch. -//! -//! Integrates SSH3 into the h3x layered protocol architecture. The protocol -//! layer identifies incoming SSH3 channel streams by peeking for the channel -//! signal value (`0xaf3627e6`), then routes them to the appropriate -//! conversation based on session ID. -//! -//! # Boundary -//! -//! The protocol layer consumes exactly two fields from each incoming channel -//! stream: -//! -//! 1. The channel signal value ([`VarInt`]) -//! 2. The session ID ([`StreamId`]) -//! -//! The conversation receives the stream positioned at the `max_message_size` -//! field, which is the start of the channel-specific data. - -use std::collections::HashMap; -use std::fmt; -use std::sync::Arc; - -use futures::future::BoxFuture; -use h3x::{ - codec::{ - BoxReadStream, BoxWriteStream, DecodeExt, EncodeExt, ErasedPeekableBiStream, - ErasedPeekableUniStream, SinkWriter, StreamReader, - }, - connection::StreamError, - protocol::{ProductProtocol, Protocol, Protocols, StreamVerdict}, - quic::{self, ConnectionError}, - stream_id::StreamId, - varint::VarInt, -}; -use snafu::prelude::*; -use tokio::io::AsyncWriteExt; -use tokio::sync::mpsc; - -use crate::constants::CHANNEL_SIGNAL_VALUE; -use crate::conversation::ManageSessionStream; - -// ============================================================================ -// Type aliases -// ============================================================================ - -/// Reader half of a routed bidirectional stream. -pub type Ssh3StreamReader = StreamReader; - -/// Writer half of a routed bidirectional stream. -pub type Ssh3StreamWriter = SinkWriter; - -/// A routed bidirectional stream after the protocol layer has consumed -/// the signal value and session ID. Raw QUIC streams — consumers wrap -/// in [`StreamReader`]/[`SinkWriter`] as needed. -type RoutedBiStream = (BoxReadStream, BoxWriteStream); - -type Registry = Arc>>>; - -/// Closure to open new QUIC bidirectional streams. -/// -/// Captured during [`ProductProtocol::init`] to erase the concrete connection -/// type. Returns raw boxed streams — callers wrap them in -/// [`StreamReader`]/[`SinkWriter`] as needed. -pub(crate) type OpenBiFn = Arc< - dyn Fn() -> BoxFuture<'static, Result<(BoxReadStream, BoxWriteStream), ConnectionError>> - + Send - + Sync, ->; - -// ============================================================================ -// Error types -// ============================================================================ - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RegisterError { - #[snafu(display("conversation already registered for session {session_id}"))] - AlreadyRegistered { session_id: StreamId }, - - #[snafu(display("conversation registry lock poisoned"))] - RegistryPoisoned, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum HandleError { - #[snafu(display("conversation stream channel closed"))] - ChannelClosed, - - #[snafu(display("failed to open bidirectional stream"))] - OpenBi { source: ConnectionError }, - - #[snafu(display("failed to encode channel signal value"))] - EncodeSignalValue { source: std::io::Error }, - - #[snafu(display("failed to encode session ID"))] - EncodeSessionId { source: std::io::Error }, - - #[snafu(display("failed to flush stream header"))] - Flush { source: std::io::Error }, -} - -// ============================================================================ -// Ssh3Protocol -// ============================================================================ - -/// SSH3 protocol layer for h3x. -/// -/// Routes incoming QUIC bidirectional streams to registered conversations -/// by peeking the channel signal value and session ID. -/// -/// Created once per QUIC connection by [`Ssh3ProtocolFactory`] and shared -/// (via `Arc`) across all concurrent streams. -pub struct Ssh3Protocol { - registry: Registry, - open_bi: OpenBiFn, -} - -impl fmt::Debug for Ssh3Protocol { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Ssh3Protocol") - .field( - "conversations", - &self.registry.lock().map(|r| r.len()).unwrap_or(0), - ) - .finish() - } -} - -impl Ssh3Protocol { - /// Create an `Ssh3Protocol` from a stream-opening closure. - /// - /// The closure should open new QUIC bidirectional streams and return - /// them as boxed trait objects. This is the most flexible constructor — - /// use it when you have a connection handle that doesn't directly - /// implement the QUIC traits (e.g., `h3x::connection::Connection`). - pub fn new( - open_bi: impl Fn() - -> BoxFuture<'static, Result<(BoxReadStream, BoxWriteStream), ConnectionError>> - + Send - + Sync - + 'static, - ) -> Self { - Self { - registry: Arc::new(std::sync::Mutex::new(HashMap::new())), - open_bi: Arc::new(open_bi), - } - } - - /// Register a new conversation for the given session ID. - /// - /// Returns a [`ConversationHandle`] that receives routed streams and can - /// open new streams. The handle unregisters the conversation when dropped. - pub fn register(&self, session_id: StreamId) -> Result { - use register_error::*; - - let (sender, receiver) = mpsc::channel(16); - - let mut registry = self - .registry - .lock() - .map_err(|_| RegisterError::RegistryPoisoned)?; - - ensure!( - !registry.contains_key(&session_id.into_inner()), - AlreadyRegisteredSnafu { session_id } - ); - - registry.insert(session_id.into_inner(), sender); - - Ok(ConversationHandle { - session_id, - receiver: tokio::sync::Mutex::new(receiver), - open_bi: Arc::clone(&self.open_bi), - registry: Arc::clone(&self.registry), - }) - } - - async fn accept_bi_inner( - &self, - (mut reader, writer): ErasedPeekableBiStream, - ) -> Result, StreamError> { - tracing::trace!("ssh3 protocol accept_bi called"); - - // Peek the first VarInt to identify SSH3 streams. - let signal_value: VarInt = match reader.decode_one().await { - Ok(v) => v, - Err(_) => return Ok(StreamVerdict::Passed((reader, writer))), - }; - - tracing::trace!(signal_value = %signal_value.into_inner(), "decoded first VarInt"); - - if signal_value != CHANNEL_SIGNAL_VALUE { - return Ok(StreamVerdict::Passed((reader, writer))); - } - - // SSH3 stream confirmed. Decode session ID to determine routing. - let session_id: StreamId = match reader.decode_one().await { - Ok(id) => id, - Err(e) => { - tracing::warn!(error = %snafu::Report::from_error(&e), "failed to decode session ID from SSH3 stream"); - return Ok(StreamVerdict::Accepted); - } - }; - - tracing::debug!(%session_id, "ssh3 channel stream accepted, routing to conversation"); - - // Convert to StreamReader (preserving buffered bytes from the peek - // operation), then re-box as a BoxReadStream so downstream consumers - // receive all remaining data (max_message_size, channel_type, etc.). - let reader: BoxReadStream = Box::pin(reader.into_stream_reader()); - - // Lookup and route. - let sender = { - let Ok(registry) = self.registry.lock() else { - tracing::warn!("SSH3 conversation registry lock poisoned"); - return Ok(StreamVerdict::Accepted); - }; - registry.get(&session_id.into_inner()).cloned() - }; - - match sender { - Some(sender) => { - let raw_writer = writer.into_inner(); - if sender.send((reader, raw_writer)).await.is_err() { - tracing::debug!( - %session_id, - "conversation channel closed, dropping SSH3 stream" - ); - } - } - None => { - tracing::debug!( - %session_id, - "no registered conversation for SSH3 stream" - ); - } - } - - Ok(StreamVerdict::Accepted) - } -} - -impl Protocol for Ssh3Protocol { - fn accept_uni<'a>( - &'a self, - stream: ErasedPeekableUniStream, - ) -> BoxFuture<'a, Result, StreamError>> { - Box::pin(async move { Ok(StreamVerdict::Passed(stream)) }) - } - - fn accept_bi<'a>( - &'a self, - stream: ErasedPeekableBiStream, - ) -> BoxFuture<'a, Result, StreamError>> { - Box::pin(self.accept_bi_inner(stream)) - } -} - -// ============================================================================ -// Ssh3ProtocolFactory -// ============================================================================ - -/// Factory for creating [`Ssh3Protocol`] instances during connection setup. -/// -/// Register this as a protocol layer to enable SSH3 stream routing: -/// -/// ```ignore -/// server.add_protocol(Ssh3ProtocolFactory); -/// ``` -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct Ssh3ProtocolFactory; - -impl fmt::Display for Ssh3ProtocolFactory { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SSH3") - } -} - -impl ProductProtocol for Ssh3ProtocolFactory { - type Protocol = Ssh3Protocol; - - fn init<'a>( - &'a self, - conn: &'a Arc, - _layers: &'a Protocols, - ) -> BoxFuture<'a, Result> { - let conn = conn.clone(); - Box::pin(async move { - let open_bi: OpenBiFn = Arc::new(move || { - let conn = conn.clone(); - Box::pin(async move { - let (reader, writer) = quic::DynManageStream::open_bi(&*conn).await?; - Ok((reader, writer)) - }) - }); - - Ok(Ssh3Protocol { - registry: Arc::new(std::sync::Mutex::new(HashMap::new())), - open_bi, - }) - }) - } -} - -// ============================================================================ -// ConversationHandle -// ============================================================================ - -/// Handle for a registered conversation. -/// -/// Provides stream management for a conversation: accepting streams routed -/// by the protocol layer and opening new streams to the remote peer. -/// -/// Implements [`ManageSessionStream`] so it can be used directly with -/// [`Conversation`](crate::conversation::Conversation). -/// -/// Dropping the handle automatically unregisters the conversation from the -/// protocol registry. -pub struct ConversationHandle { - session_id: StreamId, - receiver: tokio::sync::Mutex>, - open_bi: OpenBiFn, - registry: Registry, -} - -impl ConversationHandle { - pub fn session_id(&self) -> StreamId { - self.session_id - } - - /// Open a raw bidirectional stream with the routing header already written. - /// - /// Returns raw boxed streams without codec wrappers. The routing header - /// (signal value + session ID) is written so the remote protocol layer - /// routes this stream to the correct conversation. - pub async fn open_raw_stream(&self) -> Result<(BoxReadStream, BoxWriteStream), HandleError> { - use handle_error::*; - - let (reader, writer) = (self.open_bi)().await.context(OpenBiSnafu)?; - let mut codec_writer = SinkWriter::new(writer); - - tracing::trace!(session_id = %self.session_id, "writing channel routing header"); - - codec_writer - .encode_one(CHANNEL_SIGNAL_VALUE) - .await - .context(EncodeSignalValueSnafu)?; - codec_writer - .encode_one(self.session_id) - .await - .context(EncodeSessionIdSnafu)?; - AsyncWriteExt::flush(&mut codec_writer) - .await - .context(FlushSnafu)?; - let writer = codec_writer.into_inner(); - - tracing::trace!(session_id = %self.session_id, "channel routing header written and flushed"); - - Ok((reader, writer)) - } - - /// Accept a raw bidirectional stream routed by the protocol layer. - /// - /// Returns raw boxed streams without codec wrappers. The protocol layer - /// has already consumed the signal value and session ID, so the streams - /// are positioned at the channel-specific data. - pub async fn accept_raw_stream(&self) -> Result<(BoxReadStream, BoxWriteStream), HandleError> { - let mut rx = self.receiver.lock().await; - rx.recv().await.ok_or(HandleError::ChannelClosed) - } -} - -impl fmt::Debug for ConversationHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ConversationHandle") - .field("session_id", &self.session_id) - .finish() - } -} - -impl Drop for ConversationHandle { - fn drop(&mut self) { - if let Ok(mut registry) = self.registry.lock() { - registry.remove(&self.session_id.into_inner()); - } - } -} - -impl ManageSessionStream for ConversationHandle { - type StreamReader = Ssh3StreamReader; - type StreamWriter = Ssh3StreamWriter; - type Error = HandleError; - - async fn open_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let (reader, writer) = self.open_raw_stream().await?; - Ok((StreamReader::new(reader), SinkWriter::new(writer))) - } - - async fn accept_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let (reader, writer) = self.accept_raw_stream().await?; - Ok((StreamReader::new(reader), SinkWriter::new(writer))) - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - // ConversationHandle is Send + Sync (required for ManageSessionStream usage). - const _: () = { - #[allow(dead_code)] - fn assert_send_sync() {} - #[allow(dead_code)] - fn check() { - assert_send_sync::(); - assert_send_sync::(); - } - }; -} From e1eafd2175aa675b381b277dd19e7cf1ffb3d60e Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 28 May 2026 12:43:11 +0800 Subject: [PATCH 16/39] chore: refresh https git dependencies --- Cargo.lock | 209 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 118 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac932c2..4535aa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "android-build" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cac4c64175d504608cf239756339c07f6384a476f97f20a7043f92920b0b8fd" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" dependencies = [ "windows-sys 0.52.0", ] @@ -96,9 +96,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -164,9 +164,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -227,6 +227,15 @@ dependencies = [ "syn", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -239,9 +248,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -260,9 +269,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -475,9 +484,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -489,9 +498,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der-parser" @@ -574,7 +583,7 @@ dependencies = [ [[package]] name = "dhttp-identity" version = "0.1.0" -source = "git+ssh://git@github.com/genmeta/dhttp.git?branch=main#b48f4c4a5ebf9b9a13e9e852bc9e1e796938aec5" +source = "git+https://github.com/genmeta/dhttp.git?branch=main#cfaa8a898b473f633b61342a010e6edc8e735c0c" dependencies = [ "bytes", "futures", @@ -600,9 +609,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -623,7 +632,7 @@ dependencies = [ [[package]] name = "dquic" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "arc-swap", "bytes", @@ -652,7 +661,7 @@ dependencies = [ "http-body", "http-body-util", "libc", - "nix 0.31.2", + "nix 0.31.3", "pam-client2", "peg", "remoc", @@ -678,9 +687,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "enum_dispatch" @@ -907,9 +916,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -927,7 +936,7 @@ dependencies = [ [[package]] name = "h3x" version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#dbc8d2dd9e19043f23b7dc589a8f8689144eddb9" +source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#eacb77c2506df2f2f643c65e0b2541f854f40bb3" dependencies = [ "arc-swap", "async-channel", @@ -985,9 +994,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1009,9 +1018,9 @@ checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1042,9 +1051,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "bytes", "futures-channel", @@ -1109,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1178,10 +1187,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1200,9 +1211,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" @@ -1221,9 +1232,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "mac-addr" @@ -1239,9 +1250,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -1359,9 +1370,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags", "cfg-if", @@ -1410,9 +1421,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1601,9 +1612,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "peg" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9928cfca101b36ec5163e70049ee5368a8a1c3c6efc9ca9c5f9cc2f816152477" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" dependencies = [ "peg-macros", "peg-runtime", @@ -1611,9 +1622,9 @@ dependencies = [ [[package]] name = "peg-macros" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6298ab04c202fa5b5d52ba03269fb7b74550b150323038878fe6c372d8280f71" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" dependencies = [ "peg-runtime", "proc-macro2", @@ -1622,9 +1633,9 @@ dependencies = [ [[package]] name = "peg-runtime" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" [[package]] name = "pin-project-lite" @@ -1634,9 +1645,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64", "indexmap 2.14.0", @@ -1704,7 +1715,7 @@ dependencies = [ [[package]] name = "qbase" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bitflags", "bytes", @@ -1728,7 +1739,7 @@ dependencies = [ [[package]] name = "qcongestion" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "qbase", "qevent", @@ -1741,7 +1752,7 @@ dependencies = [ [[package]] name = "qconnection" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "dashmap", @@ -1769,7 +1780,7 @@ dependencies = [ [[package]] name = "qdatagram" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "futures", @@ -1781,7 +1792,7 @@ dependencies = [ [[package]] name = "qevent" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "derive_builder", @@ -1799,7 +1810,7 @@ dependencies = [ [[package]] name = "qinterface" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "dashmap", @@ -1824,7 +1835,7 @@ dependencies = [ [[package]] name = "qmacro" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -1835,7 +1846,7 @@ dependencies = [ [[package]] name = "qrecovery" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "derive_more", @@ -1853,7 +1864,7 @@ dependencies = [ [[package]] name = "qresolve" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "futures", "qbase", @@ -1864,7 +1875,7 @@ dependencies = [ [[package]] name = "qtraversal" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "async-trait", "bitflags", @@ -1894,12 +1905,12 @@ dependencies = [ [[package]] name = "qudp" version = "0.5.0" -source = "git+ssh://git@github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" +source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" dependencies = [ "bytes", "cfg-if", "libc", - "nix 0.31.2", + "nix 0.31.3", "qbase", "socket2", "tokio", @@ -1909,9 +1920,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -2106,9 +2117,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -2121,18 +2132,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2222,9 +2233,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2235,11 +2246,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -2254,9 +2266,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -2480,11 +2492,26 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2674,9 +2701,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2687,9 +2714,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2697,9 +2724,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2710,9 +2737,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -3155,18 +3182,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", From 29fdcbb83b5c833c7dc79581de44fb420ebdf68b Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 2 Jun 2026 14:18:03 +0800 Subject: [PATCH 17/39] chore: align dependency policy --- .gitignore | 3 +- Cargo.lock | 3213 ---------------------------------------------------- Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3215 deletions(-) delete mode 100644 Cargo.lock diff --git a/.gitignore b/.gitignore index c450dcf..a218be7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +Cargo.lock .sisyphus/ .worklog.md -plan.md \ No newline at end of file +plan.md diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 4535aa9..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3213 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android-build" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "bon" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "dhttp-identity" -version = "0.1.0" -source = "git+https://github.com/genmeta/dhttp.git?branch=main#cfaa8a898b473f633b61342a010e6edc8e735c0c" -dependencies = [ - "bytes", - "futures", - "http", - "ring", - "rustls", - "serde", - "snafu 0.9.0", - "x509-parser", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dquic" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "arc-swap", - "bytes", - "dashmap", - "derive_more", - "futures", - "qconnection", - "qresolve", - "rustls", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "dssh" -version = "0.2.0" -dependencies = [ - "base64", - "bytes", - "clap", - "futures", - "h3x", - "http", - "http-body", - "http-body-util", - "libc", - "nix 0.31.3", - "pam-client2", - "peg", - "remoc", - "ring", - "rustls", - "serde", - "serde_json", - "smallvec", - "snafu 0.9.0", - "tempfile", - "tokio", - "tokio-util", - "tower-service", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - -[[package]] -name = "getset" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h3x" -version = "0.2.0" -source = "git+https://github.com/genmeta/h3x.git?branch=endpoint#eacb77c2506df2f2f643c65e0b2541f854f40bb3" -dependencies = [ - "arc-swap", - "async-channel", - "bon", - "bytes", - "dashmap", - "derive_more", - "dhttp-identity", - "dquic", - "either", - "futures", - "globset", - "httlib-huffman", - "http", - "http-body", - "http-body-util", - "hyper", - "matchit", - "nix 0.29.0", - "peg", - "pin-project-lite", - "remoc", - "ring", - "rustls", - "serde", - "smallvec", - "snafu 0.9.0", - "tokio", - "tokio-util", - "tower-service", - "tracing", - "x509-parser", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "httlib-huffman" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" - -[[package]] -name = "http" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "hyper" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "tokio", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" - -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - -[[package]] -name = "matchit" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "netdev" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "libc", - "mac-addr", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - -[[package]] -name = "netlink-packet-core" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" -dependencies = [ - "paste", -] - -[[package]] -name = "netlink-packet-route" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" -dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - -[[package]] -name = "netlink-sys" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" -dependencies = [ - "bytes", - "libc", - "log", -] - -[[package]] -name = "netwatcher" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39d9eee015f242646a9bad2d6ea668651a6c053d0065bec923107b0a43e89a69" -dependencies = [ - "android-build", - "jni", - "ndk-context", - "nix 0.29.0", - "windows", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "bitflags", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-security", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "pam-client2" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "407daa00f98b05147dbbaa6d2f6bce574e76794fa4952a5b38764681a23dde5f" -dependencies = [ - "bitflags", - "libc", - "pam-sys2", - "rustversion", -] - -[[package]] -name = "pam-sys2" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e07ea89c210813e1a48fc32cb358ba693aae8ca70163461e22d11f1884bf1d" -dependencies = [ - "libc", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "peg" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" -dependencies = [ - "peg-macros", - "peg-runtime", -] - -[[package]] -name = "peg-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" -dependencies = [ - "peg-runtime", - "proc-macro2", - "quote", -] - -[[package]] -name = "peg-runtime" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "plist" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" -dependencies = [ - "base64", - "indexmap 2.14.0", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "qbase" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bitflags", - "bytes", - "derive_more", - "enum_dispatch", - "futures", - "getset", - "http", - "netdev", - "nom 8.0.0", - "qmacro", - "rand 0.10.1", - "rustls", - "serde", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "qcongestion" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "qbase", - "qevent", - "rand 0.10.1", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "qconnection" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "dashmap", - "derive_more", - "enum_dispatch", - "futures", - "qbase", - "qcongestion", - "qdatagram", - "qevent", - "qinterface", - "qrecovery", - "qresolve", - "qtraversal", - "rand 0.10.1", - "ring", - "rustls", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "x509-parser", -] - -[[package]] -name = "qdatagram" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "futures", - "qbase", - "tokio", - "tracing", -] - -[[package]] -name = "qevent" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "derive_builder", - "derive_more", - "enum_dispatch", - "pin-project-lite", - "qbase", - "serde", - "serde_json", - "serde_with", - "tokio", - "tracing", -] - -[[package]] -name = "qinterface" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "dashmap", - "derive_more", - "futures", - "http", - "netdev", - "netwatcher", - "parking_lot", - "pin-project-lite", - "qbase", - "qevent", - "qudp", - "rustls", - "serde", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "qmacro" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "qrecovery" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "derive_more", - "enum_dispatch", - "futures", - "qbase", - "qevent", - "rand 0.10.1", - "rustls", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "qresolve" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "futures", - "qbase", - "qinterface", - "tokio", -] - -[[package]] -name = "qtraversal" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "async-trait", - "bitflags", - "bon", - "bytes", - "dashmap", - "derive_more", - "enum_dispatch", - "futures", - "netdev", - "nom 8.0.0", - "qbase", - "qevent", - "qinterface", - "qresolve", - "qudp", - "rand 0.10.1", - "rustls", - "smallvec", - "snafu 0.8.9", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "qudp" -version = "0.5.0" -source = "git+https://github.com/genmeta/dquic.git?branch=feat%2Fv0.5.1#3d9824f9a3593f421d10654828ce6c914793c1a8" -dependencies = [ - "bytes", - "cfg-if", - "libc", - "nix 0.31.3", - "qbase", - "socket2", - "tokio", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "remoc" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0491961ac4bc1ac4191743aa58a2ce778f4725693d29743fae957b2cf45f77f0" -dependencies = [ - "bincode", - "byteorder", - "bytes", - "futures", - "rand 0.9.4", - "remoc_macro", - "serde", - "tokio", - "tokio-util", - "tracing", - "uuid", -] - -[[package]] -name = "remoc_macro" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89479d9d87f65ef573faf0167dd0a9f40d3a63fd95e7a2935d662fa57dbc30d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_with" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive 0.8.9", -] - -[[package]] -name = "snafu" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" -dependencies = [ - "snafu-derive 0.9.0", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "snafu-derive" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" -dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "zerocopy" -version = "0.8.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 70dc6c4..f3532ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ cli = ["dep:peg"] config = ["dep:peg"] [dev-dependencies] -clap = { version = "4.6.0", features = ["derive"] } +clap = { version = "4", features = ["derive"] } ring = "0.17" rustls = { version = "0.23", default-features = false, features = [ "ring", From c1ebb0ac982bbcc982261bb9447311001ad68397 Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 2 Jun 2026 15:40:29 +0800 Subject: [PATCH 18/39] refactor: remove legacy ssh3 examples and docker tests --- .sisyphus/plans/ssh3-rfc-implementation-v2.md | 2846 ----------------- Cargo.toml | 15 - examples/ssh3-session.rs | 182 -- tests/docker/Dockerfile | 37 - tests/docker/run_tests.sh | 565 ---- tests/docker_integration.rs | 220 -- 6 files changed, 3865 deletions(-) delete mode 100644 .sisyphus/plans/ssh3-rfc-implementation-v2.md delete mode 100644 examples/ssh3-session.rs delete mode 100644 tests/docker/Dockerfile delete mode 100644 tests/docker/run_tests.sh delete mode 100644 tests/docker_integration.rs diff --git a/.sisyphus/plans/ssh3-rfc-implementation-v2.md b/.sisyphus/plans/ssh3-rfc-implementation-v2.md deleted file mode 100644 index 2abb552..0000000 --- a/.sisyphus/plans/ssh3-rfc-implementation-v2.md +++ /dev/null @@ -1,2846 +0,0 @@ -# SSH3 RFC 合规 Greenfield 重写 (V2 — 审计修正版) - -## TL;DR - -> **Quick Summary**: 在独立 worktree(ssh3-rfc 分支)中,从零重写 SSH3 实现以完全符合 draft-michel-ssh3-00 RFC。采用 **SSH 二进制格式 + QUIC varint** 编码(非 CBOR),每通道独立 QUIC 双向流(非 channel number 复用),h3x 编码风格,remoc RTC 跨进程通信,两进程架构。 -> -> **V2 修正要点**: 修复 V1 中 10 个 RFC 合规错误 — CBOR→SSH binary+varint, ChannelId→stream identity, ChannelOpen(90)/WindowAdjust(93)→删除, ChannelRequest 95→98, 转发通道使用原始字节流。 -> -> **Deliverables**: -> - `genmeta-ssh3-proto`: SSH3 wire format codec (QUIC varint) + Conversation trait + SshSession RTC trait + 错误模型 -> - `genmeta-ssh3-server`: Extended CONNECT handler + Ssh3Protocol + PAM 认证 + 子进程管理 -> - `genmeta-ssh3-server/src/bin/ssh3-session`: 子进程二进制(SSH3 会话处理,setuid/setgid 后执行) -> - `genmeta-ssh3-client`: SSH3 客户端连接 + 会话 + 转发 -> - 完整 TDD 测试套件 + E2E 冒烟测试 + wire format hex dump 对比验证 -> -> **Estimated Effort**: XL -> **Parallel Execution**: YES — 6 waves -> **Critical Path**: Task 1 → Task 2 → Task 5 → Task 6 → Task 8 → Task 14 → Task 15 → Task 21 → Task 24 → Final - ---- - -## Context - -### Original Request -在独立 worktree 中对 SSH3 实现进行 RFC 合规的 greenfield 重写。代码风格参考 h3x(Encode/Decode trait、snafu 错误、newtype、pub(crate))。Server 端按 axum handler 风格设计。多进程架构使用 remoc RTC。 - -### V1 审计结果 (导致本次重写) -V1 计划执行时发现 10 个 RFC 合规错误(8 critical + 2 moderate),核心问题: -1. 🔴 使用 CBOR 编码(53处)— RFC 要求 SSH binary + QUIC varint -2. 🔴 消息类型编码为 u8 — 应为 QUIC varint -3. 🔴 SSHv2 风格 ChannelOpen(90)/Confirm(91)/Failure(92) 消息交换 — SSH3 通过打开新 QUIC 流 + channel header 创建通道 -4. 🔴 ChannelId(u32) — SSH3 无 channel number -5. 🔴 ChannelWindowAdjust(93) — QUIC 原生流控 -6. 🔴 转发通道使用 SSH_MSG_CHANNEL_DATA 包装 — 应为原始字节流 -7. 🔴 ChannelRequest=95 — 正确值为 98 -8. 🔴 Conversation trait API 使用虚构参数 (ChannelId, initial_window) -9. 🟡 GlobalRequest 机制不明确 -10. 🟡 h3x stream_id() API 未验证 - -**V1 代码已被用户手动删除。仓库为空白状态(仅 .git/.gitignore/.sisyphus/target/)。V2 Task 1 从零创建 workspace + crate 骨架。** - -### Interview Summary -**Key Discussions**: -- **重写策略**: Greenfield — 旧实现仅作参考,不做迁移骨架 -- **crate 边界**: 保留现有 crate 名称(proto/client/server/ssh-config),内部全新 -- **认证**: MVP 只支持 Basic(password),PAM 4 阶段,主进程执行(root 权限),认证通过后 spawn 子进程 -- **IPC**: remoc RTC(`#[rtc::remote] trait SshSession`)替代手动消息 enum -- **Protocol 路由**: 全在主进程(Ssh3Protocol.accept_bi → LocalConversation → remoc → RemoteConversation) -- **版本协商**: ssh-version HTTP header,RFC Section 6 -- **转发**: TCP + Unix socket + SOCKS5(服务端) -- **排除项**: x11/UDP/agent forwarding、JWT/Bearer/Concealed auth、heartbeat、gateway/gmutils 集成 -- **编码格式**: SSH 二进制格式 + QUIC varint(复用 h3x::varint::VarInt),**绝不使用 CBOR** - -**Research Findings**: -- h3x Protocol trait 流程:ConnectionBuilder::protocol() → ConnectionState.protocols → accept_bi_stream_task 循环 → Protocol 链 -- DHttpProtocol 在 Ssh3Protocol 前注册,优先处理 HTTP/3 frame type -- remoc RTC 宏生成 Client/Server,支持 `provide()/consume()` 一行建连 -- conversation_id = CONNECT 的 QUIC stream ID(u64),RFC Section 3 明确 -- signal_value = 0xaf3627e6(RFC Section 3.1),编码为 8 字节 QUIC varint(0xC000000000AF3627E6 不对,TODO 验证编码) -- Go 参考实现(francoismichel/ssh3)确认:零 CBOR、QUIC varint 编码、message type 为 varint、channel header = signal_value + conversation_id + channel_type + max_packet_size - -### Metis Review (V2 前置审查) -**Identified Gaps** (addressed): -- V1 代码已被用户删除,仓库为空白状态(无 Cargo.toml、无 crate 目录、无源文件)→ V2 Task 1 从零创建 workspace + crate 骨架 -- h3x::codec Encode/Decode 对自定义类型的支持需要 spike 验证 -- remoc RTC 跨进程 QUIC 流传递需要 spike 验证 -- pubkey auth(HTTP Signature RFC 9421)超出 MVP 范围 -- Extended Data (type 95) 用于 stderr — 纳入实现范围 -- ChannelSuccess(99) / ChannelFailure(100) — 作为 ChannelRequest want_reply 的回复,纳入实现范围 -- GlobalRequest 机制:tcpip-forward 等通过 conversation 流上的 SSH_MSG_GLOBAL_REQUEST(80) / SSH_MSG_REQUEST_SUCCESS(81) / SSH_MSG_REQUEST_FAILURE(82) 消息实现,发送在 conversation stream(Extended CONNECT 流)上,而非 channel stream 上 -- wire format hex dump 测试必须对照 Go 参考实现的字节序列 - ---- - -## 设计宗旨(Design Principles — 所有任务必须遵循) - -> **本节是整个计划的根本原则。任何任务中的实现细节如果与本节矛盾,以本节为准。** - -### 参考优先级(Reference Priority) - -实现代码时,必须按以下优先级查阅参考资料并遵循其模式: - -1. **h3x/codec(最高优先)** — h3x 是一个完备的网络传输协议库解析样例。所有编解码相关代码(类型定义、trait 实现、错误处理、流式读写)必须严格参考 h3x 的模式,包括但不限于: - - `Encode` / `Decode` trait 定义在 stream/writer 类型上(h3x/src/codec.rs:31-70) - - `EncodeExt::encode_one()` / `DecodeExt::decode_one::()` 调用模式 - - `VarInt` newtype 复用(h3x/src/varint.rs) - - `StreamReader` / `PeekableStreamReader` / `SinkWriter` 流类型(h3x/src/codec/reader.rs, writer.rs) - - `Protocol` trait + `StreamVerdict::Accepted | Passed` 模式(h3x/src/protocol.rs) - - snafu 错误模型(h3x/src/codec/error.rs) - - `Frame

` 结构体模式(h3x/src/dhttp/frame.rs) - - `pub(crate)` 可见性、newtype 包装、builder 模式 - -2. **RFC draft-michel-ssh3-00(第二优先)** — 线上格式(wire format)、消息类型常量、通道生命周期、认证流程等必须与 RFC 保持严格一致。当 h3x 模式不涉及 SSH3 特有语义时,以 RFC 为准。 - -3. **Go 参考实现 francoismichel/ssh3(最低优先)** — 仅在 h3x 和 RFC 都未明确的边界情况下参考。Go 实现可能存在与 RFC 不一致之处,不可盲目照搬。 - -### 编解码根本原则 - -1. **Trait-based, not free functions** — 所有编解码通过 `impl Encode for S where S: AsyncWrite` 和 `impl Decode for S where S: AsyncRead` 实现。调用方式为 `stream.encode_one(value).await?` / `let v: MyType = stream.decode_one().await?`。**严禁**使用 `encode_xxx()` / `decode_xxx()` free functions。 - -2. **Stream-centric** — 编解码操作直接在 AsyncRead/AsyncWrite stream 上进行,而非在内存 buffer(`Buf`/`BufMut`/`Vec`)上操作后再写入流。这确保了背压传播和零拷贝的可能性。 - -3. **复用 h3x 基础设施** — `VarInt`、`StreamReader`、`PeekableStreamReader`、`SinkWriter`、`EncodeExt`、`DecodeExt` 直接从 h3x crate 导入,不重新实现。 - -4. **SSH 二进制格式** — 所有线上数据使用 SSH 二进制格式 + QUIC varint 编码。**绝不使用 CBOR、JSON、MessagePack 或任何其他序列化格式。** - -5. **错误模型** — 编解码错误使用 snafu 派生,遵循 h3x `EncodeError` / `DecodeError` 模式,提供上下文丰富的错误链。 - -### 架构根本原则 - -1. **每通道一条 QUIC 双向流** — 无 channel number 复用,无 `ChannelId` 类型,无 `ChannelOpen(90)` 消息。打开 QUIC 流 + 写 channel header = 打开通道。 - -2. **QUIC 原生流控** — 无 `ChannelWindowAdjust(93)`,无 `initial_window` 参数。 - -3. **两进程架构** — 主进程(root)处理认证和 Protocol 路由;子进程(用户权限)处理会话逻辑。通过 remoc RTC 通信。 - -4. **TCP 转发通道使用原始字节流** — 不使用 `SSH_MSG_CHANNEL_DATA` 包装。仅 session 通道使用消息包装。 - ---- - -## Work Objectives - -### Core Objective -从零实现 RFC draft-michel-ssh3-00 合规的 SSH3 协议栈,使用 **SSH 二进制格式 + QUIC varint 编码**,每通道独立 QUIC 双向流,包含完整的 codec/server/client,采用两进程架构(root 主进程 + 用户权限子进程),所有实现在独立 worktree 中完成。 - -### Concrete Deliverables -- `genmeta-ssh3-proto/src/`: wire format codec(QUIC varint)、SshMessage enum、Conversation trait、SshSession RTC trait、错误模型 -- `genmeta-ssh3-server/src/`: Extended CONNECT handler、Ssh3Protocol、ChildProcess 管理、PAM wrapper -- `genmeta-ssh3-server/src/bin/ssh3-session.rs`: 子进程入口 -- `genmeta-ssh3-client/src/`: 连接建立、会话管理、转发客户端 - -### Definition of Done -- [ ] `cargo build --workspace` 在 ssh3-rfc worktree 中无错误 -- [ ] `cargo test --workspace` 全部通过 -- [ ] `cargo clippy --workspace -- -D warnings` 无警告 -- [ ] E2E 测试:客户端连接 → Basic 认证 → exec "echo hello" → 收到 "hello\n" -- [ ] TCP 转发测试:direct-tcp + reverse-tcp 端到端验证 -- [ ] 多进程测试:主进程 spawn 子进程 → RTC authenticate → run_session -- [ ] wire format 字节序列与 Go 参考实现一致(hex dump 对比通过) - -### Must Have -- SSH3 wire format 严格符合 RFC — **SSH 二进制格式 + QUIC varint 编码**(非 CBOR) -- 消息类型值:CHANNEL_OPEN_CONFIRMATION=91, CHANNEL_OPEN_FAILURE=92, CHANNEL_DATA=94, CHANNEL_EXTENDED_DATA=95, CHANNEL_EOF=96, CHANNEL_CLOSE=97, CHANNEL_REQUEST=98, CHANNEL_SUCCESS=99, CHANNEL_FAILURE=100 -- Channel header 格式:signal_value(0xaf3627e6) + conversation_id(varint) + channel_type_length(varint) + channel_type(utf8) + max_message_size(varint) -- 通道通过 QUIC 双向流标识 — 无 channel number -- Session 通道使用 SSH_MSG_CHANNEL_DATA(94) 包装数据 -- TCP 转发通道使用原始字节流(不使用消息包装) -- PAM 4 阶段完整调用 + timing attack 防护 -- Basic 认证按 scheme 分派,不支持的返回 401 + WWW-Authenticate -- Conversation trait 抽象(LocalConversation + RemoteConversation) -- 版本协商 ssh-version header -- h3x 编码风格(Encode/Decode trait、snafu 错误、newtype、pub(crate)) - -### Must NOT Have (Guardrails) -- **不使用** CBOR/ciborium/serde_cbor — 所有线上格式使用 SSH binary format + QUIC varint -- **不定义** ChannelId 类型 — 通道通过 QUIC 流标识,无 channel number -- **不使用** 消息类型 ChannelOpen(90) — 打开 QUIC 流 + 写 channel header = 打开通道 -- **不使用** ChannelWindowAdjust(93) — QUIC 原生流控 -- **不实现** 基于 channel number 的复用 — 每通道一条独立 QUIC 双向流 -- **不实现** x11 forwarding、UDP forwarding、agent-connection channel -- **不实现** JWT/Bearer、Concealed Auth、OIDC 认证、HTTP Signature (RFC 9421) pubkey auth -- **不实现** heartbeat message -- **不集成** gateway 或 gmutils(推迟到单独计划) -- **不重新实现** VarInt — 复用 h3x::varint::VarInt -- **不发明** 不存在的 h3x API — 先验证再使用 -- **不设置** tracing event 的 target -- **不使用** h3x::message::unify — HTTP API 用 http crate 类型 -- **不预留** AuthCredential 未来变体定义 -- **不做** PAM service name 自动降级 fallback -- **不在** 子进程中注册 Protocol 或路由 stream -- **不使用** initial_window 参数 — QUIC 原生流控 - -### SSH3 Wire Format 速查表(所有任务必须遵循) - -| 元素 | 编码格式 | 示例 | -|------|---------|------| -| 整数字段 (byte/uint32/uint64) | QUIC varint (RFC 9000 §16) | `94` → `0x5e`(1字节varint) | -| 字符串字段 | varint长度前缀 + UTF-8 字节 | `"session"` → `07 73 65 73 73 69 6f 6e` | -| 布尔字段 | 单字节 (0x00/0x01) | `true` → `0x01` | -| 消息类型标签 | QUIC varint | `SSH_MSG_CHANNEL_DATA=94` → `0x5e` | -| Channel header | signal_value(varint) + conversation_id(varint) + channel_type(ssh_string) + max_message_size(varint) | — | -| Channel number | **不存在** — 通道 = QUIC 流 | — | -| Codec 模式 | h3x Encode/Decode trait impl on stream types | `stream.encode_one(SshString("session".into())).await?` | - -**编解码模式(所有任务必须遵循)**: 不使用 free functions(如 encode_varint/decode_message),而是通过 h3x Encode/Decode trait 在 AsyncRead/AsyncWrite stream 上实现。调用方式:`stream.encode_one(value).await?` / `stream.decode_one::().await?`。参考 h3x/src/varint.rs:189-222 和 h3x/src/codec.rs:31-70。 ---- - -## Verification Strategy (MANDATORY) - -> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. - -### Test Decision -- **Infrastructure exists**: NO(仓库为空白状态,Task 1 从零创建 workspace + crate 骨架后才可运行 cargo test) -- **Automated tests**: TDD (RED → GREEN → REFACTOR) -- **Framework**: cargo test (Rust built-in) -- **Each task**: 先写失败测试 → 实现通过 → 重构 - -### QA Policy -Every task MUST include agent-executed QA scenarios. -Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. - -- **Wire format**: cargo test + hex dump 对比 Go 参考实现字节序列 -- **Protocol**: cargo test --test integration -- **Server/Client**: tmux 启动服务 → 客户端连接 → 验证输出 -- **IPC**: cargo test 子进程 spawn + RTC 调用验证 - -### Wire Format 验证标准 -每个 Encode/Decode 实现必须有以下测试: -1. **Roundtrip test**: encode(value) → decode → 与原值相等 -2. **Hex dump test**: encode(known_value) → 与预期字节序列完全一致 -3. **Cross-reference test**: 字节序列与 Go 参考实现(francoismichel/ssh3)的输出一致 - ---- - -## Execution Strategy - -### Parallel Execution Waves - -``` -Wave 1 (Start Immediately — worktree reset + codec foundation): -├── Task 1: Worktree 重置 + crate 骨架(清理 V1 CBOR 代码) [quick] -├── Task 2: SSH binary wire format codec — QUIC varint 编解码 [deep] -├── Task 3: SSH3 错误模型 [quick] - -Wave 2 (After Wave 1 — protocol abstractions + message types): -├── Task 4: Conversation trait + LocalConversation [deep] -├── Task 5: SshMessage enum 完整定义 [unspecified-high] -├── Task 6: Ssh3Protocol (h3x Protocol trait 实现) [deep] -├── Task 7: h3x API 验证 spike + remoc RTC spike [quick] - -Wave 3 (After Wave 2 — server HTTP layer): -├── Task 8: 版本协商 + 认证解析 [unspecified-high] -├── Task 9: Extended CONNECT handler [deep] -├── Task 10: E2E 冒烟测试骨架 [quick] - -Wave 4 (After Wave 2 — multi-process, parallel with Wave 3): -├── Task 11: SshSession RTC trait + SessionInit/AuthError [deep] -├── Task 12: PAM wrapper [unspecified-high] -├── Task 13: ssh3-session 子进程二进制 [deep] -├── Task 14: ChildProcess 主进程管理 [unspecified-high] - -Wave 5 (After Wave 3+4 — session + forwarding): -├── Task 15: Channel open/confirm/data 处理(session 通道) [deep] -├── Task 16: Exec/Shell/Subsystem 请求处理 [deep] -├── Task 17: PTY 分配 + 终端处理 [unspecified-high] -├── Task 18: Direct-TCP 转发(原始字节流) [unspecified-high] -├── Task 19: Reverse-TCP 转发 (global request + channel open) [unspecified-high] -├── Task 20: Streamlocal (Unix socket) 转发 [unspecified-high] -├── Task 21: SOCKS5 代理(服务端) [deep] - -Wave 6 (After Wave 5 — client + integration): -├── Task 22: SSH3 客户端连接 + 认证 [deep] -├── Task 23: 客户端会话 + 转发请求 [deep] -├── Task 24: 客户端 SOCKS5 [unspecified-high] -├── Task 25: 完整 E2E 集成测试 [deep] - -Wave FINAL (After ALL tasks — independent review, 4 parallel): -├── Task F1: Plan compliance audit (oracle) -├── Task F2: Code quality review (unspecified-high) -├── Task F3: Real manual QA (unspecified-high) -└── Task F4: Scope fidelity check (deep) - -Critical Path: T1 → T2 → T5 → T6 → T9 → T15 → T16 → T22 → T25 → FINAL -Parallel Speedup: ~60% faster than sequential -Max Concurrent: 7 (Wave 5) -``` - -### Dependency Matrix - -| Task | Depends On | Blocks | Wave | -|------|-----------|--------|------| -| 1 | — | 2, 3 | 1 | -| 2 | 1 | 4, 5, 6, 7 | 1 | -| 3 | 1 | 6, 8, 9, 12 | 1 | -| 4 | 2 | 6, 9, 11, 15 | 2 | -| 5 | 2 | 6, 15, 16 | 2 | -| 6 | 2, 3, 4, 5 | 9, 10 | 2 | -| 7 | 2 | 9, 13 | 2 | -| 8 | 3 | 9 | 3 | -| 9 | 3, 4, 6, 7, 8 | 10, 25 | 3 | -| 10 | 6, 9 | 25 | 3 | -| 11 | 4 | 13, 14 | 4 | -| 12 | 3 | 13 | 4 | -| 13 | 7, 11, 12 | 14, 25 | 4 | -| 14 | 11, 13 | 25 | 4 | -| 15 | 4, 5 | 16, 18, 19, 20 | 5 | -| 16 | 5, 15 | 17, 25 | 5 | -| 17 | 16 | 25 | 5 | -| 18 | 15 | 21, 25 | 5 | -| 19 | 15 | 25 | 5 | -| 20 | 15 | 25 | 5 | -| 21 | 15, 18 | 24, 25 | 5 | -| 22 | 6, 9 | 23, 25 | 6 | -| 23 | 15, 16, 22 | 24, 25 | 6 | -| 24 | 21, 23 | 25 | 6 | -| 25 | 9, 13, 14, 16, 17, 18, 22, 23 | FINAL | 6 | -| F1-F4 | 25 | — | FINAL | - -### Agent Dispatch Summary - -- **Wave 1**: **3** — T1 → `quick`, T2 → `deep`, T3 → `quick` -- **Wave 2**: **4** — T4 → `deep`, T5 → `unspecified-high`, T6 → `deep`, T7 → `quick` -- **Wave 3**: **3** — T8 → `unspecified-high`, T9 → `deep`, T10 → `quick` -- **Wave 4**: **4** — T11 → `deep`, T12 → `unspecified-high`, T13 → `deep`, T14 → `unspecified-high` -- **Wave 5**: **7** — T15 → `deep`, T16 → `deep`, T17 → `unspecified-high`, T18 → `unspecified-high`, T19 → `unspecified-high`, T20 → `unspecified-high`, T21 → `deep` -- **Wave 6**: **4** — T22 → `deep`, T23 → `deep`, T24 → `unspecified-high`, T25 → `deep` -- **FINAL**: **4** — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep` - ---- - -## TODOs - -### Wave 1: Workspace 初始化 + Codec Foundation - -- [x] 1. Workspace 初始化 + Crate 骨架(从零创建) - - **What to do**: - - 在仓库根目录创建 workspace `Cargo.toml`,定义 `[workspace]` 及 `members = ["genmeta-ssh3-proto", "genmeta-ssh3-client", "genmeta-ssh3-server"]` - - 创建 `genmeta-ssh3-proto/` 目录 + `Cargo.toml`(依赖:h3x、snafu、tracing、bytes、tokio)+ `src/lib.rs`(仅 `//! SSH3 protocol types and codec`) - - 创建 `genmeta-ssh3-client/` 目录 + `Cargo.toml`(依赖:genmeta-ssh3-proto、h3x、tokio、tracing)+ `src/lib.rs`(仅 `//! SSH3 client implementation`) - - 创建 `genmeta-ssh3-server/` 目录 + `Cargo.toml`(依赖:genmeta-ssh3-proto、h3x、tokio、tracing、remoc)+ `src/lib.rs`(仅 `//! SSH3 server implementation`) - - 在 `genmeta-ssh3-server/` 中创建 `src/bin/ssh3-session.rs`(仅 `fn main() { todo!() }`) - - 验证 `cargo check --workspace` 通过 - - 确认 `grep -r "cbor\|ciborium\|serde_cbor" --include="*.rs" --include="*.toml" .` 返回零匹配 - - **Must NOT do**: - - 不添加任何 CBOR 相关依赖(ciborium、serde_cbor 等) - - 不创建 Task 2-3 的文件(codec.rs, error.rs) - - 不在 src/lib.rs 中写任何实质代码 - - **Recommended Agent Profile**: - - **Category**: `quick` - - Reason: 纯文件创建和骨架搭建,无复杂逻辑 - - **Skills**: [] - - **Skills Evaluated but Omitted**: - - `git-master`: 不涉及 git 操作 - - **Parallelization**: - - **Can Run In Parallel**: NO - - **Parallel Group**: Wave 1 (sequential — must complete first) - - **Blocks**: Tasks 2, 3 - - **Blocked By**: None (can start immediately) - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/Cargo.toml` — h3x 的 Cargo.toml 结构参考(edition、dependencies 声明方式、feature flags)。注:h3x 为单 crate 结构(非 workspace),本项目需创建 workspace 结构,仅参考其依赖声明风格 - - **External References**: - - `https://doc.rust-lang.org/cargo/reference/workspaces.html` — Cargo workspace 官方文档 - - **WHY Each Reference Matters**: - - h3x Cargo.toml — 本项目需要与 h3x 兼容,参考其 workspace 组织方式确保依赖声明一致 - - Cargo workspace 文档 — 仓库当前为空白状态,需从零创建正确的 workspace 结构 - - **File Boundary**: 只可创建 `Cargo.toml`(根目录 + 3 个 crate)、`*/src/lib.rs`(3 个 crate)、`genmeta-ssh3-server/src/bin/ssh3-session.rs` - - **Acceptance Criteria**: - - [ ] `cargo check --workspace` 通过 - - [ ] `grep -r "cbor\|ciborium\|serde_cbor" --include="*.rs" --include="*.toml" .` 返回零匹配 - - [ ] 三个 crate 各有 Cargo.toml + src/lib.rs - - [ ] ssh3-session bin 存在且有 main 函数 - - [ ] workspace Cargo.toml 包含全部三个 members - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Workspace 从零创建并成功编译 - Tool: Bash - Preconditions: 仓库为空白状态(无 Cargo.toml、无 crate 目录) - Steps: - 1. Run `ls Cargo.toml genmeta-ssh3-proto/Cargo.toml genmeta-ssh3-client/Cargo.toml genmeta-ssh3-server/Cargo.toml` — 确认四个 Cargo.toml 存在 - 2. Run `cargo check --workspace` — 确认编译通过 - 3. Run `grep -r "cbor\|ciborium\|serde_cbor" --include="*.rs" --include="*.toml" .` — 确认零匹配 - 4. Run `ls genmeta-ssh3-server/src/bin/ssh3-session.rs` — 确认 bin 文件存在 - 5. Run `grep -c "members" Cargo.toml` — 确认 workspace members 声明存在 - Expected Result: 全部命令成功,cargo check 通过,零 CBOR 引用,bin 文件存在 - Failure Indicators: 任何 Cargo.toml 不存在、cargo check 失败、grep 找到 CBOR 引用 - Evidence: .sisyphus/evidence/task-1-workspace-creation.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3): initialize workspace and create greenfield crate scaffolding` - - Files: `Cargo.toml`, `genmeta-ssh3-proto/Cargo.toml`, `genmeta-ssh3-proto/src/lib.rs`, `genmeta-ssh3-client/Cargo.toml`, `genmeta-ssh3-client/src/lib.rs`, `genmeta-ssh3-server/Cargo.toml`, `genmeta-ssh3-server/src/lib.rs`, `genmeta-ssh3-server/src/bin/ssh3-session.rs` - - Pre-commit: `cargo check --workspace` - -- [x] 2. SSH Binary Wire Format Codec — QUIC Varint 编解码 - - **What to do**: - - 在 `genmeta-ssh3-proto/src/codec.rs` 中实现 SSH3 wire format 编解码核心,**严格遵循 h3x 的 Encode/Decode trait 模式** - - 复用 `h3x::varint::VarInt` 类型(已有 `Encode` 和 `Decode` trait impl),不重新实现 - - 定义以下 SSH3 协议专用 newtype,并为每个实现 `Encode` / `Decode` trait(在 AsyncWrite/AsyncRead 上): - - `SshString(String)` — varint长度前缀 + UTF-8 字节 - ```rust - pub(crate) struct SshString(pub String); - // impl Encode for S { ... } - // impl Decode for S { ... } - ``` - - `SshBytes(Vec)` — varint长度前缀 + raw bytes - ```rust - pub(crate) struct SshBytes(pub Vec); - ``` - - `SshBool(bool)` — 单字节 0x00/0x01 - ```rust - pub(crate) struct SshBool(pub bool); - ``` - - 定义 `ChannelHeader` struct 并实现 Encode/Decode trait: - ```rust - pub(crate) struct ChannelHeader { - pub signal_value: u32, - pub conversation_id: u64, - pub channel_type: String, - pub max_message_size: u64, - } - // impl Encode<&ChannelHeader> for S { ... } - // writes: VarInt(signal_value) + VarInt(conversation_id) + SshString(channel_type) + VarInt(max_message_size) - // impl Decode for S { ... } - // reads: same order - ``` - - **Encode/Decode trait 模式**(参考 h3x/src/codec.rs:31-70 和 h3x/src/varint.rs:189-222): - ```rust - // h3x pattern: trait impl on stream type, NOT free function - impl Encode for S { - type Output = (); - type Error = EncodeError; - async fn encode(mut self, item: SshString) -> Result { - self.encode_one(VarInt::try_from(item.0.len() as u64)?).await?; - self.write_all(item.0.as_bytes()).await?; - Ok(()) - } - } - // Usage: stream.encode_one(SshString("session".into())).await?; - // Usage: let s: SshString = stream.decode_one::().await?; - ``` - - **重要**: 不定义 free functions(如 encode_varint/decode_varint/encode_ssh_string 等),而是通过 trait impls 提供编解码能力,使用 `EncodeExt::encode_one()` / `DecodeExt::decode_one::()` 调用 - - 每个类型必须有 TDD 测试: - - roundtrip 测试(encode → decode → assert_eq) - - hex dump 测试(encode → 与预期字节序列逐字节对比) - - 边界值测试(0, u32::MAX, u64::MAX, 空字符串, 长字符串) - - **signal_value 编码**: 0xaf3627e6 作为 QUIC varint 编码(参考 Go 实现 `util/wire.go` 中 quicvarint.Append) - - **Must NOT do**: - - 不使用 CBOR — 所有编码为 QUIC varint + raw bytes - - 不使用 serde derive — 手动实现 Encode/Decode trait - - 不定义 ChannelId — channel header 无 channel number 字段 - - 不实现消息级别编解码 — 只做原语类型和 channel header(消息在 Task 5) - - 不重新实现 VarInt 类型 — 复用 h3x::varint::VarInt - - **不使用 free functions(encode_varint/decode_varint 等)**— 全部通过 Encode/Decode trait impl - - **不使用 Buf/BufMut 作为编解码目标** — 使用 AsyncRead/AsyncWrite stream 类型 - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 二进制编解码需要精确的字节级正确性,h3x trait 模式需要仔细的 async trait impl - - **Skills**: [] - - **Skills Evaluated but Omitted**: - - `playwright`: 不涉及浏览器 - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 Task 1 完成骨架) - - **Parallel Group**: Wave 1 (sequential after Task 1) - - **Blocks**: Tasks 4, 5, 6, 7 - - **Blocked By**: Task 1 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/varint.rs:189-222` — VarInt 的 Encode/Decode trait impl 示例(**必须严格参考此模式**) - - `/home/yiyue/code/reimu/h3x/src/codec.rs:31-70` — Encode/Decode trait 定义 + EncodeExt/DecodeExt 辅助 trait - - `/home/yiyue/code/reimu/h3x/src/codec/error.rs` — EncodeError/DecodeError snafu 错误类型(codec 错误应复用或仿照) - - `/home/yiyue/code/reimu/h3x/src/dhttp/settings.rs` — Settings 类型的 Encode/Decode impl 示例(复合类型编解码参考) - - `/home/yiyue/code/reimu/h3x/src/dhttp/goaway.rs` — Goaway 类型的 Encode/Decode impl 示例 - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 3 — Wire format 速查表(channel header, message types, varint 编码) - - **External References**: - - Go 参考实现 `util/wire.go` (francoismichel/ssh3 SHA 5b4b242d) — `WriteVarInt()`, `ReadVarInt()`, `WriteSSHString()`, `ReadSSHString()` 函数,字节序列权威参考 - - Go 参考实现 `message/channel.go` — `BuildChannelHeader()` 函数,channel header 字节序列参考 - - RFC 9000 §16 — QUIC Variable-Length Integer Encoding 规范 - - **WHY Each Reference Matters**: - - h3x varint.rs: **最关键参考** — 必须严格模仿其 Encode/Decode trait impl 模式(impl on AsyncWrite/AsyncRead, type Output/Error, async fn encode/decode) - - h3x codec.rs: Encode/Decode trait 定义和 EncodeExt 的 encode_one() 调用方式 — 所有下游 task 都通过此 API 调用编解码 - - h3x settings.rs/goaway.rs: 复合类型(多字段 struct)的 Encode/Decode impl 示例 — ChannelHeader 编解码参考 - - Go wire.go: 字节序列的唯一权威来源 — hex dump 测试必须与 Go 输出一致 - - Go channel.go: channel header 的字节序列参考 — 验证 signal_value + conversation_id 编码顺序 - **File Boundary**: 只可修改 `genmeta-ssh3-proto/src/codec.rs`、`genmeta-ssh3-proto/src/lib.rs`(添加 mod codec) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-proto -- codec` 全部通过 - - [ ] 每个编解码原语有 roundtrip + hex dump 测试 - - [ ] channel header roundtrip 测试通过 - - [ ] signal_value 0xaf3627e6 的 varint 编码字节序列正确 - - [ ] 零 CBOR 引用 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Varint encoding matches QUIC RFC 9000 §16 - Tool: Bash - Preconditions: codec.rs implemented with tests - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- codec::tests::varint_encoding_known_values` - 2. Verify test includes hex dump assertions for: 0 → [0x00], 63 → [0x3f], 64 → [0x40, 0x40], 16383 → [0x7f, 0xff], 16384 → [0x80, 0x00, 0x40, 0x00] - Expected Result: All varint encoding tests pass with exact byte sequences matching RFC 9000 §16 examples - Failure Indicators: Any hex dump mismatch or test failure - Evidence: .sisyphus/evidence/task-2-varint-encoding.txt - - Scenario: Channel header encoding matches Go reference implementation - Tool: Bash - Preconditions: ChannelHeader codec implemented - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- codec::tests::channel_header_roundtrip` - 2. Run `cargo test -p genmeta-ssh3-proto -- codec::tests::channel_header_hex_dump` - 3. Verify signal_value 0xaf3627e6 encodes to correct varint bytes - Expected Result: Roundtrip preserves all fields, hex dump matches expected sequence - Failure Indicators: Field mismatch after roundtrip, or hex bytes differ from expected - Evidence: .sisyphus/evidence/task-2-channel-header.txt - - Scenario: SSH string encoding uses varint length prefix (not u32) - Tool: Bash - Preconditions: ssh_string codec implemented - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- codec::tests::ssh_string_hex_dump` - 2. Verify "session" encodes as [0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e] (varint 7 + UTF-8) - 3. Verify empty string encodes as [0x00] - Expected Result: String encoding uses varint length prefix, not fixed 4-byte u32 - Failure Indicators: Length prefix is 4 bytes instead of varint, or UTF-8 bytes wrong - Evidence: .sisyphus/evidence/task-2-ssh-string.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-proto): implement SSH binary wire format codec with QUIC varint encoding` - - Files: `genmeta-ssh3-proto/src/codec.rs`, `genmeta-ssh3-proto/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-proto -- codec` - -- [x] 3. SSH3 错误模型 + AuthCredential 类型 - - **What to do**: - - 在 `genmeta-ssh3-proto/src/error.rs` 中定义 snafu 错误类型: - - `Ssh3Error` — 顶层错误 enum(snafu derive) - - `CodecError` — 编解码错误(varint too large, buffer underflow, invalid message type, invalid string encoding) - - `ProtocolError` — 协议级错误(unknown channel type, unexpected message, version mismatch) - - `AuthError` — 认证错误(invalid credentials, PAM failure, unsupported auth scheme) - - `ChannelError` — 通道错误(channel closed, EOF, request failed) - - `SessionError` — 会话错误(exec failed, pty allocation failed, forwarding failed) - - 在 `genmeta-ssh3-proto/src/auth.rs` 中定义: - - `AuthCredential` enum — 仅 `Basic { username: String, password: String }` 一个变体 - - `AuthScheme` enum — 仅 `Basic` 一个变体 - - `parse_authorization_header(header_value: &str) -> Result` — 解析 HTTP Authorization header(Basic base64 decode) - - 所有错误类型实现 `Display`(通过 snafu 自动生成) - - 错误类型使用 `#[snafu(visibility(pub(crate)))]` - - **Must NOT do**: - - 不预留 AuthCredential 未来变体定义(如 Bearer, PublicKey)— 只有 Basic - - 不使用 anyhow/thiserror — 统一使用 snafu - - 不设置 tracing event 的 target - - 不定义 ChannelId 相关错误变体 - - **Recommended Agent Profile**: - - **Category**: `quick` - - Reason: 类型定义和 snafu derive 属于直接的结构化工作 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Task 2 并行 — 但需要 Task 1 完成) - - **Parallel Group**: Wave 1 (parallel with Task 2 after Task 1) - - **Blocks**: Tasks 6, 8, 9, 12 - - **Blocked By**: Task 1 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/error.rs`(如果存在)— h3x 的 snafu 错误模式 - - 搜索 `#[derive(Debug, Snafu)]` 在 h3x crate 中找到示例错误定义 - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 4 — 认证机制描述 - - HTTP Authorization header (RFC 7617) — Basic auth base64 格式 `Basic base64(user:pass)` - - **External References**: - - snafu crate docs — `#[snafu(visibility(...))]`, `#[snafu(display(...))]` 用法 - - **WHY Each Reference Matters**: - - h3x error 模式: 保持 error 风格一致(visibility, display format, context selector pattern) - - RFC Section 4: 确认 Basic auth 格式正确(仅 password-based,scheme="Basic") - - **File Boundary**: 只可修改 `genmeta-ssh3-proto/src/error.rs`、`genmeta-ssh3-proto/src/auth.rs`、`genmeta-ssh3-proto/src/lib.rs`(添加 mod error, mod auth) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-proto -- error` 通过 - - [ ] `cargo test -p genmeta-ssh3-proto -- auth` 通过 - - [ ] AuthCredential 仅有 Basic 变体 - - [ ] `parse_authorization_header("Basic dXNlcjpwYXNz")` 返回 `Basic { username: "user", password: "pass" }` - - [ ] 非 Basic scheme 返回 AuthError - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Basic auth header parsing - Tool: Bash - Preconditions: auth.rs implemented with parse_authorization_header - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- auth::tests::parse_basic_auth` - 2. Verify test covers: valid "Basic dXNlcjpwYXNz" → user/pass - 3. Verify test covers: invalid scheme "Bearer xxx" → AuthError - 4. Verify test covers: malformed base64 → CodecError or AuthError - Expected Result: Valid Basic auth parsed correctly, non-Basic schemes rejected - Failure Indicators: Wrong username/password, or non-Basic schemes accepted - Evidence: .sisyphus/evidence/task-3-basic-auth.txt - - Scenario: Error types compile and display correctly - Tool: Bash - Preconditions: error.rs defined with snafu derives - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- error::tests` - 2. Verify each error variant has a meaningful Display implementation - 3. Run `cargo doc -p genmeta-ssh3-proto --no-deps` to verify docs build - Expected Result: All error variants constructible, displayable, and documented - Failure Indicators: snafu derive errors, Display shows raw debug format - Evidence: .sisyphus/evidence/task-3-error-model.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-proto): define snafu error model and AuthCredential` - - Files: `genmeta-ssh3-proto/src/error.rs`, `genmeta-ssh3-proto/src/auth.rs`, `genmeta-ssh3-proto/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-proto` - -### Wave 2: Protocol Abstractions + Message Types - -- [x] 4. Conversation Trait + LocalConversation - - **What to do**: - - 在 `genmeta-ssh3-proto/src/conversation.rs` 中定义: - - `Conversation` trait: - ```rust - #[async_trait] - pub(crate) trait Conversation: Send + Sync { - /// conversation_id = CONNECT stream 的 QUIC stream ID - fn conversation_id(&self) -> u64; - /// 开始新通道:打开新 QUIC 双向流 + 写 channel header - async fn open_channel(&self, channel_type: &str, max_message_size: u64) -> Result>; - /// 接受传入通道流(由 Ssh3Protocol 派发,已解码 channel header) - async fn accept_channel(&self) -> Result<(ChannelHeader, BoxPeekableBiStream)>; - /// 发送 global request(tcpip-forward 等)—— 在 conversation stream 上发送 SSH 消息 - async fn send_global_request(&self, request_type: &str, want_reply: bool, data: &[u8]) -> Result>>; - /// 接收 global request(服务端用)—— 从 conversation stream 读取 SSH 消息 - async fn recv_global_request(&self) -> Result<(String, bool, Vec)>; - } - ``` - - `LocalConversation` struct: - - 包装 `Arc>` + 一个内部通道队列 `mpsc::Receiver<(ChannelHeader, BoxPeekableBiStream)>`(用于接收 Ssh3Protocol 派发的入站流) - - `open_channel`: 在 QUIC 连接上打开新双向流 → 写入 channel header → 返回读写半边 - - `accept_channel`: 从内部 mpsc::Receiver 接收已派发的流(由 Ssh3Protocol.accept_bi 解码 header 后通过 mpsc::Sender 发送),**不直接从 QuicConnection 接受流** - - `send_global_request`: 在 conversation stream(CONNECT 流)上发送 SSH_MSG_GLOBAL_REQUEST(80) 消息,若 want_reply=true 则等待 SSH_MSG_REQUEST_SUCCESS(81)/FAILURE(82) - - `recv_global_request`: 从 conversation stream 读取 SSH_MSG_GLOBAL_REQUEST(80) 消息 - - `conversation_id`: 返回 CONNECT 流的 stream ID - - 注意:`RemoteConversation`(通过 remoc RTC 代理)在 Task 11 中实现 - - 写 channel header 时使用 h3x Encode/Decode trait:`writer.encode_one(channel_header).await?` / `let header: ChannelHeader = reader.decode_one().await?`(参考设计宗旨 §编解码根本原则) - - Global request 消息编解码使用 Task 5 定义的 SshMessage Encode/Decode trait - - 单元测试: - - mock QUIC 连接 → open_channel → verify channel header bytes on stream - - mock mpsc 通道 → accept_channel → verify 接收派发的流 - - global request roundtrip 测试(发送 + 接收) - **Must NOT do**: - - 不定义 ChannelId — open_channel 返回 stream handles,不返回 channel number - - 不实现 RemoteConversation — 延迟到 Task 11 - - 不实现 initial_window 参数 — QUIC 原生流控 - - 不使用 ChannelOpen(90) 消息 — 打开 QUIC 流 = 打开通道 - - 不在 conversation.rs 中定义消息类型 — 那是 Task 5 - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 涉及 QUIC stream 抽象和异步 trait 设计,需要仔细的生命周期管理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 5, 6, 7 并行) - - **Parallel Group**: Wave 2 - - **Blocks**: Tasks 6, 9, 11, 15 - - **Blocked By**: Task 2 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/codec.rs:26-29` — BoxPeekableBiStream 类型(QUIC 双向流的读写端) - - `/home/yiyue/code/reimu/h3x/src/remoc/quic/connection.rs` — QuicConnection 抽象,用于打开新双向流 - - **API/Type References**: - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader 的 `Encode` / `Decode` trait impl(通过 `stream.encode_one(header).await?` / `stream.decode_one::().await?` 调用) - - RFC draft-michel-ssh3-00 Section 3 — conversation_id 定义(= CONNECT stream ID) - - RFC draft-michel-ssh3-00 Section 3.1 — channel header 格式 - - **External References**: - - Go 参考实现 `channel.go` (francoismichel/ssh3) — `openChannel()` / `acceptChannel()` 流程 - - **WHY Each Reference Matters**: - - BoxPeekableBiStream: 了解 h3x 如何表示 QUIC 双向流,确保 Conversation 返回兼容类型 - - Go channel.go: open/accept channel 的完整流程参考(header 写入顺序、stream 管理) - - RFC Section 3/3.1: conversation_id 和 channel header 的权威定义 - - **File Boundary**: 只可修改 `genmeta-ssh3-proto/src/conversation.rs`、`genmeta-ssh3-proto/src/lib.rs`(添加 mod conversation) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-proto -- conversation` 通过 - - [ ] Conversation trait 无 ChannelId 参数 - - [ ] open_channel 写入正确的 channel header 字节序列 - - [ ] accept_channel 从内部 mpsc 队列接收派发的流(不直接访问 QuicConnection) - - [ ] send_global_request 在 conversation stream 上发送 SSH_MSG_GLOBAL_REQUEST(80) - - [ ] recv_global_request 从 conversation stream 读取 SSH_MSG_GLOBAL_REQUEST(80) - - [ ] 无 ChannelOpen(90) 消息交换 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: open_channel writes correct channel header bytes - Tool: Bash - Preconditions: LocalConversation with mock QUIC connection - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- conversation::tests::open_channel_writes_header` - 2. Verify test opens a channel with type="session", max_message_size=32768 - 3. Verify the first bytes on the stream are: signal_value(varint) + conversation_id(varint) + "session"(ssh_string) + 32768(varint) - Expected Result: Channel header bytes match expected encoding - Failure Indicators: Header bytes wrong, or ChannelOpen(90) message sent instead of header - Evidence: .sisyphus/evidence/task-4-open-channel-header.txt - - Scenario: accept_channel receives from internal dispatch queue - Tool: Bash - Preconditions: LocalConversation with mock mpsc channel (simulating Ssh3Protocol dispatch) - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- conversation::tests::accept_channel_from_dispatch` - 2. Send a (ChannelHeader, stream) pair through the mpsc::Sender - 3. Verify accept_channel() returns the same ChannelHeader and stream - Expected Result: accept_channel receives dispatched stream without touching QuicConnection - Failure Indicators: accept_channel tries to accept directly from QuicConnection - Evidence: .sisyphus/evidence/task-4-accept-channel-dispatch.txt - - Scenario: global request roundtrip on conversation stream - Tool: Bash - Preconditions: LocalConversation with mock conversation stream - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- conversation::tests::global_request_roundtrip` - 2. Verify send_global_request encodes SSH_MSG_GLOBAL_REQUEST(80) with request_type + want_reply + data - 3. Verify recv_global_request decodes the same message correctly - 4. Verify want_reply=true triggers wait for SSH_MSG_REQUEST_SUCCESS(81)/FAILURE(82) - Expected Result: Global request sent and received correctly on conversation stream - Failure Indicators: Wrong message type, or global request sent on wrong stream - Evidence: .sisyphus/evidence/task-4-global-request.txt - - **Commit**: YES - - Message: `feat(ssh3-proto): implement Conversation trait with LocalConversation` - - Files: `genmeta-ssh3-proto/src/conversation.rs`, `genmeta-ssh3-proto/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-proto -- conversation` - -- [x] 5. SshMessage Enum 完整定义 + SSH Binary Codec - - **What to do**: - - 在 `genmeta-ssh3-proto/src/message.rs` 中定义 `SshMessage` enum: - ```rust - pub(crate) enum SshMessage { - /// SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91 - ChannelOpenConfirmation { - max_message_size: u64, // server 端的 max_message_size - }, - /// SSH_MSG_CHANNEL_OPEN_FAILURE = 92 - ChannelOpenFailure { - reason_code: u64, - description: String, - }, - /// SSH_MSG_CHANNEL_DATA = 94 - ChannelData { - data: Vec, - }, - /// SSH_MSG_CHANNEL_EXTENDED_DATA = 95 - ChannelExtendedData { - data_type: u64, // 1 = stderr - data: Vec, - }, - /// SSH_MSG_CHANNEL_EOF = 96 - ChannelEof, - /// SSH_MSG_CHANNEL_CLOSE = 97 - ChannelClose, - /// SSH_MSG_CHANNEL_REQUEST = 98 - ChannelRequest { - request_type: String, - want_reply: bool, - request_data: Vec, // 原始负载,按 request_type 解析 - }, - /// SSH_MSG_CHANNEL_SUCCESS = 99 - ChannelSuccess, - /// SSH_MSG_CHANNEL_FAILURE = 100 - ChannelFailure, - /// SSH_MSG_GLOBAL_REQUEST = 80 (conversation stream only) - GlobalRequest { - request_type: String, - want_reply: bool, - data: Vec, - }, - /// SSH_MSG_REQUEST_SUCCESS = 81 (conversation stream only) - RequestSuccess { - data: Vec, - }, - /// SSH_MSG_REQUEST_FAILURE = 82 (conversation stream only) - RequestFailure, - } - ``` - - 实现 Encode/Decode trait(参考设计宗旨 §编解码根本原则): - - `impl Encode<&SshMessage> for S` — 写入 message_type(varint) + 各字段(varint/ssh_string/ssh_bytes/bool) - - `impl Decode for S` — 读 message_type(varint) → 按类型解码各字段 - - 调用方式:`stream.encode_one(&msg).await?` / `let msg: SshMessage = stream.decode_one().await?` - - **严禁**定义 `encode_message()` / `decode_message()` free functions - - TDD 测试:每个消息类型的 roundtrip + hex dump - - **关键**: ChannelRequest request_data 先作为原始字节保存,不在此处解析具体 request type(exec/shell/pty 在 Task 16 解析) - - **关键**: GlobalRequest(80)/RequestSuccess(81)/RequestFailure(82) 属于 conversation 级消息(在 conversation stream 上发送),不在 channel stream 上使用。但它们复用同一个 SshMessage enum 以简化编解码。 - - **Must NOT do**: - - 不定义 ChannelOpen(90) — 不存在 - - 不定义 ChannelWindowAdjust(93) — 不存在 - - 不在消息中包含 channel_number 字段 — 无 ChannelId - - 不使用 CBOR 编解码 - - 不解析 ChannelRequest 的 request_data 内容 — 只保存原始字节 - - 不使用 ChannelRequest type=95 — 正确值为 98 - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: 多消息类型的编解码工作量大但模式统一,需要仔细但非极度复杂 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 4, 6, 7 并行) - - **Parallel Group**: Wave 2 - - **Blocks**: Tasks 6, 15, 16 - - **Blocked By**: Task 2 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — VarInt/SshString/SshBytes/Bool 的 `Encode`/`Decode` trait impl(SshMessage 的 Encode/Decode 内部通过 `self.encode_one(VarInt(msg_type)).await?` 等调用这些原语) - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 3.2-3.9 — 每个消息类型的字段定义 - - Go 参考实现 `message/message.go` (francoismichel/ssh3 SHA 5b4b242d) — 消息类型常量表(SSH_MSG_CHANNEL_DATA=94, SSH_MSG_CHANNEL_REQUEST=98 等) - - **External References**: - - Go 参考实现 `message/message.go` (francoismichel/ssh3) — `ParseMessage()` / `Write()` 函数,message type + 字段顺序的权威参考 - - Go 参考实现 `message/channel_request.go` — ChannelRequest 的编解码细节 - - **WHY Each Reference Matters**: - - codec.rs: 复用原语而非重复实现 — 确保字节级一致性 - - Go message.go: message type varint + 字段顺序的唯一权威来源 - - Go channel_request.go: ChannelRequest 的 want_reply + request_data 编解码顺序确认 - - **File Boundary**: 只可修改 `genmeta-ssh3-proto/src/message.rs`、`genmeta-ssh3-proto/src/lib.rs`(添加 mod message) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-proto -- message` 全部通过 - - [ ] 每个消息类型有 roundtrip + hex dump 测试 - - [ ] ChannelRequest type = 98(非 95) - - [ ] 无 ChannelOpen(90)、ChannelWindowAdjust(93) 消息类型 - - [ ] 无 channel_number 字段 - - [ ] ChannelExtendedData 包含 data_type 字段 - - [ ] GlobalRequest(80)、RequestSuccess(81)、RequestFailure(82) 均有 roundtrip 测试 - - [ ] SshMessage enum 共 12 个变体(9 channel + 3 global) - **QA Scenarios (MANDATORY):** - ``` - Scenario: All message types roundtrip correctly - Tool: Bash - Preconditions: message.rs with SshMessage enum and codec - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- message::tests::roundtrip` - 2. Verify tests cover all 12 message variants: GlobalRequest(80), RequestSuccess(81), RequestFailure(82), ChannelOpenConfirmation(91), ChannelOpenFailure(92), ChannelData(94), ChannelExtendedData(95), ChannelEof(96), ChannelClose(97), ChannelRequest(98), ChannelSuccess(99), ChannelFailure(100) - Expected Result: All 12 variants encode then decode back to identical values - Failure Indicators: Any variant fails roundtrip, or unknown message type error - Evidence: .sisyphus/evidence/task-5-message-roundtrip.txt - - Scenario: Message type constants are correct varint values - Tool: Bash - Preconditions: message type constants defined - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- message::tests::message_type_hex_dump` - 2. Verify ChannelData(94) encodes with type varint 0x5e (94 in varint) - 3. Verify ChannelRequest(98) encodes with type varint 0x62 (98 in varint) - 4. Verify NO message type 90 or 93 exists in the enum - Expected Result: Message type constants match RFC values, encoded as QUIC varints - Failure Indicators: Wrong type value, or types 90/93 present - Evidence: .sisyphus/evidence/task-5-message-types.txt - - Scenario: ChannelRequest preserves raw request_data - Tool: Bash - Preconditions: ChannelRequest codec implemented - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- message::tests::channel_request_raw_data` - 2. Verify request_type="exec", want_reply=true, request_data=arbitrary bytes - 3. Verify request_data is NOT parsed, just stored as raw bytes - Expected Result: request_data roundtrips as opaque bytes - Failure Indicators: request_data modified or parsed during encode/decode - Evidence: .sisyphus/evidence/task-5-channel-request.txt - - Scenario: GlobalRequest/RequestSuccess/RequestFailure roundtrip - Tool: Bash - Preconditions: SshMessage enum includes 3 global message variants - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- message::tests::global_request_roundtrip` - 2. Verify GlobalRequest encodes with type varint 0x50 (80), includes request_type + want_reply + data - 3. Verify RequestSuccess encodes with type varint 0x51 (81), includes optional data - 4. Verify RequestFailure encodes with type varint 0x52 (82), no payload - 5. Verify all 3 variants roundtrip correctly - Expected Result: All 3 global message types encode/decode correctly with RFC-compliant type values - Failure Indicators: Wrong type varint, missing fields, or roundtrip failure - Evidence: .sisyphus/evidence/task-5-global-request.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-proto): define complete SshMessage enum with SSH binary codec` - - Files: `genmeta-ssh3-proto/src/message.rs`, `genmeta-ssh3-proto/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-proto -- message` - -- [x] 6. Ssh3Protocol (h3x Protocol Trait 实现) - - **What to do**: - - 在 `genmeta-ssh3-server/src/protocol.rs` 中实现 `Ssh3Protocol` struct: - - 实现 `h3x::protocol::Protocol` trait 的 `accept_bi` 方法 - - accept_bi 逻辑(严格参照 h3x DHttpProtocol.accept_bi 模式): - 1. 使用 `reader.decode_one::().await` 尝试读取第一个 VarInt(signal_value) - 2. 如果值等于 `0xaf3627e6` → `Pin::new(&mut reader).reset()` 回退读取位置,然后 `StreamVerdict::Accepted`,解码完整 channel header(`reader.decode_one::().await`),派发到相应 conversation - 3. 如果值不匹配或解码失败 → `Pin::new(&mut reader).reset()` 回退,返回 `StreamVerdict::Passed((reader, writer))`,让下一个 Protocol 处理 - - 保存 conversation 注册表:`HashMap>`(conversation_id → 发送端) - - 提供 `register_conversation(id: u64) -> mpsc::Receiver<(ChannelHeader, BoxPeekableBiStream)>` — 创建 mpsc channel,保存 Sender 到注册表,返回 Receiver 给 LocalConversation(Task 4 的 accept_channel 从此 Receiver 接收) - - 提供 `unregister_conversation(id: u64)` — 从注册表移除 Sender(drop 后 Receiver 端自动收到关闭通知) - - accept_bi 中派发逻辑:解码 channel header 后,通过 `conversation_registry[conversation_id].send((header, stream)).await` 将 stream 派发到对应的 LocalConversation - - **关键**: Ssh3Protocol.accept_bi 是所有入站 bidi stream 的**唯一入口**,LocalConversation.accept_channel 不直接操作 QuicConnection,而是从 mpsc::Receiver 端接收被派发的 stream - - 为 Protocol trait 的 `Any + Send + Sync + Debug` bound 添加必要的 derive/impl - - 单元测试:mock stream 与 signal_value 开头 → Accepted + 通过 mpsc 派发到正确 conversation;非 signal_value 开头 → Passed(stream) - - **Must NOT do**: - - 不在 Ssh3Protocol 中处理 HTTP/3 帧 — 那是 DHttpProtocol 的职责 - - 不在子进程中注册 Protocol — Protocol 仅在主进程 - - 不实现消息处理逻辑 — 只做 stream 路由到 conversation - - 不发明不存在的 h3x API - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 需要理解 h3x Protocol trait 的精确契约并正确实现 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Wave 2 其他任务并行,但实际依赖 T2+T3+T4+T5) - - **Parallel Group**: Wave 2 (但应在 T4 和 T5 完成后开始) - - **Blocks**: Tasks 9, 10, 22 - - **Blocked By**: Tasks 2, 3, 4, 5 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/protocol.rs:138-154` — Protocol trait 定义(accept_bi 签名、StreamVerdict 返回类型) - - `/home/yiyue/code/reimu/h3x/src/protocol.rs:156-163` — StreamVerdict enum(Accepted / Passed(S)) - - `/home/yiyue/code/reimu/h3x/src/dhttp/protocol.rs:280-324` — DHttpProtocol.accept_bi 参考实现(peek + frame type 检查流程) - - **API/Type References**: - - `/home/yiyue/code/reimu/h3x/src/codec.rs:26-29` — BoxPeekableBiStream 类型(accept_bi 参数类型) - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader 的 `Decode` trait impl(通过 `reader.decode_one::().await?` 调用) - - `genmeta-ssh3-proto/src/conversation.rs` (Task 4) — LocalConversation(Ssh3Protocol 创建 mpsc channel,将 Receiver 传给 LocalConversation 的 accept_channel) - - **WHY Each Reference Matters**: - - h3x Protocol trait: 必须精确匹配 accept_bi 签名,返回 StreamVerdict - - DHttpProtocol: 唯一现有的 Protocol 实现参考,理解 peek 模式 - - BoxPeekableBiStream: 精确的参数类型不可猜测 - - LocalConversation: Ssh3Protocol.accept_bi 通过 mpsc::Sender 派发 stream → LocalConversation.accept_channel 从 mpsc::Receiver 接收 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/protocol.rs`、`genmeta-ssh3-server/src/lib.rs`(添加 mod protocol) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- protocol` 通过 - - [ ] Ssh3Protocol 实现 h3x Protocol trait - - [ ] signal_value 流 → StreamVerdict::Accepted - - [ ] 非 signal_value 流 → StreamVerdict::Passed(stream) - - [ ] conversation 注册返回 mpsc::Receiver,注销 drop Sender - - [ ] accept_bi 通过 mpsc::Sender 将 (ChannelHeader, BoxPeekableBiStream) 派发到正确 conversation - - [ ] 未注册 conversation_id 的 stream 被拒绝(不 panic) - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Ssh3Protocol routes SSH3 streams correctly - Tool: Bash - Preconditions: Ssh3Protocol implemented with Protocol trait - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- protocol::tests::accept_bi_ssh3_stream` - 2. Verify: mock stream starting with signal_value bytes → Accepted - 3. Verify: mock stream starting with HTTP/3 frame type → Passed(stream) - Expected Result: SSH3 streams accepted, non-SSH3 streams passed through - Failure Indicators: SSH3 stream passed through, or non-SSH3 stream accepted incorrectly - Evidence: .sisyphus/evidence/task-6-protocol-routing.txt - - Scenario: Conversation registration and mpsc dispatch - Tool: Bash - Preconditions: Ssh3Protocol with conversation registry using mpsc channels - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- protocol::tests::conversation_dispatch` - 2. Register conversation id=42 → get mpsc::Receiver back - 3. Send mock SSH3 stream with conversation_id=42 through accept_bi - 4. Verify Receiver receives (ChannelHeader, BoxPeekableBiStream) with correct header - 5. Send mock SSH3 stream with conversation_id=999 (unregistered) - 6. Verify stream is rejected with error (not dispatched, not panicked) - 7. Unregister conversation id=42 → Receiver gets closed notification - Expected Result: Streams dispatched via mpsc to correct conversation; unregistered IDs rejected gracefully - Failure Indicators: Stream dispatched to wrong conversation, panic on unregistered id, or Receiver not closed on unregister - Evidence: .sisyphus/evidence/task-6-conversation-dispatch.txt - - **Commit**: YES - - Message: `feat(ssh3-server): implement Ssh3Protocol for h3x Protocol trait` - - Files: `genmeta-ssh3-server/src/protocol.rs`, `genmeta-ssh3-server/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- protocol` - -- [x] 7. h3x API 验证 Spike + remoc RTC Spike - - **What to do**: - - **h3x spike** — 验证以下 API 可用性: - - `ConnectionBuilder::protocol()` 可以注册自定义 Protocol 实现 - - `QuicConnection` 可以打开新双向流(open_bi 或等效 API) - - stream peek 可以读取前 N 字节而不消费(用于 signal_value 检测) - - 记录任何 API 差异到 `.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md`(若目录不存在则创建) - - **remoc RTC spike** — 验证以下能力: - - `#[rtc::remote]` trait 可以生成 Client/Server - - `provide()` / `consume()` 可以在 QUIC 连接上工作 - - QUIC stream 可以通过 RTC 传递到子进程(或发现限制并记录 workaround) - - 输出:将发现写入 `.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md`,包括: - - “可用/不可用”判定 + 具体 API 调用示例 - - 任何需要的 workaround 或替代方案 - - 对后续 Tasks 的影响评估 - - **Must NOT do**: - - 不发明不存在的 h3x API — spike 的目的就是验证真实可用性 - - 不写生产代码 — 只写测试/示例代码 - - 不修改 h3x crate 源代码 - - **Recommended Agent Profile**: - - **Category**: `quick` - - Reason: 探索性验证工作,不需要复杂实现 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 4, 5, 6 并行) - - **Parallel Group**: Wave 2 - - **Blocks**: Tasks 9, 13 - - **Blocked By**: Task 2 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/server/route.rs:59-63,65-87` — Router patterns(protocol 注册方式) - - `/home/yiyue/code/reimu/h3x/src/protocol.rs:138-154` — Protocol trait API - - `/home/yiyue/code/reimu/h3x/src/remoc/quic/connection.rs` — RemoteQuicConnection / LocalQuicConnection - - **External References**: - - remoc crate docs — RTC(Remote Trait Call)宏用法和 Client/Server 生成 - - remoc examples — provide()/consume() 使用模式 - - **WHY Each Reference Matters**: - - route.rs: 确认 protocol 注册的正确 API,避免发明不存在的接口 - - remoc connection: 确认 QUIC stream 能否通过 RTC 传递到子进程 - - **File Boundary**: 只可修改 `genmeta-ssh3-proto/tests/spike_*.rs`、`.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md`(若不存在则创建) - - **Acceptance Criteria**: - - [ ] h3x API spike 结果记录在 `.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md` - - [ ] remoc RTC spike 结果记录在 `.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md` - - [ ] 每个 API 有明确的 “可用/不可用/需 workaround” 判定 - - [ ] 后续任务影响评估已写入 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: h3x Protocol registration compiles and works - Tool: Bash - Preconditions: Spike test file created - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto --test spike_h3x -- --nocapture` - 2. Verify output shows: Protocol can be registered, stream peek works, open_bi available - Expected Result: h3x API confirmed usable for SSH3 Protocol implementation - Failure Indicators: Compilation errors, API not found, or unexpected behavior - Evidence: .sisyphus/evidence/task-7-h3x-spike.txt - - Scenario: remoc RTC works across process boundary - Tool: Bash - Preconditions: Spike test with remoc RTC trait - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto --test spike_remoc -- --nocapture` - 2. Verify output shows: RTC trait compiles, Client/Server generated, provide/consume works - 3. Check if QUIC stream passing is possible or needs workaround - Expected Result: remoc RTC confirmed for cross-process trait calls, limitations documented - Failure Indicators: RTC macro fails, provide/consume errors, stream passing impossible - Evidence: .sisyphus/evidence/task-7-remoc-spike.txt - ``` - - **Commit**: YES (group with learnings update) - - Message: `chore(ssh3): h3x API and remoc RTC verification spike` - - Files: `genmeta-ssh3-proto/tests/spike_*.rs`, `.sisyphus/notepads/ssh3-rfc-implementation-v2/learnings.md` - - Pre-commit: `cargo test -p genmeta-ssh3-proto --test spike_h3x && cargo test -p genmeta-ssh3-proto --test spike_remoc` - -### Wave 3: Server HTTP Layer - -- [x] 8. 版本协商 + 认证解析 - - **What to do**: - - 在 `genmeta-ssh3-server/src/version.rs` 中实现: - - SSH3 版本协商逻辑(RFC Section 6) - - `negotiate_version(request_headers: &HeaderMap) -> Result` — 解析 `ssh-version` HTTP header - - `SshVersion` struct: `{ major: u32, minor: u32 }` 或字符串形式 - - 版本不匹配时返回 ProtocolError - - 在 `genmeta-ssh3-server/src/auth.rs` 中实现: - - `extract_auth_credential(request: &Request) -> Result` — 从 HTTP 请求中提取认证信息 - - 支持 Basic scheme: 解析 Authorization header → 调用 proto 层的 `parse_authorization_header()` - - 不支持的 scheme: 返回 401 + WWW-Authenticate: Basic header - - 单元测试:版本协商成功/失败、Basic auth 提取、不支持 scheme 拒绝 - - **Must NOT do**: - - 不实现 Bearer/JWT/PublicKey auth — 只有 Basic - - 不做 PAM 调用 — 那是 Task 12 - - 不做 PAM service name 自动降级 fallback - - 不设置 tracing event 的 target - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: HTTP header 解析 + 版本协商逻辑,中等复杂度 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 9, 10 并行) - - **Parallel Group**: Wave 3 - - **Blocks**: Task 9 - - **Blocked By**: Task 3 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/auth.rs` (Task 3) — parse_authorization_header 函数,server 层调用 proto 层解析 - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 6 — 版本协商 ssh-version header 格式 - - RFC draft-michel-ssh3-00 Section 4 — 认证流程(Basic scheme) - - http crate 的 HeaderMap/HeaderValue 类型 — 请求 header 解析 API - - **WHY Each Reference Matters**: - - proto auth.rs: 复用 proto 层解析而非重复实现 - - RFC Section 6: ssh-version header 格式是权威定义,不可猜测 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/version.rs`、`genmeta-ssh3-server/src/auth.rs`、`genmeta-ssh3-server/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- version` 通过 - - [ ] `cargo test -p genmeta-ssh3-server -- auth` 通过 - - [ ] ssh-version header 解析正确 - - [ ] Basic auth 提取正确 - - [ ] 不支持 scheme 返回 401 + WWW-Authenticate - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Version negotiation accepts valid ssh-version header - Tool: Bash - Preconditions: version.rs implemented - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- version::tests` - 2. Verify valid ssh-version header → SshVersion parsed correctly - 3. Verify missing ssh-version header → ProtocolError - 4. Verify incompatible version → ProtocolError - Expected Result: Valid versions accepted, invalid/missing rejected with proper error - Failure Indicators: Version parsed wrong, or missing version accepted - Evidence: .sisyphus/evidence/task-8-version-negotiation.txt - - Scenario: Auth extraction returns 401 for unsupported schemes - Tool: Bash - Preconditions: auth.rs with extract_auth_credential - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- auth::tests::unsupported_scheme` - 2. Verify Bearer token → 401 + WWW-Authenticate: Basic - 3. Verify missing Authorization header → 401 - Expected Result: Non-Basic schemes rejected with 401 and correct WWW-Authenticate header - Failure Indicators: Bearer accepted, or WWW-Authenticate header missing/wrong - Evidence: .sisyphus/evidence/task-8-auth-401.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement version negotiation and auth parsing` - - Files: `genmeta-ssh3-server/src/version.rs`, `genmeta-ssh3-server/src/auth.rs`, `genmeta-ssh3-server/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server` - -- [x] 9. Extended CONNECT Handler - - **What to do**: - - 在 `genmeta-ssh3-server/src/handler.rs` 中实现 SSH3 Extended CONNECT handler: - - 实现 h3x `Service` trait 或直接写 handler 函数(根据 Task 7 spike 结果确定) - - handler 流程: - 1. 接收 Extended CONNECT 请求(`:protocol = ssh3`) - 2. 调用 version.rs 的 negotiate_version() 验证版本 - 3. 调用 auth.rs 的 extract_auth_credential() 提取认证信息 - 4. 创建 LocalConversation(conversation_id = CONNECT stream ID) - 5. 注册 conversation 到 Ssh3Protocol - 6. 通过 ChildProcess 派发到子进程(Task 14)或直接处理(MVP 可先 inline) - 7. 返回 200 OK + ssh-version response header - - 错误处理: - - 版本不匹配 → 400 - - 认证失败 → 401 + WWW-Authenticate - - 内部错误 → 500 - - 集成测试:mock CONNECT 请求 → 验证 200 响应 + conversation 创建 - - **Must NOT do**: - - 不在子进程中注册 Protocol 或路由 stream - - 不实现通道处理逻辑 — handler 只负责 conversation 创建和认证 - - 不使用 h3x::message::unify — 使用 http crate 类型 - - 不处理普通 HTTP 请求 — 只处理 Extended CONNECT (:protocol=ssh3) - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 核心入口点,整合多个子系统(version + auth + conversation + protocol),需要仔细设计 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖多个 Wave 2 任务) - - **Parallel Group**: Wave 3 (sequential, after T3+T4+T6+T7+T8) - - **Blocks**: Tasks 10, 25 - - **Blocked By**: Tasks 3, 4, 6, 7, 8 - - **References**: - - **Pattern References**: - - `/home/yiyue/code/reimu/h3x/src/server/service.rs:7-11` — Service trait 定义(Request/Response 类型) - - `/home/yiyue/code/reimu/h3x/src/server/message.rs:60-64,151-155` — Request/Response 结构体 - - `/home/yiyue/code/reimu/h3x/src/dhttp/protocol.rs:280-324` — DHttpProtocol 如何处理传入流 - - **API/Type References**: - - `genmeta-ssh3-server/src/version.rs` (Task 8) — negotiate_version() - - `genmeta-ssh3-server/src/auth.rs` (Task 8) — extract_auth_credential() - - `genmeta-ssh3-proto/src/conversation.rs` (Task 4) — LocalConversation - - `genmeta-ssh3-server/src/protocol.rs` (Task 6) — Ssh3Protocol.register_conversation() - - **External References**: - - RFC draft-michel-ssh3-00 Section 2 — Extended CONNECT 请求处理流程 - - RFC 8441 — Bootstrapping WebSockets with HTTP/2 Extended CONNECT(SSH3 复用此机制) - - **WHY Each Reference Matters**: - - Service trait: handler 必须匹配 h3x 的 Request/Response 抽象 - - DHttpProtocol: 理解 h3x 如何派发传入 CONNECT 请求 - - RFC Section 2: Extended CONNECT 的 :protocol 字段和响应格式是权威定义 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/handler.rs`、`genmeta-ssh3-server/src/lib.rs`(添加 mod handler) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- handler` 通过 - - [ ] Extended CONNECT (:protocol=ssh3) → 200 OK + ssh-version header - - [ ] LocalConversation 创建并注册到 Ssh3Protocol - - [ ] 版本不匹配 → 400 - - [ ] 认证失败 → 401 + WWW-Authenticate - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Extended CONNECT handler accepts valid SSH3 connection - Tool: Bash - Preconditions: handler.rs with all dependencies wired - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- handler::tests::valid_ssh3_connect` - 2. Verify: CONNECT request with :protocol=ssh3 + valid ssh-version + valid Basic auth → 200 OK - 3. Verify: response includes ssh-version header - 4. Verify: LocalConversation created with correct conversation_id - Expected Result: 200 OK response with ssh-version, conversation registered - Failure Indicators: Non-200 status, missing ssh-version header, no conversation created - Evidence: .sisyphus/evidence/task-9-valid-connect.txt - - Scenario: Extended CONNECT handler rejects bad auth - Tool: Bash - Preconditions: handler.rs with auth integration - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- handler::tests::bad_auth_connect` - 2. Verify: missing Authorization → 401 - 3. Verify: Bearer token → 401 + WWW-Authenticate: Basic - 4. Verify: wrong password → 401 (after PAM integration in later task, for now mock) - Expected Result: All bad auth scenarios return 401 with WWW-Authenticate header - Failure Indicators: 200 returned for bad auth, or missing WWW-Authenticate - Evidence: .sisyphus/evidence/task-9-bad-auth.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement Extended CONNECT handler` - - Files: `genmeta-ssh3-server/src/handler.rs`, `genmeta-ssh3-server/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server` - -- [x] 10. E2E 冒烟测试骨架 - - **What to do**: - - 创建 `genmeta-ssh3-server/tests/e2e.rs`(server crate 集成测试,添加 genmeta-ssh3-client 为 dev-dependency): - - 建立测试基础设施: - - `start_test_server()` — 启动 test QUIC server(h3x + Ssh3Protocol + DHttpProtocol) - - `create_test_client()` — 创建 QUIC 客户端连接 - - 自签名 TLS 证书生成(用于测试) - - 写一个最小冒烟测试: - - 客户端发起 Extended CONNECT → 服务器响应 200 - - 验证 ssh-version header 存在 - - 这是一个“验证集成点”,后续 tasks 会扩展此测试 - - **Must NOT do**: - - 不测试通道逻辑 — 只测 CONNECT 握手 - - 不使用真实 PAM — 使用 mock auth(硬编码 test user) - - 不启动子进程 — 还不需要(先单进程测试) - - **Recommended Agent Profile**: - - **Category**: `quick` - - Reason: 测试基础设施搭建,模式明确 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T6 + T9) - - **Parallel Group**: Wave 3 (after T6+T9) - - **Blocks**: Task 25 - - **Blocked By**: Tasks 6, 9 - - **References**: - - **Pattern References**: - - 搜索 h3x crate 中的现有测试(`tests/` 目录)— 了解如何启动 h3x test server - - `/home/yiyue/code/reimu/h3x/src/server/` — server 启动 API(ConnectionBuilder, bind, serve) - - **API/Type References**: - - `genmeta-ssh3-server/src/protocol.rs` (Task 6) — Ssh3Protocol - - `genmeta-ssh3-server/src/handler.rs` (Task 9) — CONNECT handler - - **WHY Each Reference Matters**: - - h3x tests: 复用 h3x 的 test server 启动模式,避免重新发明 - - protocol.rs + handler.rs: E2E 测试需要将两者组装在一起 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/tests/e2e.rs`、`genmeta-ssh3-server/tests/common/mod.rs`、`genmeta-ssh3-server/Cargo.toml`(添加 dev-dependency) - - **Acceptance Criteria**: - - [ ] E2E 冒烟测试可运行并通过 - - [ ] test server 启动 + 客户端连接成功 - - [ ] CONNECT 握手返回 200 + ssh-version - - **QA Scenarios (MANDATORY):** - ``` - Scenario: E2E smoke test passes - Tool: Bash - Preconditions: All Wave 1-3 tasks complete - Steps: - 1. Run `cargo test -p genmeta-ssh3-server --test e2e -- smoke_connect` - 2. Verify: test server starts on random port - 3. Verify: client connects and sends Extended CONNECT - 4. Verify: server responds with 200 OK + ssh-version - Expected Result: E2E CONNECT handshake succeeds end-to-end - Failure Indicators: Connection refused, timeout, or non-200 response - Evidence: .sisyphus/evidence/task-10-e2e-smoke.txt - ``` - - **Commit**: YES - - Message: `test(ssh3): add E2E smoke test scaffold for CONNECT handshake` - - Files: `genmeta-ssh3-server/tests/e2e.rs`, `genmeta-ssh3-server/tests/common/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server --test e2e` - -### Wave 4: Multi-Process Architecture - -- [x] 11. SshSession RTC Trait + SessionInit/AuthResult 类型 - - **What to do**: - - 在 `genmeta-ssh3-proto/src/session.rs` 中定义: - - `SshSession` trait(使用 remoc `#[rtc::remote]` 宏): - ```rust - #[rtc::remote] - pub(crate) trait SshSession: Send + Sync { - /// 开始 SSH 会话处理(子进程主循环)—— 子进程 setuid/setgid 后执行 - async fn run_session(&self, init: SessionInit) -> Result<(), SessionError>; - } - ``` - - `SessionInit` struct:`{ conversation_id: u64, username: String, uid: u32, gid: u32, home: PathBuf, shell: PathBuf }` —— PAM 认证成功后主进程构建,传递给子进程 - - `AuthResult` enum:`Success { uid: u32, gid: u32, home: PathBuf, shell: PathBuf }` | `Failure { reason: String }` —— PAM wrapper 的返回类型,主进程使用 - - remoc RTC 会自动生成 `SshSessionClient` 和 `SshSessionServer` - - 单元测试:SshSession trait 可编译、RTC Client/Server 可构造 - - **Must NOT do**: - - 不在 SshSession trait 中传递 ChannelId —— 无 channel number - - 不在 trait 中使用 initial_window 参数 - - 不实现 trait 方法体 —— 只定义 trait + 类型(实现在 Task 13) - - 不预留 pubkey auth 变体 - - 不在 SshSession trait 中定义 authenticate() —— PAM 在主进程直接调用,不经 RTC - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: RTC 宏展开行为需要理解,trait 设计影响后续多个 tasks - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 12, 13, 14 并行) - - **Parallel Group**: Wave 4 - - **Blocks**: Tasks 13, 14 - - **Blocked By**: Task 4 - - **References**: - - **Pattern References**: - - Task 7 spike 结果 — remoc RTC 宏的实际行为和限制 - - `/home/yiyue/code/reimu/h3x/src/remoc/quic/connection.rs` — remoc QUIC 连接抽象 - - **API/Type References**: - - `genmeta-ssh3-proto/src/error.rs` (Task 3) — SessionError 类型(run_session 返回值) - - **External References**: - - remoc crate docs — `#[rtc::remote]` 宏展开、Client/Server 生成规则 - - **WHY Each Reference Matters**: - - Task 7 spike: 确认 RTC 宏可用性和生成代码结构 - - SessionError: run_session() 返回值依赖该类型 - - **File Boundary**: 只可修改 `genmeta-ssh3-proto/src/session.rs`、`genmeta-ssh3-proto/src/lib.rs`(添加 mod session) - - **Acceptance Criteria**: - - [ ] `cargo check -p genmeta-ssh3-proto` 通过(RTC 宏展开成功) - - [ ] `cargo test -p genmeta-ssh3-proto -- session` 通过 - - [ ] SshSessionClient 和 SshSessionServer 类型存在 - - [ ] trait 无 ChannelId / initial_window 参数 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: RTC macro generates Client/Server types - Tool: Bash - Preconditions: session.rs with #[rtc::remote] trait - Steps: - 1. Run `cargo test -p genmeta-ssh3-proto -- session::tests::rtc_types_exist` - 2. Verify: SshSessionClient can be constructed - 3. Verify: SshSessionServer can be constructed from a trait implementation - Expected Result: RTC macro correctly generates Client/Server types - Failure Indicators: Type not found errors, macro expansion failure - Evidence: .sisyphus/evidence/task-11-rtc-types.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-proto): define SshSession RTC trait with SessionInit/AuthResult` - - Files: `genmeta-ssh3-proto/src/session.rs`, `genmeta-ssh3-proto/src/lib.rs` - - Pre-commit: `cargo check -p genmeta-ssh3-proto` - -- [x] 12. PAM Wrapper - - **What to do**: - - 在 `genmeta-ssh3-server/src/auth/pam.rs` 中实现 PAM 4 阶段认证: - - `PamAuth` struct 封装 PAM 调用: - 1. `pam_start()` — 初始化 PAM handle(service name 参数化,默认 "ssh3") - 2. `pam_authenticate()` — 验证用户名/密码 - 3. `pam_acct_mgmt()` — 账户管理检查(账户是否过期等) - 4. `pam_end()` — 清理 PAM handle - - `async fn pam_authenticate(username: &str, password: &str) -> Result` - - Timing attack 防护:认证失败时添加随机延迟(100-500ms) - - 从 PAM 查询用户信息:uid, gid, home, shell(构建 AuthResult::Success) - - 单元测试:mock PAM(用 trait 抽象或 cfg(test) mock),测试 4 阶段流程 - - **Must NOT do**: - - 不做 PAM service name 自动降级 fallback — 使用固定 service name - - 不在子进程中调用 PAM — PAM 在主进程(root 权限)中执行 - - 不实现 pubkey auth - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: PAM C 库集成需要处理 unsafe 和错误处理,但模式明确 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 11, 13, 14 并行) - - **Parallel Group**: Wave 4 - - **Blocks**: Task 13 - - **Blocked By**: Task 3 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/auth.rs` (Task 3) — AuthCredential::Basic 类型 - - `genmeta-ssh3-proto/src/session.rs` (Task 11) — AuthResult 类型(返回值) - - **External References**: - - pam crate docs (pam-rs 或 pam-sys) — PAM C API 的 Rust 绑定 - - OpenSSH source (auth-pam.c) — PAM 4 阶段调用顺序参考 - - **WHY Each Reference Matters**: - - AuthResult: PAM 成功后必须构建正确的 AuthResult::Success - - OpenSSH auth-pam.c: PAM 4 阶段顺序的权威参考 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/auth/pam.rs`、`genmeta-ssh3-server/src/auth/mod.rs`、`genmeta-ssh3-server/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- auth::pam` 通过 - - [ ] PAM 4 阶段调用顺序正确(start → authenticate → acct_mgmt → end) - - [ ] timing attack 防护存在(失败时有随机延迟) - - [ ] 成功认证返回 uid/gid/home/shell - - **QA Scenarios (MANDATORY):** - ``` - Scenario: PAM 4-stage authentication flow - Tool: Bash - Preconditions: pam.rs with mock PAM backend - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- auth::pam::tests::four_stage_flow` - 2. Verify: pam_start called with service="ssh3" - 3. Verify: pam_authenticate called with username+password - 4. Verify: pam_acct_mgmt called after authenticate success - 5. Verify: pam_end called in all cases (success and failure) - Expected Result: All 4 PAM stages called in correct order - Failure Indicators: Stage skipped, or pam_end not called on error path - Evidence: .sisyphus/evidence/task-12-pam-flow.txt - - Scenario: PAM failure returns AuthError with timing protection - Tool: Bash - Preconditions: mock PAM configured to reject - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- auth::pam::tests::auth_failure_timing` - 2. Verify: failure returns AuthError, not panic - 3. Verify: failure path includes artificial delay (measure elapsed time > 100ms) - Expected Result: Auth failure is graceful with timing protection - Failure Indicators: Panic on failure, or response faster than 100ms - Evidence: .sisyphus/evidence/task-12-pam-timing.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement PAM wrapper with 4-stage authentication` - - Files: `genmeta-ssh3-server/src/auth/pam.rs`, `genmeta-ssh3-server/src/auth/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- auth::pam` - -- [x] 13. ssh3-session 子进程二进制 - - **What to do**: - - 在 `genmeta-ssh3-server/src/bin/ssh3-session.rs` 中实现子进程入口: - - `fn main()` → tokio runtime → `async fn run()` - - 通过环境变量或命令行参数接收 remoc channel fd - - 创建 `SshSessionServer`(从 Task 11 的 RTC trait) - - 实现 `SshSession` trait: - - `run_session(init: SessionInit)`: 从 SessionInit 获取 uid/gid/home/shell → setuid/setgid 切换到用户身份 → 占位符实现(实际通道处理在 Wave 5) - - 提供 remoc RTC SshSessionServer → 主进程通过 SshSessionClient 调用 run_session() - - 集成测试:spawn 子进程 → RTC 连接 → 调用 run_session() - - **Must NOT do**: - - 不在子进程中注册 Protocol 或路由 stream - - 不实现完整 run_session 逻輎 — 只做 setuid/setgid + 占位符 - - 不使用 ChannelId - - 不在子进程中调用 PAM — PAM 在主进程执行,子进程通过 SessionInit 接收认证结果 - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 跨进程 RTC 集成和子进程生命周期管理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T7+T11+T12) - - **Parallel Group**: Wave 4 (after T7+T11+T12) - - **Blocks**: Tasks 14, 25 - - **Blocked By**: Tasks 7, 11, 12 - - **References**: - - **Pattern References**: - - Task 7 spike 结果 — remoc RTC provide()/consume() 的实际 API 和 workaround - - `genmeta-ssh3-proto/src/session.rs` (Task 11) — SshSession trait + SshSessionServer 类型 - - **API/Type References**: - - `genmeta-ssh3-server/src/auth/pam.rs` (Task 12) — pam_authenticate() 函数 - - `genmeta-ssh3-proto/src/session.rs` (Task 11) — SshSession trait の run_session() + SessionInit 类型(uid/gid/home/shell 包含) - - **External References**: - - remoc crate docs — provide()/consume() 用于跨进程 RTC 建连 - - Unix setuid/setgid API — nix crate docs - - **WHY Each Reference Matters**: - - Task 7 spike: RTC 的真实可用 API,避免发明不存在的接口 - - SessionInit 类型: run_session() 接收 uid/gid/home/shell,子进程据此 setuid/setgid - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/bin/ssh3-session.rs`、可能添加 `genmeta-ssh3-server/src/session_impl.rs` - - **Acceptance Criteria**: - - [x] `cargo build -p genmeta-ssh3-server --bin ssh3-session` 成功 - - [x] 子进程启动后通过 RTC 提供 SshSession 服务 - - [x] 主进程可通过 SshSessionClient 调用 run_session() - - [x] 不在子进程中注册 Protocol - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Child process starts and provides RTC service - Tool: Bash - Preconditions: ssh3-session binary builds - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::tests::spawn_child_rtc` - 2. Verify: child process spawns successfully - 3. Verify: RTC SshSessionClient created in parent process - 4. Verify: run_session() call reaches child process - Expected Result: Cross-process RTC communication works - Failure Indicators: Spawn fails, RTC connection fails, method call timeout - Evidence: .sisyphus/evidence/task-13-child-rtc.txt - - Scenario: Child process receives SessionInit and does setuid/setgid - Tool: Bash - Preconditions: Child process with mock nix setuid - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::tests::child_setuid` - 2. Call run_session(SessionInit{conversation_id:1, username:"test", uid:1000, gid:1000, home:"/home/test", shell:"/bin/bash"}) via RTC - 3. Verify: setuid(1000) and setgid(1000) called in correct order (gid first, then uid) - Expected Result: Privilege drop executed with correct uid/gid from SessionInit - Failure Indicators: setuid/setgid not called, wrong order, wrong values - Evidence: .sisyphus/evidence/task-13-child-setuid.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement ssh3-session child process with RTC` - - Files: `genmeta-ssh3-server/src/bin/ssh3-session.rs` - - Pre-commit: `cargo build -p genmeta-ssh3-server --bin ssh3-session` - -- [x] 14. ChildProcess 主进程管理 - - **What to do**: - - 在 `genmeta-ssh3-server/src/child.rs` 中实现: - - `ChildProcess` struct — 管理子进程生命周期 - - `spawn(ssh3_session_path: &Path) -> Result<(ChildProcess, SshSessionClient)>` - - 创建 socketpair 或 pipe 用于 remoc 传输 - - `Command::new(ssh3_session_path).spawn()` 启动子进程 - - 通过 remoc consume() 获取 SshSessionClient - - `wait(&mut self) -> Result` — 等待子进程退出 - - `kill(&mut self)` — 强制终止子进程 - - Drop impl — 确保子进程清理 - - 与 CONNECT handler (Task 9) 集成:handler 调用 PamAuth::authenticate() → 成功后调用 ChildProcess::spawn() → 获取 SshSessionClient → 调用 run_session(SessionInit{包含 uid/gid/home/shell}) - - 单元测试:spawn + RTC 连接 + 子进程清理 - - **Must NOT do**: - - 不在子进程中注册 Protocol - - 不传递 ChannelId 给子进程 - - 不实现通道处理 — 只做进程管理 + RTC 建连 - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: 进程管理 + IPC 集成,中等复杂度但需要仔细的资源清理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T11+T13) - - **Parallel Group**: Wave 4 (after T13) - - **Blocks**: Task 25 - - **Blocked By**: Tasks 11, 13 - - **References**: - - **Pattern References**: - - Task 7 spike 结果 — remoc provide()/consume() 的 fd 传递方式 - - `genmeta-ssh3-server/src/bin/ssh3-session.rs` (Task 13) — 子进程端 provide() 逻辑 - - **API/Type References**: - - `genmeta-ssh3-proto/src/session.rs` (Task 11) — SshSessionClient 类型 - - `genmeta-ssh3-server/src/handler.rs` (Task 9) — handler 需要调用 ChildProcess::spawn() - - **WHY Each Reference Matters**: - - Task 13 bin: 子进程端的 provide() 和主进程端的 consume() 必须匹配 - - handler.rs: 了解 handler 如何调用 ChildProcess 才能设计正确 API - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/child.rs`、`genmeta-ssh3-server/src/lib.rs`(添加 mod child)、可能更新 `handler.rs`(集成 spawn) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- child` 通过 - - [ ] ChildProcess::spawn() 返回 SshSessionClient - - [ ] 子进程异常退出时 Drop 清理正常 - - [ ] 不传递 ChannelId - - **QA Scenarios (MANDATORY):** - ``` - Scenario: ChildProcess spawn and cleanup - Tool: Bash - Preconditions: ssh3-session binary available - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- child::tests::spawn_and_cleanup` - 2. Verify: spawn() succeeds and returns SshSessionClient - 3. Verify: after drop(child_process), child process is terminated - 4. Verify: no zombie processes remain - Expected Result: Child process lifecycle managed correctly - Failure Indicators: Spawn fails, zombie process, or RTC connection error - Evidence: .sisyphus/evidence/task-14-child-lifecycle.txt - - Scenario: ChildProcess integrates with CONNECT handler - Tool: Bash - Preconditions: handler.rs updated to use ChildProcess - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- handler::tests::handler_spawns_child` - 2. Verify: CONNECT request → PamAuth::authenticate() → ChildProcess::spawn() → run_session(SessionInit) → 200 OK - Expected Result: Full CONNECT → child spawn → auth flow works - Failure Indicators: Handler fails to spawn child, or auth not called - Evidence: .sisyphus/evidence/task-14-handler-integration.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement ChildProcess manager with RTC integration` - - Files: `genmeta-ssh3-server/src/child.rs`, `genmeta-ssh3-server/src/handler.rs`, `genmeta-ssh3-server/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server` - -### Wave 5: Session + Forwarding - -- [x] 15. Channel Open/Confirm/Data 处理(Session 通道) - - **What to do**: - - 在 `genmeta-ssh3-server/src/channel.rs` 中实现通道生命周期处理: - - 服务端 accept 新 QUIC 双向流(已由 Ssh3Protocol 派发,channel header 已解码) - - 根据 channel_type 分发: - - `"session"` → 发送 ChannelOpenConfirmation(91) → 进入消息循环 - - `"direct-tcpip"` / `"forwarded-tcpip"` → 转发到转发处理(Task 18/19) - - `"direct-streamlocal@openssh.com"` → Unix socket 转发(Task 20) - - 未知类型 → 发送 ChannelOpenFailure(92) - - 消息循环(session 通道): - - 读取流 → `let msg: SshMessage = stream.decode_one().await?` → 匹配 SshMessage 类型 → 处理 - - ChannelData(94) → 写入 stdin / 从 stdout 读取并发送 ChannelData - - ChannelExtendedData(95) → stderr 处理 - - ChannelRequest(98) → 解析 request_type 并派发(Task 16 处理) - - ChannelEof(96) → 关闭 stdin - - ChannelClose(97) → 关闭通道 - - Session 通道数据使用 SSH_MSG_CHANNEL_DATA(94) 包装 - - TDD 测试:模拟客户端发送消息序列 → 验证服务器响应正确 - - **Must NOT do**: - - 不使用 ChannelOpen(90) 消息 — 打开流 = 打开通道,通过 channel header 识别 - - 不使用 ChannelId — 通道 = QUIC 流 - - 不实现 ChannelWindowAdjust — QUIC 原生流控 - - 不在 TCP 转发通道上使用 ChannelData 包装 — TCP 转发用原始字节流 - - 不解析 ChannelRequest 的具体 request_type 内容 — 只做派发(解析在 Task 16) - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 通道生命周期和消息循环是核心逻辑,错误会影响所有下游任务 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T4+T5) - - **Parallel Group**: Wave 5 (first task) - - **Blocks**: Tasks 16, 18, 19, 20 - - **Blocked By**: Tasks 4, 5 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/conversation.rs` (Task 4) — accept_channel() 返回 ChannelHeader + stream - - `genmeta-ssh3-proto/src/message.rs` (Task 5) — SshMessage 的 `Encode`/`Decode` trait impl(通过 `stream.encode_one(&msg).await?` / `stream.decode_one::().await?` 调用) - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 3 — 通道生命周期 - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader 的 Encode/Decode trait impl(通过 stream trait 方法调用) - - **External References**: - - Go 参考实现 `channel.go` (francoismichel/ssh3) — channel 消息循环和派发逻辑 - - **WHY Each Reference Matters**: - - conversation.rs: accept_channel 返回的 ChannelHeader 确定了 channel_type 和 stream handles - - Go channel.go: 消息循环和通道类型派发的权威参考 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/channel.rs`、`genmeta-ssh3-server/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- channel` 通过 - - [ ] session 通道:ChannelOpenConfirmation(91) 正确发送 - - [ ] session 通道:ChannelData(94) 编解码正确 - - [ ] 未知 channel_type → ChannelOpenFailure(92) - - [ ] 无 ChannelOpen(90) 消息 - - [ ] 无 ChannelId 使用 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Session channel lifecycle - Tool: Bash - Preconditions: channel.rs with session channel handling - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- channel::tests::session_channel_lifecycle` - 2. Verify: client opens stream with channel_type="session" → server sends ChannelOpenConfirmation(91) - 3. Verify: client sends ChannelData(94) → server receives data correctly - 4. Verify: client sends ChannelEof(96) → server closes stdin - 5. Verify: client sends ChannelClose(97) → channel closed cleanly - Expected Result: Full session channel lifecycle works - Failure Indicators: Wrong message sent, or lifecycle doesn't complete - Evidence: .sisyphus/evidence/task-15-session-lifecycle.txt - - Scenario: Unknown channel type rejected - Tool: Bash - Preconditions: channel.rs with type dispatch - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- channel::tests::unknown_channel_type` - 2. Verify: channel_type="x11" → ChannelOpenFailure(92) sent - Expected Result: Unknown channel types properly rejected with failure message - Failure Indicators: Unknown type accepted, or server panics - Evidence: .sisyphus/evidence/task-15-unknown-type.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement channel lifecycle with session channel handling` - - Files: `genmeta-ssh3-server/src/channel.rs`, `genmeta-ssh3-server/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- channel` - -- [x] 16. Exec/Shell/Subsystem 请求处理 - - **What to do**: - - 在 `genmeta-ssh3-server/src/session/request.rs` 中解析 ChannelRequest 的 request_data: - - `"exec"` request: - - 解码 request_data → command(ssh_string) - - 启动子进程执行命令(Command::new(shell) -c command) - - 将 stdin/stdout/stderr 连接到通道 ChannelData/ChannelExtendedData - - 等待进程结束 → 发送 exit-status request → ChannelEof → ChannelClose - - `"shell"` request: - - 启动登录 shell(从 AuthResult 的 shell 路径) - - 类似 exec 但无命令参数 - - `"subsystem"` request: - - 解码 request_data → subsystem_name(ssh_string) - - MVP 可以只支持 "sftp"(或返回 ChannelFailure) - - 对于 want_reply=true 的请求,发送 ChannelSuccess(99) 或 ChannelFailure(100) - - `"exit-status"` request:解码 request_data → exit_status(uint32) → server → client 方向 - - `"exit-signal"` request:解码 request_data → signal_name + core_dumped + error_msg + language - - 单元测试:mock 通道 → exec "echo hello" → 收到 ChannelData("hello\n") + exit-status(0) + EOF + Close - - **Must NOT do**: - - 不使用 ChannelRequest type=95 — 正确值为 98 - - 不直接在主进程执行命令 — 通过子进程(已 setuid 到用户身份) - - 不使用 ChannelId 来关联 request 和 channel - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 涉及子进程 stdin/stdout/stderr 管道连接、异步流处理、退出状态管理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 Tasks 18-21 并行,但依赖 T5+T15) - - **Parallel Group**: Wave 5 (after T15) - - **Blocks**: Tasks 17, 25 - - **Blocked By**: Tasks 5, 15 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/message.rs` (Task 5) — SshMessage::ChannelRequest, ChannelSuccess, ChannelFailure - - `genmeta-ssh3-server/src/channel.rs` (Task 15) — ChannelRequest 派发逻辑 - - **API/Type References**: - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — SshString 的 `Decode` trait impl(通过 `stream.decode_one::().await?` 解析 request_data 中的字符串字段) - - RFC draft-michel-ssh3-00 Section 3.7 — ChannelRequest 格式 - - RFC 4254 Section 6.5 — exec/shell/subsystem request 字段定义(SSH3 复用 SSHv2 的 request type) - - **External References**: - - Go 参考实现 `message/channel_request.go` — exec/shell/pty request 解析 - - **WHY Each Reference Matters**: - - RFC 4254 Section 6.5: exec/shell/subsystem 的 request_data 字段定义权威来源 - - Go channel_request.go: request_data 解析的实际字节顺序参考 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/session/request.rs`、`genmeta-ssh3-server/src/session/mod.rs`、`genmeta-ssh3-server/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- session::request` 通过 - - [ ] exec "echo hello" → ChannelData("hello\n") + exit-status(0) + EOF + Close - - [ ] shell → 启动登录 shell - - [ ] want_reply=true → ChannelSuccess(99) 或 ChannelFailure(100) - - [ ] ChannelRequest 使用 type=98 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Exec request runs command and returns output - Tool: Bash - Preconditions: request.rs with exec handling - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::request::tests::exec_echo` - 2. Verify: exec request with command="echo hello" received - 3. Verify: ChannelSuccess(99) sent (if want_reply=true) - 4. Verify: ChannelData with "hello\n" sent back - 5. Verify: exit-status ChannelRequest with exit_status=0 sent - 6. Verify: ChannelEof(96) + ChannelClose(97) sent - Expected Result: Full exec lifecycle: request → success → data → exit-status → eof → close - Failure Indicators: Missing exit-status, wrong output, or channel not closed - Evidence: .sisyphus/evidence/task-16-exec-echo.txt - - Scenario: Failed exec returns ChannelFailure - Tool: Bash - Preconditions: request.rs with exec handling - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::request::tests::exec_failure` - 2. Verify: exec request with nonexistent command → ChannelFailure(100) - Expected Result: Bad command rejected with ChannelFailure - Failure Indicators: ChannelSuccess sent for invalid command, or panic - Evidence: .sisyphus/evidence/task-16-exec-failure.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement exec/shell/subsystem request handling` - - Files: `genmeta-ssh3-server/src/session/request.rs`, `genmeta-ssh3-server/src/session/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- session` - -- [x] 17. PTY 分配 + 终端处理 - - **What to do**: - - 在 `genmeta-ssh3-server/src/session/pty.rs` 中实现: - - `"pty-req"` ChannelRequest 处理: - - 解码 request_data → term_type(ssh_string) + width_cols(uint32) + height_rows(uint32) + width_px(uint32) + height_px(uint32) + terminal_modes(ssh_bytes) - - 使用 nix crate 分配 PTY(openpty) - - 设置终端大小(ioctl TIOCSWINSZ) - - 将 PTY master 连接到通道 I/O - - `"window-change"` ChannelRequest 处理: - - 解码 request_data → width_cols + height_rows + width_px + height_px - - 更新 PTY 终端大小(TIOCSWINSZ) - - `"signal"` ChannelRequest 处理: - - 解码 signal_name → 发送信号给子进程 - - 单元测试:mock PTY 分配、window-change 处理 - - **Must NOT do**: - - 不实现 x11 forwarding - - 不使用 ChannelId - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: PTY 分配涉及 Unix 系统调用但模式成熟 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T16) - - **Parallel Group**: Wave 5 (after T16) - - **Blocks**: Task 25 - - **Blocked By**: Task 16 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-server/src/session/request.rs` (Task 16) — ChannelRequest 派发逻辑 - - **API/Type References**: - - RFC 4254 Section 6.2 — pty-req request 字段定义 - - RFC 4254 Section 6.7 — window-change request 字段定义 - - nix crate docs — openpty(), ioctl TIOCSWINSZ - - **WHY Each Reference Matters**: - - RFC 4254: pty-req 和 window-change 的 request_data 字段顺序是权威定义 - - nix crate: Rust 中分配 PTY 和设置终端大小的标准方法 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/session/pty.rs`、`genmeta-ssh3-server/src/session/mod.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- session::pty` 通过 - - [ ] pty-req 正确分配 PTY 并设置终端大小 - - [ ] window-change 正确更新终端大小 - - [ ] signal request 正确发送信号 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: PTY allocation with pty-req - Tool: Bash - Preconditions: pty.rs with PTY handling - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::pty::tests::pty_allocation` - 2. Verify: pty-req request → PTY allocated with correct terminal size - 3. Verify: ChannelSuccess(99) sent back (if want_reply=true) - Expected Result: PTY allocated, terminal size set, success acknowledged - Failure Indicators: openpty fails, wrong terminal size, or ChannelFailure sent - Evidence: .sisyphus/evidence/task-17-pty-alloc.txt - - Scenario: Window change resizes terminal - Tool: Bash - Preconditions: PTY already allocated - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- session::pty::tests::window_change` - 2. Verify: window-change request → terminal size updated via TIOCSWINSZ - Expected Result: Terminal size updated without errors - Failure Indicators: TIOCSWINSZ ioctl fails, or new size not applied - Evidence: .sisyphus/evidence/task-17-window-change.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement PTY allocation and terminal handling` - - Files: `genmeta-ssh3-server/src/session/pty.rs`, `genmeta-ssh3-server/src/session/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- session::pty` - - -- [x] 18. Direct-TCP 转发(原始字节流) - - **What to do**: - - 在 `genmeta-ssh3-server/src/forward/direct_tcp.rs` 中实现: - - Direct-TCP 通道打开处理: - - 客户端打开新 QUIC bidi stream - - 写入 channel header: signal_value(0xaf3627e6) + conversation_id + channel_type_length + "direct-tcpip" + max_message_size - - 服务端读取 channel header,验证 channel_type=="direct-tcpip" - - 解码 request_data: dest_host(ssh_string) + dest_port(uint32) + originator_host(ssh_string) + originator_port(uint32) - - 建立到 dest_host:dest_port 的 TCP 连接 - - 发送 SSH_MSG_CHANNEL_OPEN_CONFIRMATION(91) 确认 - - 双向数据转发: - - **关键**: TCP 转发通道使用原始字节流,**不**使用 SSH_MSG_CHANNEL_DATA(94) 包装 - - QUIC stream → TCP socket 直接 copy_bidirectional - - TCP socket 关闭 → 发送 ChannelEof(96) + ChannelClose(97) - - QUIC stream 收到 ChannelClose → 关闭 TCP socket - - 错误处理: - - TCP 连接失败 → SSH_MSG_CHANNEL_OPEN_FAILURE(92) with reason code - - 传输中断 → 清理两端资源 - - 在 `genmeta-ssh3-server/src/forward/mod.rs` 中注册 direct-tcp handler - - 单元测试: - - channel header 编解码 round-trip(hex dump 验证) - - 模拟 TCP 连接成功 → 数据双向传输 → 关闭 - - TCP 连接失败 → ChannelOpenFailure(92) 返回 - - 验证**不使用** SSH_MSG_CHANNEL_DATA 包装 - - **Must NOT do**: - - 不使用 ChannelId — 通道通过 QUIC 流标识 - - 不用 SSH_MSG_CHANNEL_DATA(94) 包装 TCP 数据 — 原始字节流直接传输 - - 不实现 ChannelWindowAdjust — QUIC 原生流控 - - 不实现 UDP forwarding - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: 涉及 QUIC stream + TCP socket 双向桥接,需要异步 I/O 处理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 T19, T20 同波次) - - **Parallel Group**: Wave 5 (with Tasks 19, 20, 21) - - **Blocks**: Task 25 - - **Blocked By**: Task 15 (channel lifecycle) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader/ChannelOpenConfirmation/ChannelOpenFailure 的 Encode/Decode trait impl(通过 `stream.encode_one(header).await?` / `stream.decode_one::().await?` 调用) - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — SshString/VarInt 的 Encode/Decode trait impl(通过 stream trait 方法编解码 direct-tcpip 的 request_data 字段) - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 3.5 — TCP port forwarding - - RFC 4254 Section 7.2 — direct-tcpip channel 的 request_data 字段: dest_host, dest_port, originator_host, originator_port - - Go 参考实现 `channel.go` — TCP forwarding 使用 raw byte streams 的实现方式 - - **External References**: - - tokio docs — `tokio::io::copy_bidirectional` 用于双向数据桥接 - - **WHY Each Reference Matters**: - - RFC 4254 Section 7.2: direct-tcpip channel 的 request_data 字段顺序是权威定义 - - Go channel.go: 确认 TCP forwarding 使用原始字节流而非 ChannelData 包装的关键证据 - - tokio copy_bidirectional: 异步双向数据传输的标准 Rust 方案 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/forward/direct_tcp.rs`、`genmeta-ssh3-server/src/forward/mod.rs`、`genmeta-ssh3-server/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- forward::direct_tcp` 通过 - - [ ] channel header 包含 channel_type="direct-tcpip" - - [ ] 数据使用原始字节流传输,**无** SSH_MSG_CHANNEL_DATA(94) 包装 - - [ ] TCP 连接失败 → ChannelOpenFailure(92) 返回 - - [ ] hex dump 测试验证 channel header 字节序列 - - [ ] `grep -r 'ChannelData' genmeta-ssh3-server/src/forward/direct_tcp.rs` → 无匹配 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Direct-TCP channel opens and forwards data bidirectionally - Tool: Bash - Preconditions: direct_tcp.rs with forwarding logic; local TCP echo server on 127.0.0.1:9999 - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::direct_tcp::tests::direct_tcp_roundtrip` - 2. Verify: channel header with channel_type="direct-tcpip" sent - 3. Verify: dest_host="127.0.0.1", dest_port=9999 decoded correctly - 4. Verify: ChannelOpenConfirmation(91) sent back - 5. Verify: raw bytes "hello" sent through QUIC stream → received by TCP server - 6. Verify: TCP server response "echo: hello" → received on QUIC stream as raw bytes - 7. Verify: NO SSH_MSG_CHANNEL_DATA(94) wrapper anywhere in data path - Expected Result: Bidirectional raw byte transfer between QUIC stream and TCP socket - Failure Indicators: Data wrapped in ChannelData, connection refused, or data corruption - Evidence: .sisyphus/evidence/task-18-direct-tcp-roundtrip.txt - - Scenario: TCP connection failure returns ChannelOpenFailure - Tool: Bash - Preconditions: direct_tcp.rs; no server listening on target port - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::direct_tcp::tests::tcp_connect_failure` - 2. Verify: dest_host="127.0.0.1", dest_port=1 (no listener) - 3. Verify: SSH_MSG_CHANNEL_OPEN_FAILURE(92) sent back with reason code - Expected Result: ChannelOpenFailure with appropriate reason - Failure Indicators: Hang, panic, or ChannelOpenConfirmation sent - Evidence: .sisyphus/evidence/task-18-tcp-connect-failure.txt - ``` - - **Commit**: YES (groups with T19, T20) - - Message: `feat(ssh3): implement TCP forwarding with raw byte streams` - - Files: `genmeta-ssh3-server/src/forward/direct_tcp.rs`, `genmeta-ssh3-server/src/forward/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- forward` - - -- [x] 19. Reverse-TCP 转发(global request + 服务端主动开通道) - - **What to do**: - - 在 `genmeta-ssh3-server/src/forward/reverse_tcp.rs` 中实现: - - Global Request 处理(在 conversation stream 上): - - 客户端发送 tcpip-forward global request: bind_address(ssh_string) + bind_port(uint32) - - 服务端监听指定地址端口 - - bind_port=0 时,服务端分配端口并在 reply 中返回 allocated_port(uint32) - - cancel-tcpip-forward global request: 停止监听 - - 服务端主动开通道(收到 TCP 连接时): - - 服务端打开新 QUIC bidi stream - - 写入 channel header: channel_type="forwarded-tcpip" - - request_data: connected_address(ssh_string) + connected_port(uint32) + originator_address(ssh_string) + originator_port(uint32) - - 等待客户端 SSH_MSG_CHANNEL_OPEN_CONFIRMATION(91) - - 数据使用原始字节流(同 direct-tcp) - - 错误处理: - - TCP listener bind 失败 → global request failure reply - - 客户端拒绝 channel → 关闭 TCP 连接 - - 单元测试: - - tcpip-forward global request 解码 + reply 编码 - - 服务端主动开通道的 channel header 编码(hex dump) - - cancel-tcpip-forward 停止监听 - - bind_port=0 时分配端口并返回 - - **Must NOT do**: - - 不使用 ChannelId — 通道通过 QUIC 流标识 - - 不用 SSH_MSG_CHANNEL_DATA(94) 包装 TCP 数据 — 原始字节流 - - 不实现 UDP forwarding - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: 涉及 global request + 服务端主动开通道的双向逻辑,复杂度较高 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 T18, T20, T21 同波次) - - **Parallel Group**: Wave 5 (with Tasks 18, 20, 21) - - **Blocks**: Task 25 - - **Blocked By**: Task 15 (channel lifecycle) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader/ChannelOpenConfirmation 的 Encode/Decode trait impl(通过 `stream.encode_one(header).await?` / `stream.decode_one::().await?` 调用) - - `genmeta-ssh3-server/src/forward/direct_tcp.rs` (Task 18) — 原始字节流数据传输模式 - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 3.5 — TCP port forwarding (reverse 方向) - - RFC 4254 Section 7.1 — tcpip-forward global request 字段定义 - - RFC 4254 Section 7.2 — forwarded-tcpip channel request_data 字段定义 - - Go 参考实现 `channel.go` — reverse forwarding 的服务端主动开通道实现 - - **WHY Each Reference Matters**: - - RFC 4254 Section 7.1: tcpip-forward global request 的 bind_address + bind_port 字段顺序是权威定义 - - RFC 4254 Section 7.2: forwarded-tcpip 的 request_data 字段顺序与 direct-tcpip 不同,必须按规范实现 - - Go channel.go: 服务端如何主动打开新流并发送 channel header 的实际流程 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/forward/reverse_tcp.rs`、`genmeta-ssh3-server/src/forward/mod.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- forward::reverse_tcp` 通过 - - [ ] tcpip-forward global request 正确解码 bind_address + bind_port - - [ ] bind_port=0 → reply 包含 allocated_port - - [ ] 服务端打开新流时 channel_type="forwarded-tcpip" - - [ ] 数据使用原始字节流传输 - - [ ] cancel-tcpip-forward 停止监听 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Reverse-TCP forward with server-initiated channel - Tool: Bash - Preconditions: reverse_tcp.rs with forwarding logic - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::reverse_tcp::tests::reverse_tcp_forward` - 2. Verify: tcpip-forward global request with bind_address="0.0.0.0", bind_port=8080 received - 3. Verify: server starts listening on 0.0.0.0:8080 - 4. Verify: incoming TCP connection triggers server to open new QUIC bidi stream - 5. Verify: channel header with channel_type="forwarded-tcpip" written - 6. Verify: client sends ChannelOpenConfirmation(91) - 7. Verify: data forwarded bidirectionally as raw bytes (no ChannelData wrapping) - Expected Result: Full reverse forwarding lifecycle: global request → listen → accept → channel open → data relay - Failure Indicators: Channel type wrong, data wrapped in ChannelData, or listen failure not reported - Evidence: .sisyphus/evidence/task-19-reverse-tcp-forward.txt - - Scenario: Bind port 0 allocates dynamic port - Tool: Bash - Preconditions: reverse_tcp.rs - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::reverse_tcp::tests::dynamic_port_allocation` - 2. Verify: tcpip-forward with bind_port=0 → server allocates ephemeral port - 3. Verify: reply contains allocated_port > 0 - Expected Result: Server allocates and returns a valid ephemeral port - Failure Indicators: allocated_port=0 or port not actually listening - Evidence: .sisyphus/evidence/task-19-dynamic-port.txt - ``` - - **Commit**: YES (groups with T18, T20) - - Message: `feat(ssh3-server): implement reverse-TCP forwarding with global request` - - Files: `genmeta-ssh3-server/src/forward/reverse_tcp.rs`, `genmeta-ssh3-server/src/forward/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- forward` - -- [x] 20. Streamlocal (Unix Socket) 转发 - - **What to do**: - - 在 `genmeta-ssh3-server/src/forward/streamlocal.rs` 中实现: - - Direct streamlocal 通道: - - channel_type="direct-streamlocal@openssh.com" - - request_data: socket_path(ssh_string) + reserved(ssh_string) + reserved(uint32) - - 建立 Unix socket 连接到 socket_path - - 数据使用原始字节流(同 TCP forwarding) - - Reverse streamlocal 通道: - - streamlocal-forward@openssh.com global request: socket_path(ssh_string) - - cancel-streamlocal-forward@openssh.com global request: socket_path(ssh_string) - - 服务端监听 Unix socket,收到连接时打开 channel_type="forwarded-streamlocal@openssh.com" - - 错误处理: - - Socket 不存在 → ChannelOpenFailure(92) - - Socket 权限拒绝 → ChannelOpenFailure(92) with reason - - 单元测试: - - channel header 编解码与 direct-streamlocal channel_type 验证 - - Unix socket 连接 + 双向数据传输 - - socket 不存在时的错误处理 - - **Must NOT do**: - - 不使用 ChannelId - - 不用 SSH_MSG_CHANNEL_DATA(94) 包装数据 - - 不实现 x11 forwarding - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: 与 direct-tcp 结构类似,但涉及 Unix socket 特有逻辑 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 T18, T19, T21 同波次) - - **Parallel Group**: Wave 5 (with Tasks 18, 19, 21) - - **Blocks**: Task 25 - - **Blocked By**: Task 15 (channel lifecycle) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-server/src/forward/direct_tcp.rs` (Task 18) — 直接复用原始字节流数据传输模式 - - `genmeta-ssh3-server/src/forward/reverse_tcp.rs` (Task 19) — reverse forwarding 的 global request + 服务端开通道模式 - - **API/Type References**: - - OpenSSH streamlocal extension spec — channel_type 和 request_data 字段定义 - - Go 参考实现 `channel.go` — streamlocal 处理方式 - - **WHY Each Reference Matters**: - - OpenSSH spec: direct-streamlocal@openssh.com 的正确 channel_type 和 request_data 字段定义 - - Task 18 (direct_tcp.rs): Unix socket 传输与 TCP 传输共享相同的原始字节流模式,可抽取公共逻辑 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/forward/streamlocal.rs`、`genmeta-ssh3-server/src/forward/mod.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- forward::streamlocal` 通过 - - [ ] direct-streamlocal@openssh.com channel 正确连接 Unix socket - - [ ] 数据使用原始字节流传输 - - [ ] streamlocal-forward@openssh.com global request 启动监听 - - [ ] cancel-streamlocal-forward@openssh.com 停止监听 - - [ ] socket 不存在 → ChannelOpenFailure(92) - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Direct streamlocal connects to Unix socket - Tool: Bash - Preconditions: streamlocal.rs; test Unix socket at /tmp/test-ssh3.sock (echo server) - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::streamlocal::tests::direct_streamlocal` - 2. Verify: channel_type="direct-streamlocal@openssh.com" - 3. Verify: socket_path decoded correctly from request_data - 4. Verify: Unix socket connection established - 5. Verify: raw byte data forwarded bidirectionally - Expected Result: Data relayed between QUIC stream and Unix socket without ChannelData wrapping - Failure Indicators: Socket connection fails, data wrapped in ChannelData, or wrong channel_type - Evidence: .sisyphus/evidence/task-20-direct-streamlocal.txt - - Scenario: Missing socket returns ChannelOpenFailure - Tool: Bash - Preconditions: streamlocal.rs; no socket at /tmp/nonexistent.sock - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::streamlocal::tests::missing_socket` - 2. Verify: ChannelOpenFailure(92) sent with reason code - Expected Result: Graceful failure with ChannelOpenFailure - Failure Indicators: Panic, hang, or ChannelOpenConfirmation sent - Evidence: .sisyphus/evidence/task-20-missing-socket.txt - ``` - - **Commit**: YES (groups with T18, T19) - - Message: `feat(ssh3-server): implement streamlocal (Unix socket) forwarding` - - Files: `genmeta-ssh3-server/src/forward/streamlocal.rs`, `genmeta-ssh3-server/src/forward/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- forward` - -- [x] 21. SOCKS5 代理(服务端) - - **What to do**: - - 在 `genmeta-ssh3-server/src/forward/socks5.rs` 中实现: - - SOCKS5 协议处理(RFC 1928): - - 客户端打开 QUIC bidi stream,channel_type="socks5" - - 服务端解析 SOCKS5 协商: VERSION(0x05) + NMETHODS + METHODS - - 服务端回复: VERSION(0x05) + METHOD(0x00 = no auth) - - 解析 CONNECT 请求: VERSION + CMD(0x01) + RSV + ATYP + DST.ADDR + DST.PORT - - 支持 ATYP: 0x01 (IPv4), 0x03 (domain name), 0x04 (IPv6) - - 建立 TCP 连接到目标地址 - - 回复 SOCKS5 success: VERSION(0x05) + REP(0x00) + RSV + ATYP + BND.ADDR + BND.PORT - - 之后转为原始字节流双向传输 - - 错误处理: - - SOCKS5 协商失败 → 关闭流 - - TCP 连接失败 → SOCKS5 reply with REP=0x05 (connection refused) - - 不支持的 CMD → REP=0x07 (command not supported) - - 单元测试: - - SOCKS5 协商字节序列 round-trip - - IPv4/IPv6/domain name CONNECT 解析 - - TCP 连接成功后双向数据传输 - - TCP 连接失败的错误回复 - - **Must NOT do**: - - 不实现 SOCKS5 认证(仅 no-auth) - - 不实现 BIND 或 UDP ASSOCIATE 命令 - - 不使用 ChannelId - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: SOCKS5 协议解析 + TCP 转发,涉及多层字节级解析,复杂度较高 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: YES(与 T18, T19, T20 同波次) - - **Parallel Group**: Wave 5 (with Tasks 18, 19, 20) - - **Blocks**: Task 24 - - **Blocked By**: Task 15 (channel lifecycle), Task 18 (TCP forwarding pattern reference) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-server/src/forward/direct_tcp.rs` (Task 18) — TCP 连接 + 原始字节流数据传输模式 - - **API/Type References**: - - RFC 1928 — SOCKS5 协议完整字节格式定义 - - RFC draft-michel-ssh3-00 Section 3.5 — SSH3 对 SOCKS5 的集成方式 - - Go 参考实现 — SOCKS5 channel 处理逻辑 - - **External References**: - - RFC 1928 full text — SOCKS5 字节级协议解析参考 - - **WHY Each Reference Matters**: - - RFC 1928: SOCKS5 协商/CONNECT/reply 的精确字节格式是权威定义,必须严格遵循 - - Task 18 (direct_tcp.rs): SOCKS5 CONNECT 成功后的数据传输与 direct-tcp 使用相同的原始字节流模式 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/src/forward/socks5.rs`、`genmeta-ssh3-server/src/forward/mod.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server -- forward::socks5` 通过 - - [ ] SOCKS5 VERSION/METHOD 协商正确 - - [ ] CONNECT 支持 IPv4(0x01), domain(0x03), IPv6(0x04) - - [ ] 成功连接后数据双向传输(原始字节流) - - [ ] TCP 连接失败 → SOCKS5 REP=0x05 - - [ ] 不支持的 CMD → REP=0x07 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: SOCKS5 CONNECT to remote host via IPv4 - Tool: Bash - Preconditions: socks5.rs with SOCKS5 handling; TCP echo server on 127.0.0.1:9999 - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::socks5::tests::socks5_connect_ipv4` - 2. Verify: SOCKS5 negotiation: client sends 05 01 00, server replies 05 00 - 3. Verify: CONNECT request: 05 01 00 01 7f000001 2710 (127.0.0.1:10000 equivalent) - 4. Verify: TCP connection established - 5. Verify: SOCKS5 success reply: 05 00 00 01 ... with bound address/port - 6. Verify: raw bytes forwarded bidirectionally after SOCKS5 handshake - Expected Result: Full SOCKS5 lifecycle: negotiate → connect → relay - Failure Indicators: Wrong SOCKS5 reply bytes, connection not relayed, or data corruption - Evidence: .sisyphus/evidence/task-21-socks5-connect-ipv4.txt - - Scenario: SOCKS5 CONNECT fails with connection refused - Tool: Bash - Preconditions: socks5.rs; no server on target port - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::socks5::tests::socks5_connect_refused` - 2. Verify: SOCKS5 negotiation succeeds - 3. Verify: CONNECT to unreachable host → SOCKS5 reply with REP=0x05 - Expected Result: SOCKS5 connection refused error properly reported - Failure Indicators: Panic, hang, or success reply sent - Evidence: .sisyphus/evidence/task-21-socks5-connect-refused.txt - - Scenario: Unsupported SOCKS5 command rejected - Tool: Bash - Preconditions: socks5.rs - Steps: - 1. Run `cargo test -p genmeta-ssh3-server -- forward::socks5::tests::socks5_unsupported_cmd` - 2. Verify: BIND command (CMD=0x02) → SOCKS5 reply with REP=0x07 - Expected Result: Command not supported error - Failure Indicators: Attempt to process BIND command - Evidence: .sisyphus/evidence/task-21-socks5-unsupported-cmd.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-server): implement SOCKS5 proxy` - - Files: `genmeta-ssh3-server/src/forward/socks5.rs`, `genmeta-ssh3-server/src/forward/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server -- forward::socks5` - -### Wave 6: Client + Integration - -- [ ] 22. SSH3 客户端连接 + 认证 - - **What to do**: - - 在 `genmeta-ssh3-client/src/lib.rs` 中实现: - - `Ssh3Client` 结构体: - - QUIC 连接到服务端(复用 h3x QUIC 连接) - - 发送 Extended CONNECT 请求(method=CONNECT, :protocol=ssh3, path=/.well-known/ssh3/v3, authority=host:port) - - SSH 版本协商:发送客户端版本信息 - - Basic 认证:发送 Authorization: Basic base64(user:password) header - - 处理服务端 200 OK 响应(认证成功)或 401/403 拒绝 - - 认证成功后,conversation stream 建立 - - `Conversation` 客户端接口: - - open_channel(channel_type, max_message_size) → 打开新 QUIC bidi stream + 写 channel header - - send_global_request(request_type, data) → 在 conversation stream 上发送 - - TLS 配置:设置 QUIC TLS 参数,支持自签名证书用于测试 - - 单元测试: - - Extended CONNECT 请求构造验证 - - Basic auth header 编码验证 - - 版本协商字节序列验证 - - **Must NOT do**: - - 不实现 JWT/Bearer 或 OIDC 认证 - - 不实现 HTTP Signature pubkey auth - - 不实现 Concealed Auth - - 不发明不存在的 h3x API - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 客户端连接建立涉及 QUIC + Extended CONNECT + 认证,多层协议交互 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(Wave 6 基础) - - **Parallel Group**: Wave 6 (sequential start) - - **Blocks**: Tasks 23, 24, 25 - - **Blocked By**: Tasks 5, 6, 8, 9 (协议 + 服务端基础设施) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-proto/src/message.rs` (Task 5) — SshMessage 的 Encode/Decode trait impl(通过 `stream.encode_one(&msg).await?` / `stream.decode_one::().await?` 复用) - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader 的 Encode/Decode trait impl(通过 `stream.encode_one(header).await?` 复用) - - `genmeta-ssh3-server/src/handler.rs` (Task 9) — Extended CONNECT 服务端处理,客户端必须匹配 - - **API/Type References**: - - RFC draft-michel-ssh3-00 Section 2 — Extended CONNECT 字段: :protocol=ssh3, path=/.well-known/ssh3/v3 - - RFC draft-michel-ssh3-00 Section 2.2 — Basic (password) 认证方式 - - h3x QUIC connection API — `RemoteQuicConnection` 客户端连接接口 - - **External References**: - - Go 参考实现 `client/` 目录 — 客户端连接建立流程 - - **WHY Each Reference Matters**: - - Task 9 (handler.rs): 客户端发送的 Extended CONNECT 必须与服务端期望的格式严格匹配 - - RFC Section 2.2: Basic auth 的 Authorization header 格式是权威定义 - - h3x RemoteQuicConnection: 客户端 QUIC 连接的实际 API 接口 - - **File Boundary**: 只可修改 `genmeta-ssh3-client/src/lib.rs`、`genmeta-ssh3-client/Cargo.toml` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-client` 通过 - - [ ] Extended CONNECT 请求包含 :protocol=ssh3, path=/.well-known/ssh3/v3 - - [ ] Basic auth header 正确编码 base64(user:password) - - [ ] 认证成功后 conversation stream 可用 - - [ ] 认证失败(401) 返回错误,不 panic - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Client connects and authenticates with Basic auth - Tool: Bash - Preconditions: genmeta-ssh3-client crate; genmeta-ssh3-server running on localhost - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- tests::basic_auth_connect` - 2. Verify: Extended CONNECT sent with :protocol="ssh3", path="/.well-known/ssh3/v3" - 3. Verify: Authorization header contains "Basic " + base64 of "testuser:testpassword" - 4. Verify: Server responds with 200 OK - 5. Verify: conversation stream established and functional - Expected Result: Successful client-server connection with Basic auth - Failure Indicators: CONNECT rejected, wrong auth header format, or connection hangs - Evidence: .sisyphus/evidence/task-22-basic-auth-connect.txt - - Scenario: Client handles authentication failure gracefully - Tool: Bash - Preconditions: genmeta-ssh3-client; server configured to reject credentials - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- tests::auth_failure` - 2. Verify: wrong credentials → server responds 401 Unauthorized - 3. Verify: client returns Err with descriptive message, no panic - Expected Result: Graceful error with clear message about auth failure - Failure Indicators: Panic, hang, or incorrect error type - Evidence: .sisyphus/evidence/task-22-auth-failure.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-client): implement SSH3 client connection and Basic auth` - - Files: `genmeta-ssh3-client/src/lib.rs`, `genmeta-ssh3-client/Cargo.toml` - - Pre-commit: `cargo test -p genmeta-ssh3-client` - -- [ ] 23. 客户端会话 + 转发请求 - - **What to do**: - - 在 `genmeta-ssh3-client/src/session.rs` 中实现: - - 打开会话通道: - - 打开 QUIC bidi stream + 写 channel header (channel_type="session") - - 等待服务端 ChannelOpenConfirmation(91) - - 发送 ChannelRequest: - - exec(command) → ChannelRequest(98) with request_type="exec" - - shell() → ChannelRequest(98) with request_type="shell" - - pty_req(term, width, height) → ChannelRequest(98) with request_type="pty-req" - - window_change(width, height) → ChannelRequest(98) with request_type="window-change" - - 接收数据: - - 读取 ChannelData(94) → stdout - - 读取 ChannelExtendedData(95) type=1 → stderr - - 读取 exit-status ChannelRequest → 提取退出码 - - 读取 ChannelEof(96) + ChannelClose(97) → 会话结束 - - 在 `genmeta-ssh3-client/src/forward.rs` 中实现: - - direct_tcp_forward(dest_host, dest_port) → 打开 channel_type="direct-tcpip" 流 - - request_reverse_forward(bind_addr, bind_port) → tcpip-forward global request - - handle_forwarded_channel() → 接受服务端主动打开的 forwarded-tcpip 通道 - - 单元测试: - - exec request 编码验证(hex dump) - - ChannelData/ExtendedData 解码验证 - - exit-status 提取验证 - - **Must NOT do**: - - 不使用 ChannelId - - 不实现 agent-connection channel - - 不使用 ChannelWindowAdjust - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: 客户端会话逻辑复杂,涉及多种通道类型和双向消息流 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T22) - - **Parallel Group**: Wave 6 (after T22) - - **Blocks**: Task 25 - - **Blocked By**: Task 22 - - **References**: - - **Pattern References**: - - `genmeta-ssh3-client/src/lib.rs` (Task 22) — 客户端连接 + open_channel 接口 - - `genmeta-ssh3-proto/src/message.rs` (Task 5) — SshMessage 的 Encode/Decode trait impl(通过 stream trait 方法编解码) - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — ChannelHeader 的 Encode/Decode trait impl(通过 stream trait 方法编解码) - - `genmeta-ssh3-server/src/session/request.rs` (Task 16) — 服务端对应的 request 处理,客户端必须匹配 - - **API/Type References**: - - RFC 4254 Section 6.5 — exec/shell/subsystem request_data 字段定义 - - RFC 4254 Section 6.2 — pty-req request_data 字段定义 - - RFC 4254 Section 7.2 — direct-tcpip channel request_data 字段定义 - - **WHY Each Reference Matters**: - - Task 16 (request.rs): 客户端发送的 request 必须与服务端解析一致,否则会话失败 - - RFC 4254: request_data 字段的精确顺序和类型是客户端服务端互操作的关键 - - **File Boundary**: 只可修改 `genmeta-ssh3-client/src/session.rs`、`genmeta-ssh3-client/src/forward.rs`、`genmeta-ssh3-client/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-client -- session` 通过 - - [ ] exec request 正确发送 ChannelRequest(98) with request_type="exec" - - [ ] ChannelData(94) → stdout, ChannelExtendedData(95) type=1 → stderr - - [ ] exit-status 正确提取 - - [ ] direct_tcp_forward 打开 channel_type="direct-tcpip" 流 - - [ ] request_reverse_forward 发送 tcpip-forward global request - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Client executes remote command and receives output - Tool: Bash - Preconditions: genmeta-ssh3-client with session.rs; mock server - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- session::tests::exec_remote_command` - 2. Verify: channel header with channel_type="session" sent - 3. Verify: ChannelRequest(98) with request_type="exec", command="echo hello" sent - 4. Verify: ChannelData(94) with "hello\n" received as stdout - 5. Verify: exit-status ChannelRequest received with exit_status=0 - 6. Verify: ChannelEof(96) + ChannelClose(97) received - Expected Result: Full exec lifecycle from client perspective - Failure Indicators: Wrong message type, missing exit status, or channel not closed - Evidence: .sisyphus/evidence/task-23-exec-remote.txt - - Scenario: Client receives stderr via ExtendedData - Tool: Bash - Preconditions: session.rs with ExtendedData handling - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- session::tests::stderr_via_extended_data` - 2. Verify: ChannelExtendedData(95) with data_type_code=1 decoded as stderr - 3. Verify: stderr data separated from stdout - Expected Result: stderr correctly extracted from ExtendedData messages - Failure Indicators: stderr mixed with stdout, or ExtendedData not handled - Evidence: .sisyphus/evidence/task-23-stderr.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-client): implement session and forwarding requests` - - Files: `genmeta-ssh3-client/src/session.rs`, `genmeta-ssh3-client/src/forward.rs`, `genmeta-ssh3-client/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-client` - -- [ ] 24. 客户端 SOCKS5 - - **What to do**: - - 在 `genmeta-ssh3-client/src/socks5.rs` 中实现: - - 本地 SOCKS5 服务器: - - 监听本地端口(如 127.0.0.1:1080) - - 接受本地 SOCKS5 客户端连接 - - 解析 SOCKS5 CONNECT 请求,提取目标地址 - - 通过 SSH3 服务端转发: - - 打开 channel_type="direct-tcpip" 流到服务端(目标地址来自 SOCKS5 请求) - - 等待 ChannelOpenConfirmation(91) - - 回复 SOCKS5 success 给本地客户端 - - 双向桥接: 本地 TCP socket ↔ QUIC stream(原始字节流) - - 或者直接使用 channel_type="socks5" 让服务端处理 SOCKS5(待确认) - - 单元测试: - - SOCKS5 协商解析 - - 目标地址提取后正确转发到 direct-tcpip channel - - **Must NOT do**: - - 不实现 SOCKS5 认证(仅 no-auth) - - 不实现 UDP ASSOCIATE - - 不使用 ChannelId - - **Recommended Agent Profile**: - - **Category**: `unspecified-high` - - Reason: SOCKS5 本地服务器 + SSH3 通道桥接,模式清晰但需细心处理 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖 T22) - - **Parallel Group**: Wave 6 (after T22, parallel with T23) - - **Blocks**: Task 25 - - **Blocked By**: Task 22, Task 21 (服务端 SOCKS5) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-server/src/forward/socks5.rs` (Task 21) — SOCKS5 协议解析逻辑复用 - - `genmeta-ssh3-client/src/forward.rs` (Task 23) — direct_tcp_forward 接口复用 - - **API/Type References**: - - RFC 1928 — SOCKS5 协议格式 - - **WHY Each Reference Matters**: - - Task 21 (socks5.rs): 服务端 SOCKS5 解析逻辑可部分复用于客户端本地 SOCKS5 服务器 - - Task 23 (forward.rs): 客户端 direct_tcp_forward 接口用于将 SOCKS5 请求转发到服务端 - - **File Boundary**: 只可修改 `genmeta-ssh3-client/src/socks5.rs`、`genmeta-ssh3-client/src/lib.rs` - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-client -- socks5` 通过 - - [ ] 本地 SOCKS5 服务器监听指定端口 - - [ ] SOCKS5 CONNECT 请求正确解析目标地址 - - [ ] 通过 direct-tcpip channel 转发到服务端 - - [ ] 双向数据传输正常 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Local SOCKS5 proxy forwards via SSH3 tunnel - Tool: Bash - Preconditions: socks5.rs; genmeta-ssh3-server + genmeta-ssh3-client running; TCP echo server at remote - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- socks5::tests::socks5_proxy_forward` - 2. Verify: local SOCKS5 server accepts connection on 127.0.0.1:1080 - 3. Verify: SOCKS5 CONNECT request parsed correctly - 4. Verify: direct-tcpip channel opened to destination - 5. Verify: data relayed: local → SOCKS5 → SSH3 → remote - Expected Result: End-to-end data flow through SOCKS5 + SSH3 tunnel - Failure Indicators: SOCKS5 negotiation fails, channel not opened, or data lost - Evidence: .sisyphus/evidence/task-24-socks5-proxy.txt - - Scenario: SOCKS5 proxy handles connection failure - Tool: Bash - Preconditions: socks5.rs; server running; no echo server at remote - Steps: - 1. Run `cargo test -p genmeta-ssh3-client -- socks5::tests::socks5_proxy_connect_fail` - 2. Verify: direct-tcpip channel → ChannelOpenFailure(92) from server - 3. Verify: SOCKS5 connection refused reply (REP=0x05) sent to local client - Expected Result: Failure propagated correctly from SSH3 to SOCKS5 - Failure Indicators: Local SOCKS5 client receives success, or hang - Evidence: .sisyphus/evidence/task-24-socks5-connect-fail.txt - ``` - - **Commit**: YES - - Message: `feat(ssh3-client): implement local SOCKS5 proxy` - - Files: `genmeta-ssh3-client/src/socks5.rs`, `genmeta-ssh3-client/src/lib.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-client -- socks5` - -- [ ] 25. 完整 E2E 集成测试 - - **What to do**: - - 在 `genmeta-ssh3-server/tests/e2e.rs` 中扩展完整端到端测试(复用 Task 10 的测试基础设施,添加 genmeta-ssh3-client 为 dev-dependency): - - 测试基础设施: - - 启动 genmeta-ssh3-server 实例(使用自签名证书,随机端口) - - 创建 genmeta-ssh3-client 实例连接到服务端 - - 测试结束后自动清理 - - E2E 测试用例: - - `test_basic_exec`: 客户端连接 → Basic auth → 打开 session → exec "echo hello" → 收到 "hello\n" + exit_status=0 - - `test_exec_with_stderr`: exec command 产生 stderr → ChannelExtendedData(95) 正确分离 - - `test_shell_interactive`: 打开 shell → 发送命令 → 收到输出 → 发送 exit - - `test_direct_tcp_forward`: direct-tcpip 通道 → 原始字节流转发 → 验证无 ChannelData 包装 - - `test_reverse_tcp_forward`: tcpip-forward global request → 服务端主动开通道 → 原始字节流 - - `test_auth_failure`: 错误密码 → 401 → 客户端错误处理 - - `test_multiple_channels`: 同时打开多个 session 通道,验证各通道独立工作 - - `test_wire_format_compliance`: 拦截实际网络数据,验证无 CBOR、所有整数为 QUIC varint、消息类型正确 - - 确保测试不依赖外部服务,完全自包含 - - **Must NOT do**: - - 不使用 CBOR — 验证线上格式为 SSH binary + QUIC varint - - 不使用 ChannelId — 验证无 channel number 存在 - - 不依赖外部 SSH/PAM 服务 — 测试必须自包含 - - **Recommended Agent Profile**: - - **Category**: `deep` - - Reason: E2E 测试涉及客户端+服务端完整交互,需要深度理解整个协议栈 - - **Skills**: [] - - **Parallelization**: - - **Can Run In Parallel**: NO(依赖所有前置任务) - - **Parallel Group**: Wave 6 (final, after all other tasks) - - **Blocks**: Final Verification Wave - - **Blocked By**: Tasks 17, 18, 19, 20, 21, 22, 23, 24 (全部实现任务) - - **References**: - - **Pattern References**: - - `genmeta-ssh3-client/src/lib.rs` (Task 22) — 客户端连接接口 - - `genmeta-ssh3-client/src/session.rs` (Task 23) — 客户端会话接口 - - `genmeta-ssh3-server/src/handler.rs` (Task 9) — 服务端 Extended CONNECT 处理 - - `genmeta-ssh3-proto/src/codec.rs` (Task 2) — wire format 编解码用于拦截验证 - - **API/Type References**: - - 所有 SshMessage 类型常量(Task 5)— 验证线上格式 - - **WHY Each Reference Matters**: - - Task 22+23: E2E 测试使用客户端公开 API,必须了解接口设计 - - Task 2 (codec.rs): wire format 合规性测试需要直接检查字节序列 - - **File Boundary**: 只可修改 `genmeta-ssh3-server/tests/e2e.rs`、`genmeta-ssh3-server/tests/common/mod.rs`(测试基础设施) - - **Acceptance Criteria**: - - [ ] `cargo test -p genmeta-ssh3-server --test e2e` 全部通过 - - [ ] test_basic_exec: 收到 "hello\n" + exit_status=0 - - [ ] test_direct_tcp_forward: 原始字节流数据传输,无 ChannelData 包装 - - [ ] test_auth_failure: 401 正确处理 - - [ ] test_wire_format_compliance: 无 CBOR、所有整数 QUIC varint、无 ChannelId - - [ ] test_multiple_channels: 多通道独立工作 - - [ ] `grep -r "cbor\|ciborium\|ChannelId\|ChannelOpen(\|ChannelWindowAdjust" --include="*.rs" .` → 无匹配 - - **QA Scenarios (MANDATORY):** - ``` - Scenario: Full E2E exec flow - Tool: Bash - Preconditions: All crates built; no external services needed - Steps: - 1. Run `cargo test -p genmeta-ssh3-server --test e2e -- test_basic_exec --nocapture` - 2. Verify: server starts on random port - 3. Verify: client connects with Basic auth - 4. Verify: exec "echo hello" returns "hello\n" via ChannelData(94) - 5. Verify: exit_status=0 via exit-status ChannelRequest - 6. Verify: channel closes cleanly (Eof+Close) - Expected Result: Complete SSH3 exec lifecycle working end-to-end - Failure Indicators: Any step fails, wrong output, or resource leak - Evidence: .sisyphus/evidence/task-25-e2e-basic-exec.txt - - Scenario: Wire format compliance check - Tool: Bash - Preconditions: All crates built - Steps: - 1. Run `cargo test -p genmeta-ssh3-server --test e2e -- test_wire_format_compliance --nocapture` - 2. Verify: intercepted wire data contains NO CBOR markers (0xbf, 0xff, 0xa1) - 3. Verify: all integer fields are QUIC varint encoded - 4. Verify: string fields use QUIC varint length prefix + UTF-8 bytes - 5. Verify: boolean fields are single raw byte (0x00/0x01) - 6. Verify: message type tags are QUIC varint - Expected Result: Wire format is 100% SSH binary + QUIC varint, zero CBOR - Failure Indicators: Any CBOR byte pattern detected, or non-varint integers - Evidence: .sisyphus/evidence/task-25-wire-format-compliance.txt - - Scenario: Multiple concurrent channels - Tool: Bash - Preconditions: All crates built - Steps: - 1. Run `cargo test -p genmeta-ssh3-server --test e2e -- test_multiple_channels --nocapture` - 2. Verify: 3 session channels opened simultaneously (3 separate QUIC bidi streams) - 3. Verify: each channel independently executes a command and receives output - 4. Verify: no data cross-contamination between channels - 5. Verify: all 3 channels close cleanly - Expected Result: Channels are fully independent via QUIC streams - Failure Indicators: Data from one channel appears in another, or deadlock - Evidence: .sisyphus/evidence/task-25-multiple-channels.txt - ``` - - **Commit**: YES - - Message: `test(ssh3): add complete E2E integration tests` - - Files: `genmeta-ssh3-server/tests/e2e.rs`, `genmeta-ssh3-server/tests/common/mod.rs` - - Pre-commit: `cargo test -p genmeta-ssh3-server --test e2e` - ---- - -## Final Verification Wave (MANDATORY — after ALL implementation tasks) - -> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run. - -- [ ] F1. **Plan Compliance Audit** — `oracle` - Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. **特别检查**: 无 CBOR 引用、无 ChannelId、无 ChannelOpen(90)、无 ChannelWindowAdjust(93)。 - Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - -- [ ] F2. **Code Quality Review** — `unspecified-high` - Run `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo test --workspace`. Review all changed files for: `as any`/`@ts-ignore` (Rust equivalents: `as _`, unsafe without justification), empty catches, println!/dbg! in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp). - Output: `Build [PASS/FAIL] | Clippy [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT` - -- [ ] F3. **Real Manual QA** — `unspecified-high` - Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to `.sisyphus/evidence/final-qa/`. - Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - -- [ ] F4. **Scope Fidelity Check** — `deep` - For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. **特别检查**: 无任何 CBOR 代码残留、ChannelRequest type=98 not 95。 - Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT` - ---- - -## Commit Strategy - -- **Wave 1**: `feat(ssh3): reset worktree and create greenfield crate scaffolding` — Cargo.toml, src/lib.rs files -- **Wave 1**: `feat(ssh3-proto): implement SSH binary wire format codec with QUIC varint encoding` — codec.rs -- **Wave 1**: `feat(ssh3-proto): define snafu error model and AuthCredential` — error.rs, session.rs -- **Wave 2**: `feat(ssh3-proto): implement Conversation trait with Local/Remote variants` — conversation.rs -- **Wave 2**: `feat(ssh3-proto): define complete SshMessage enum with SSH binary codec` — message.rs -- **Wave 2**: `feat(ssh3-server): implement Ssh3Protocol for h3x Protocol trait` — protocol.rs -- **Wave 3**: `feat(ssh3-server): implement version negotiation and auth parsing` — auth.rs, version.rs -- **Wave 3**: `feat(ssh3-server): implement Extended CONNECT handler` — handler.rs -- **Wave 4**: `feat(ssh3-proto): define SshSession RTC trait` — session.rs -- **Wave 4**: `feat(ssh3-server): implement PAM wrapper` — auth/pam.rs -- **Wave 4**: `feat(ssh3-server): implement ssh3-session child process binary` — bin/ssh3-session.rs -- **Wave 5**: `feat(ssh3-proto): implement channel lifecycle (open/confirm/data/eof/close)` — channel.rs -- **Wave 5**: `feat(ssh3-server): implement exec/shell/pty request handling` — session/ -- **Wave 5**: `feat(ssh3): implement TCP forwarding with raw byte streams` — forward/direct_tcp.rs, reverse_tcp.rs, streamlocal.rs -- **Wave 5**: `feat(ssh3-server): implement SOCKS5 proxy` — forward/socks5.rs -- **Wave 6**: `feat(ssh3-client): implement SSH3 client connection and auth` — client lib -- **Wave 6**: `feat(ssh3-client): implement session and forwarding requests` — session.rs, forward.rs -- **Wave 6**: `feat(ssh3-client): implement local SOCKS5 proxy` — socks5.rs -- **Wave 6**: `test(ssh3): add complete E2E integration tests` — tests/ - ---- - -## Success Criteria - -### Verification Commands -```bash -cargo build --workspace # Expected: success -cargo test --workspace # Expected: all tests pass -cargo clippy --workspace -- -D warnings # Expected: no warnings -# E2E test -cargo test -p genmeta-ssh3-server --test e2e -- basic_exec # Expected: "hello\n" received -# Wire format verification -cargo test -p genmeta-ssh3-proto -- wire_format # Expected: hex dumps match Go reference -# No CBOR anywhere -grep -r "cbor\|ciborium\|serde_cbor" --include="*.rs" . # Expected: no matches -# No ChannelId anywhere -grep -r "ChannelId" --include="*.rs" . # Expected: no matches -# No ChannelOpen(90) or ChannelWindowAdjust(93) -grep -r "ChannelOpen\|ChannelWindowAdjust" --include="*.rs" . # Expected: no matches -``` - -### Final Checklist -- [ ] All "Must Have" present -- [ ] All "Must NOT Have" absent -- [ ] All tests pass -- [ ] Wire format hex dumps match Go reference implementation -- [ ] No CBOR/ciborium references in codebase -- [ ] No ChannelId type defined -- [ ] No ChannelOpen(90) or ChannelWindowAdjust(93) message types -- [ ] ChannelRequest type value = 98 (not 95) -- [ ] TCP forwarding uses raw byte streams -- [ ] Session channels use SSH_MSG_CHANNEL_DATA(94) wrapping diff --git a/Cargo.toml b/Cargo.toml index f3532ae..c59d358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,20 +56,5 @@ cli = ["dep:peg"] config = ["dep:peg"] [dev-dependencies] -clap = { version = "4", features = ["derive"] } -ring = "0.17" -rustls = { version = "0.23", default-features = false, features = [ - "ring", - "logging", - "std", -] } serde_json = "1" -smallvec = "1" tempfile = "3" -tower-service = "0.3" -tokio-util = "0.7" -tracing-subscriber = "0.3" - -[[example]] -name = "ssh3-session" -required-features = ["server"] diff --git a/examples/ssh3-session.rs b/examples/ssh3-session.rs deleted file mode 100644 index 7d95c97..0000000 --- a/examples/ssh3-session.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! SSH3 session child process example. -//! -//! Spawned by the SSH3 server (ssh3-server example) for each connection. -//! Communicates with the parent via remoc RPC over a MuxChannel socketpair -//! received on stdin (FD 0). -//! -//! Flow: -//! 1. Send `AuthenticateFn` to parent over remoc -//! 2. Parent calls it with `AuthRequest` → child runs PAM authentication -//! 3. On success, return `StartSessionFn` to parent -//! 4. Parent calls it with `SessionBootstrap` → child drops privileges -//! and runs the session dispatcher -//! -//! Stream data travels through FD-passed Unix socketpairs, not through -//! remoc serialization. - -use std::sync::Arc; - -use dssh::{ - auth::AuthCredential, - conversation::Conversation, - session::{ - AuthError, AuthRequest, SessionBootstrap, SessionRunError, StartSessionFn, UserInfo, - dispatcher::{SessionConfig, run_session}, - privilege::drop_privileges, - }, -}; -use h3x::ipc::transport::MuxChannel; -use snafu::Report; -use tokio_util::task::AbortOnDropHandle; -use tracing::Instrument; - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .init(); - - // Recover the MuxChannel FD from stdin (passed by the parent process). - let mux_fd = { - use std::os::fd::FromRawFd; - // SAFETY: the parent process passed the socketpair FD as our stdin - // (FD 0) via tokio::process::Command. - unsafe { std::os::fd::OwnedFd::from_raw_fd(0) } - }; - - let mux = MuxChannel::from_fd(mux_fd).expect("failed to create MuxChannel from stdin"); - let (sink, stream) = mux.split().expect("failed to split MuxChannel"); - - // Capture FD registry before remoc consumes the stream. - let fd_registry = stream.fd_registry(); - - // Establish remoc channel over MuxSink/MuxStream. - let (conn, mut tx, _rx) = - remoc::Connect::framed::<_, _, dssh::session::AuthenticateFn, (), remoc::codec::Default>( - remoc::Cfg::default(), - sink, - stream, - ) - .await - .expect("failed to establish remoc channel"); - let conn_handle = AbortOnDropHandle::new(tokio::spawn( - conn.instrument(tracing::info_span!("remoc_conn")), - )); - - // Create the outer RFnOnce: authentication. - let auth_fn = remoc::rfn::RFnOnce::new_1(|auth_request: AuthRequest| async move { - tracing::info!( - username = %auth_request.username, - credential = %auth_request.credential, - "authentication starting" - ); - - let user_info: UserInfo = match &auth_request.credential { - AuthCredential::Basic { .. } => { - return Err(AuthError::PamFailed { - reason: "password authentication is no longer supported".to_owned(), - }); - } - #[cfg(feature = "pam")] - AuthCredential::Certificate => { - dssh::session::pam::open_session("sshd", &auth_request.username) - .await - .map_err(|e| AuthError::PamFailed { - reason: Report::from_error(e).to_string(), - })? - } - #[cfg(not(feature = "pam"))] - AuthCredential::Certificate => { - let user_info = dssh::session::lookup_user(&auth_request.username) - .await - .map_err(|e| AuthError::PamFailed { - reason: Report::from_error(e).to_string(), - })?; - if let Err(msg) = dssh::session::check_nologin(user_info.uid) { - return Err(AuthError::PamFailed { reason: msg }); - } - user_info - } - }; - - tracing::info!( - uid = user_info.uid, - gid = user_info.gid, - "authentication succeeded" - ); - - let username = auth_request.username; - - // Create the inner RFnOnce: drop privileges + run session. - let start_session_fn: StartSessionFn = - remoc::rfn::RFnOnce::new_1(move |bootstrap: SessionBootstrap| async move { - tracing::info!(%username, "starting session"); - - if nix::unistd::getuid().is_root() { - drop_privileges(user_info.uid, user_info.gid, &username).map_err(|e| { - SessionRunError::DropPrivileges { - reason: Report::from_error(e).to_string(), - } - })?; - tracing::info!( - uid = user_info.uid, - gid = user_info.gid, - "privileges dropped" - ); - } - - // Resolve control stream from FD registry. - let fds = fd_registry - .wait_fds(bootstrap.control_fd_id) - .await - .map_err(|e| SessionRunError::ConversationBuild { - reason: Report::from_error(e).to_string(), - })?; - let ctrl_fd = - fds.into_iter() - .next() - .ok_or_else(|| SessionRunError::ConversationBuild { - reason: "expected 1 FD for control stream, got 0".into(), - })?; - let ctrl_unix = - tokio::net::UnixStream::from_std(std::os::unix::net::UnixStream::from(ctrl_fd)) - .map_err(|e| SessionRunError::ConversationBuild { - reason: format!("failed to convert control FD to tokio stream: {e}"), - })?; - let (control_reader, control_writer) = ctrl_unix.into_split(); - - // Create IPC manage stream handle. - let manage_stream = dssh::conversation::ipc::IpcManageStreamHandle::new( - bootstrap.manage_stream, - fd_registry, - ); - - let conversation = Arc::new(Conversation::new( - bootstrap.conversation_id, - bootstrap.peer_version, - control_reader, - control_writer, - manage_stream, - )); - - let config = SessionConfig { - user: user_info, - ..Default::default() - }; - - tracing::info!("session dispatcher starting"); - run_session(conversation, config).await; - tracing::info!("session ended"); - Ok(()) - }); - - Ok(start_session_fn) - }); - - tx.send(auth_fn) - .await - .expect("failed to send AuthenticateFn to parent"); - - let _ = conn_handle.await; - tracing::info!("ssh session process exiting"); -} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile deleted file mode 100644 index f19b635..0000000 --- a/tests/docker/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# Dockerfile for SSH3 integration tests. -# -# Expects the build context directory to contain: -# ssh3-server, ssh3-client, ssh3-session (pre-built binaries) -# run_tests.sh (test harness) -# -# The Rust test driver (`docker_integration.rs`) prepares this context by -# compiling binaries inside a `rust:1-bookworm` container (to match the -# runtime glibc), then assembles the build context. -# -# Usage (handled automatically by the test driver): -# docker run --rm ssh3-test - -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - openssl \ - ca-certificates \ - libpam0g \ - socat \ - netcat-openbsd \ - && rm -rf /var/lib/apt/lists/* - -# Create a test user for PAM authentication tests. -RUN useradd -m -s /bin/sh testuser \ - && echo 'testuser:testpass' | chpasswd - -COPY ssh3-server /usr/local/bin/ssh3-server -COPY ssh3-client /usr/local/bin/ssh3-client -COPY ssh3-session /usr/local/bin/ssh3-session -COPY run_tests.sh /run_tests.sh - -RUN chmod +x /run_tests.sh /usr/local/bin/* - -ENV RUST_LOG=info - -ENTRYPOINT ["/run_tests.sh"] diff --git a/tests/docker/run_tests.sh b/tests/docker/run_tests.sh deleted file mode 100644 index 27b090a..0000000 --- a/tests/docker/run_tests.sh +++ /dev/null @@ -1,565 +0,0 @@ -#!/bin/bash -# SSH3 Docker integration test runner. -# -# Generates TLS certificates, starts an ssh3-server, and runs test scenarios -# against it with ssh3-client. Prints TAP (Test Anything Protocol) output -# so the Rust test driver can parse results. -# -# Exit code: 0 if all tests pass, 1 otherwise. - -set -euo pipefail - -CERT_DIR=/tmp/certs -SERVER_ADDR="127.0.0.1" -SERVER_PORT="8443" -# Client connects via "localhost" to match the server's TLS certificate name. -CLIENT_AUTHORITY="localhost:${SERVER_PORT}" -SERVER_PID="" -PASS=0 -FAIL=0 -TEST_NUM=0 - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -cleanup() { - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -generate_certs() { - mkdir -p "$CERT_DIR" - openssl ecparam -name prime256v1 -genkey -noout \ - -out "$CERT_DIR/server.key" 2>/dev/null - openssl req -new -x509 -days 1 \ - -key "$CERT_DIR/server.key" \ - -sha256 \ - -out "$CERT_DIR/server.crt" \ - -subj "/CN=localhost" 2>/dev/null - echo "# Certificates generated in $CERT_DIR" -} - -start_server() { - ssh3-server "$CERT_DIR/server.crt" "$CERT_DIR/server.key" \ - --bind "${SERVER_ADDR}:${SERVER_PORT}" \ - --session-binary /usr/local/bin/ssh3-session 2>/tmp/server.log & - SERVER_PID=$! - - # Wait for the server to be ready (up to 5 seconds). - local retries=50 - while [ $retries -gt 0 ]; do - if kill -0 "$SERVER_PID" 2>/dev/null; then - # Server process alive; give it a moment to bind. - sleep 0.1 - retries=$((retries - 1)) - else - echo "# Server exited prematurely" - return 1 - fi - done - # Give a final settling pause. - sleep 0.5 - echo "# Server started (PID=$SERVER_PID) on ${SERVER_ADDR}:${SERVER_PORT}" -} - -stop_server() { - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - SERVER_PID="" - fi - # Dump server log for diagnostics. - if [ -f /tmp/server.log ]; then - echo "# --- server log ---" - tail -100 /tmp/server.log | sed 's/^/# /' - echo "# --- end server log ---" - rm -f /tmp/server.log - fi -} - -# Run a test case. Arguments: -# $1 - test name -# $2 - expected exit code -# $3 - expected stdout substring (empty string = skip check) -# $4+ - client arguments -run_test() { - local name="$1"; shift - local expected_exit="$1"; shift - local expected_stdout="$1"; shift - - TEST_NUM=$((TEST_NUM + 1)) - - local actual_stdout="" - local actual_exit=0 - - # Run client with a 10-second timeout. Capture stderr for diagnostics. - local tmpstderr - tmpstderr=$(mktemp) - actual_stdout=$(timeout 10 "$@" 2>"$tmpstderr") || actual_exit=$? - local actual_stderr - actual_stderr=$(cat "$tmpstderr") - rm -f "$tmpstderr" - - # timeout(1) returns 124 on timeout. - if [ "$actual_exit" -eq 124 ]; then - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - $name (timed out after 10s)" - [ -n "$actual_stderr" ] && echo "# client stderr: $actual_stderr" - return - fi - - local ok=true - - # Check exit code. - if [ "$actual_exit" -ne "$expected_exit" ]; then - ok=false - fi - - # Check stdout substring. - if [ -n "$expected_stdout" ]; then - if ! echo "$actual_stdout" | grep -qF "$expected_stdout"; then - ok=false - fi - fi - - if $ok; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - $name" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - $name" - echo "# expected exit=$expected_exit got=$actual_exit" - if [ -n "$expected_stdout" ]; then - echo "# expected stdout to contain: $expected_stdout" - echo "# actual stdout: $actual_stdout" - fi - fi -} - -# --------------------------------------------------------------------------- -# Test scenarios -# --------------------------------------------------------------------------- - -run_session_tests() { - # 1. exec echo - run_test "exec echo" 0 "hello" \ - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass "echo hello" - - # 2. exec exit code - run_test "exec exit code 42" 42 "" \ - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass "exit 42" - - # 3. exec cat with stdin - run_test "exec cat stdin" 0 "inputdata" \ - sh -c 'echo inputdata | ssh3-client '"$CLIENT_AUTHORITY"' -u testuser -p testpass cat' - - # 4. exec stderr (capture stderr too) - # For this test, we check exit code only since stderr goes to fd 2. - run_test "exec stderr" 0 "" \ - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass "echo err >&2" -} - -run_pam_tests() { - # 5. PAM correct credentials — whoami should return testuser - run_test "pam auth correct" 0 "testuser" \ - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass "whoami" - - # 6. PAM wrong password — should fail (non-zero exit) - run_test "pam auth wrong password" 101 "" \ - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p wrongpass "whoami" - - # 7. PAM non-existent user — should fail (non-zero exit) - run_test "pam auth no such user" 101 "" \ - ssh3-client "$CLIENT_AUTHORITY" -u nobody99 -p x "whoami" -} - -run_forward_tests() { - # Start echo servers on multiple ports (reflect input back). - socat TCP-LISTEN:9999,reuseaddr,fork EXEC:cat & - local ECHO_PID1=$! - socat TCP-LISTEN:9998,reuseaddr,fork EXEC:cat & - local ECHO_PID2=$! - sleep 0.3 - - # 8. Local forward (-L): client binds 8888 → server connects to 127.0.0.1:9999. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8888:127.0.0.1:9999 "sleep 10" & - local FWD_PID=$! - sleep 1 - - local fwd_result - fwd_result=$(echo "hello-forward" | timeout 5 nc -q1 127.0.0.1 8888 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$fwd_result" = "hello-forward" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - local forward (-L)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - local forward (-L)" - echo "# expected: hello-forward" - echo "# got: $fwd_result" - fi - - kill "$FWD_PID" 2>/dev/null; wait "$FWD_PID" 2>/dev/null || true - - # 9. Remote forward (-R): client asks server to listen on 7777 → client connects to 127.0.0.1:9999. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -R 7777:127.0.0.1:9999 "sleep 10" & - local RFWD_PID=$! - sleep 1 - - local rfwd_result - rfwd_result=$(echo "hello-reverse" | timeout 5 nc -q1 127.0.0.1 7777 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$rfwd_result" = "hello-reverse" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - remote forward (-R)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - remote forward (-R)" - echo "# expected: hello-reverse" - echo "# got: $rfwd_result" - fi - - kill "$RFWD_PID" 2>/dev/null; wait "$RFWD_PID" 2>/dev/null || true - - # 10. Multiple local forwards: two -L on one connection. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8881:127.0.0.1:9999 -L 8882:127.0.0.1:9998 "sleep 10" & - local MULTI_L_PID=$! - sleep 1 - - local ml_result1 ml_result2 - ml_result1=$(echo "multi-L-1" | timeout 5 nc -q1 127.0.0.1 8881 2>/dev/null) || true - ml_result2=$(echo "multi-L-2" | timeout 5 nc -q1 127.0.0.1 8882 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$ml_result1" = "multi-L-1" ] && [ "$ml_result2" = "multi-L-2" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - multiple local forwards (-L -L)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - multiple local forwards (-L -L)" - echo "# port 8881: expected 'multi-L-1', got '$ml_result1'" - echo "# port 8882: expected 'multi-L-2', got '$ml_result2'" - fi - - kill "$MULTI_L_PID" 2>/dev/null; wait "$MULTI_L_PID" 2>/dev/null || true - - # 11. Multiple remote forwards: two -R on one connection. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -R 7771:127.0.0.1:9999 -R 7772:127.0.0.1:9998 "sleep 10" & - local MULTI_R_PID=$! - sleep 1 - - local mr_result1 mr_result2 - mr_result1=$(echo "multi-R-1" | timeout 5 nc -q1 127.0.0.1 7771 2>/dev/null) || true - mr_result2=$(echo "multi-R-2" | timeout 5 nc -q1 127.0.0.1 7772 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$mr_result1" = "multi-R-1" ] && [ "$mr_result2" = "multi-R-2" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - multiple remote forwards (-R -R)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - multiple remote forwards (-R -R)" - echo "# port 7771: expected 'multi-R-1', got '$mr_result1'" - echo "# port 7772: expected 'multi-R-2', got '$mr_result2'" - fi - - kill "$MULTI_R_PID" 2>/dev/null; wait "$MULTI_R_PID" 2>/dev/null || true - - # 12. Combined -L and -R on the same connection. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8883:127.0.0.1:9999 -R 7773:127.0.0.1:9998 "sleep 10" & - local COMBO_PID=$! - sleep 1 - - local combo_l combo_r - combo_l=$(echo "combo-L" | timeout 5 nc -q1 127.0.0.1 8883 2>/dev/null) || true - combo_r=$(echo "combo-R" | timeout 5 nc -q1 127.0.0.1 7773 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$combo_l" = "combo-L" ] && [ "$combo_r" = "combo-R" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - combined local+remote forward (-L -R)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - combined local+remote forward (-L -R)" - echo "# -L port 8883: expected 'combo-L', got '$combo_l'" - echo "# -R port 7773: expected 'combo-R', got '$combo_r'" - fi - - kill "$COMBO_PID" 2>/dev/null; wait "$COMBO_PID" 2>/dev/null || true - - # 13. Concurrent connections through the same local forward. - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8884:127.0.0.1:9999 "sleep 10" & - local CONC_PID=$! - sleep 1 - - local conc1 conc2 conc3 - # Fire 3 connections in parallel. - conc1=$(echo "conn-1" | timeout 5 nc -q1 127.0.0.1 8884 2>/dev/null) & - local C1=$! - conc2=$(echo "conn-2" | timeout 5 nc -q1 127.0.0.1 8884 2>/dev/null) & - local C2=$! - conc3=$(echo "conn-3" | timeout 5 nc -q1 127.0.0.1 8884 2>/dev/null) & - local C3=$! - wait "$C1" 2>/dev/null; conc1=$(echo "conn-1" | timeout 5 nc -q1 127.0.0.1 8884 2>/dev/null) || true - wait "$C2" 2>/dev/null || true - wait "$C3" 2>/dev/null || true - - # Re-run sequentially (subshell capture in bg is unreliable). - local conc_ok=true - for i in 1 2 3; do - local cr - cr=$(echo "seq-$i" | timeout 5 nc -q1 127.0.0.1 8884 2>/dev/null) || true - if [ "$cr" != "seq-$i" ]; then - conc_ok=false - break - fi - done - - TEST_NUM=$((TEST_NUM + 1)) - if $conc_ok; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - concurrent connections through forward" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - concurrent connections through forward" - fi - - kill "$CONC_PID" 2>/dev/null; wait "$CONC_PID" 2>/dev/null || true - - # 14. Large data transfer through local forward (128KB). - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8885:127.0.0.1:9999 "sleep 10" & - local LARGE_PID=$! - sleep 1 - - local expected_md5 actual_md5 - dd if=/dev/urandom bs=1024 count=128 of=/tmp/testdata 2>/dev/null - expected_md5=$(md5sum /tmp/testdata | awk '{print $1}') - actual_md5=$(timeout 10 nc -q1 127.0.0.1 8885 < /tmp/testdata 2>/dev/null | md5sum | awk '{print $1}') || true - rm -f /tmp/testdata - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$expected_md5" = "$actual_md5" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - large data (128KB) through forward" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - large data (128KB) through forward" - echo "# expected md5: $expected_md5" - echo "# actual md5: $actual_md5" - fi - - kill "$LARGE_PID" 2>/dev/null; wait "$LARGE_PID" 2>/dev/null || true - - # 15. Multiple concurrent client sessions (server handles parallel connections). - local pids=() - local results=() - for i in 1 2 3; do - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass "echo session-$i" \ - >/tmp/session_result_$i 2>/dev/null & - pids+=($!) - done - for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true - done - local multi_ok=true - for i in 1 2 3; do - local content - content=$(cat /tmp/session_result_$i 2>/dev/null) || true - if [ "$content" != "session-$i" ]; then - multi_ok=false - fi - rm -f /tmp/session_result_$i - done - - TEST_NUM=$((TEST_NUM + 1)) - if $multi_ok; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - multiple concurrent client sessions" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - multiple concurrent client sessions" - echo "# expected each session to return its own 'session-N'" - fi - - # Cleanup echo servers. - kill "$ECHO_PID1" 2>/dev/null; wait "$ECHO_PID1" 2>/dev/null || true - kill "$ECHO_PID2" 2>/dev/null; wait "$ECHO_PID2" 2>/dev/null || true -} - -run_unix_socket_tests() { - # Start a Unix domain socket echo server. - local ECHO_SOCK="/tmp/echo.sock" - rm -f "$ECHO_SOCK" - socat UNIX-LISTEN:"$ECHO_SOCK",reuseaddr,fork,mode=0666 EXEC:cat & - local ECHO_PID=$! - sleep 0.3 - - # 16. Local forward: TCP bind → Unix socket connect (-L port:/path). - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L 8886:/tmp/echo.sock "sleep 10" & - local TCP2UNIX_PID=$! - sleep 1 - - local t2u_result - t2u_result=$(echo "tcp-to-unix" | timeout 5 nc -q1 127.0.0.1 8886 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$t2u_result" = "tcp-to-unix" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - local forward TCP→Unix (-L port:/path)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - local forward TCP→Unix (-L port:/path)" - echo "# expected: tcp-to-unix" - echo "# got: $t2u_result" - fi - - kill "$TCP2UNIX_PID" 2>/dev/null; wait "$TCP2UNIX_PID" 2>/dev/null || true - - # 17. Local forward: Unix socket bind → TCP connect (-L /path:host:port). - # Start TCP echo server on port 9997. - socat TCP-LISTEN:9997,reuseaddr,fork EXEC:cat & - local TCP_ECHO_PID=$! - sleep 0.3 - - local CLIENT_SOCK="/tmp/client_fwd.sock" - rm -f "$CLIENT_SOCK" - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L "$CLIENT_SOCK":127.0.0.1:9997 "sleep 10" & - local UNIX2TCP_PID=$! - sleep 1 - - local u2t_result - u2t_result=$(echo "unix-to-tcp" | timeout 5 socat - UNIX-CONNECT:"$CLIENT_SOCK" 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$u2t_result" = "unix-to-tcp" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - local forward Unix→TCP (-L /path:host:port)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - local forward Unix→TCP (-L /path:host:port)" - echo "# expected: unix-to-tcp" - echo "# got: $u2t_result" - fi - - kill "$UNIX2TCP_PID" 2>/dev/null; wait "$UNIX2TCP_PID" 2>/dev/null || true - kill "$TCP_ECHO_PID" 2>/dev/null; wait "$TCP_ECHO_PID" 2>/dev/null || true - rm -f "$CLIENT_SOCK" - - # 18. Local forward: Unix socket bind → Unix socket connect (-L /local:/remote). - local CLIENT_SOCK2="/tmp/client_fwd2.sock" - rm -f "$CLIENT_SOCK2" - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -L "$CLIENT_SOCK2":/tmp/echo.sock "sleep 10" & - local UNIX2UNIX_PID=$! - sleep 1 - - local u2u_result - u2u_result=$(echo "unix-to-unix" | timeout 5 socat - UNIX-CONNECT:"$CLIENT_SOCK2" 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$u2u_result" = "unix-to-unix" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - local forward Unix→Unix (-L /local:/remote)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - local forward Unix→Unix (-L /local:/remote)" - echo "# expected: unix-to-unix" - echo "# got: $u2u_result" - fi - - kill "$UNIX2UNIX_PID" 2>/dev/null; wait "$UNIX2UNIX_PID" 2>/dev/null || true - rm -f "$CLIENT_SOCK2" - - # 19. Remote forward: Unix socket → TCP (-R /remote/path:host:port). - socat TCP-LISTEN:9996,reuseaddr,fork EXEC:cat & - local RTCP_ECHO_PID=$! - sleep 0.3 - - local REMOTE_SOCK="/tmp/remote_fwd.sock" - rm -f "$REMOTE_SOCK" - ssh3-client "$CLIENT_AUTHORITY" -u testuser -p testpass \ - -R "$REMOTE_SOCK":127.0.0.1:9996 "sleep 10" & - local RUNIX_PID=$! - sleep 1 - - local ru_result - ru_result=$(echo "remote-unix" | timeout 5 socat - UNIX-CONNECT:"$REMOTE_SOCK" 2>/dev/null) || true - - TEST_NUM=$((TEST_NUM + 1)) - if [ "$ru_result" = "remote-unix" ]; then - PASS=$((PASS + 1)) - echo "ok $TEST_NUM - remote forward Unix→TCP (-R /path:host:port)" - else - FAIL=$((FAIL + 1)) - echo "not ok $TEST_NUM - remote forward Unix→TCP (-R /path:host:port)" - echo "# expected: remote-unix" - echo "# got: $ru_result" - fi - - kill "$RUNIX_PID" 2>/dev/null; wait "$RUNIX_PID" 2>/dev/null || true - kill "$RTCP_ECHO_PID" 2>/dev/null; wait "$RTCP_ECHO_PID" 2>/dev/null || true - rm -f "$REMOTE_SOCK" - - # Cleanup. - kill "$ECHO_PID" 2>/dev/null; wait "$ECHO_PID" 2>/dev/null || true - rm -f "$ECHO_SOCK" -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -main() { - echo "# SSH3 integration tests" - - # Verify binaries have all required libraries. - echo "# Checking library dependencies..." - ldd /usr/local/bin/ssh3-server 2>&1 | grep "not found" && echo "# WARNING: ssh3-server missing libs" - ldd /usr/local/bin/ssh3-client 2>&1 | grep "not found" && echo "# WARNING: ssh3-client missing libs" - ldd /usr/local/bin/ssh3-session 2>&1 | grep "not found" && echo "# WARNING: ssh3-session missing libs" - - generate_certs - - echo "# --- Starting server (child-process mode) ---" - start_server - - echo "# --- Session tests ---" - run_session_tests - - echo "# --- PAM tests ---" - run_pam_tests - - echo "# --- Forwarding tests ---" - run_forward_tests - - echo "# --- Unix socket forwarding tests ---" - run_unix_socket_tests - - stop_server - - # Summary - local total=$((PASS + FAIL)) - echo "1..$total" - echo "# $PASS passed, $FAIL failed out of $total tests" - - if [ "$FAIL" -gt 0 ]; then - exit 1 - fi -} - -main "$@" diff --git a/tests/docker_integration.rs b/tests/docker_integration.rs deleted file mode 100644 index 7c1f055..0000000 --- a/tests/docker_integration.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Docker-based integration tests for SSH3. -//! -//! These tests compile the example binaries inside a `rust:1-bookworm` -//! container (to match the runtime glibc), assemble a Docker build context, -//! build a minimal runtime image, and run test scenarios inside it. -//! The container test script outputs TAP (Test Anything Protocol) which this -//! driver parses. -//! -//! Run with: -//! ```sh -//! cargo test --test docker_integration -- --ignored -//! ``` -//! -//! Prerequisites: -//! - Docker daemon running - -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::{env, fs}; - -const IMAGE_NAME: &str = "ssh3-integration-test"; - -/// Locate the repository root (where Cargo.toml lives). -fn repo_root() -> PathBuf { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - PathBuf::from(manifest_dir) -} - -/// Build example binaries inside a `rust:1-bookworm` Docker container. -/// -/// The workspace root (parent of dssh) is volume-mounted so that -/// path dependencies (h3x, gm-quic, etc.) are available. -/// -/// Returns the host path to the directory containing the built binaries. -fn build_examples_in_docker(repo: &Path) -> PathBuf { - let workspace_root = repo.parent().expect("repo has no parent directory"); - let repo_name = repo.file_name().unwrap().to_str().unwrap(); - - // Output directory on host for the built binaries. - let out_dir = repo.join("target").join("docker-build"); - fs::create_dir_all(&out_dir).expect("failed to create docker-build output dir"); - - // Prefer mounting the host cargo registry (read-only) so builds don't - // need network access. Fall back to named Docker volumes when the host - // registry directory does not exist. - let cargo_home = - env::var("CARGO_HOME").unwrap_or_else(|_| format!("{}/.cargo", env::var("HOME").unwrap())); - let host_registry = PathBuf::from(&cargo_home).join("registry"); - let use_host_registry = host_registry.is_dir(); - - let mut args: Vec = vec![ - "run".into(), - "--rm".into(), - // Mount workspace (read-only) so all path deps are available. - "-v".into(), - format!("{}:/workspace:ro", workspace_root.display()), - // Mount a writable overlay for the build output. - "-v".into(), - format!("{}:/output", out_dir.display()), - ]; - - if use_host_registry { - // Mount host cargo registry read-only to avoid network downloads. - args.extend([ - "-v".into(), - format!("{}:/usr/local/cargo/registry:ro", host_registry.display()), - ]); - } else { - // Fall back to named Docker volumes (requires network). - args.extend([ - "-v".into(), - "ssh3-test-cargo-registry:/usr/local/cargo/registry".into(), - ]); - } - - args.extend([ - "-v".into(), - "ssh3-test-cargo-target:/build-target".into(), - "-e".into(), - "CARGO_TARGET_DIR=/build-target".into(), - "-w".into(), - format!("/workspace/{repo_name}"), - "rust:1-bookworm".into(), - "sh".into(), - "-c".into(), - // Copy source (excluding target/ and .git/ dirs) to a writable - // location, build, and copy binaries out. Uses tar with --exclude - // to avoid installing extra packages. - format!( - "cd /workspace && \ - tar cf - --exclude='target' --exclude='.git' . | (mkdir -p /build && cd /build && tar xf -) && \ - apt-get update && apt-get install -y --no-install-recommends libpam0g-dev libclang-dev && \ - cd /build/{repo_name} && \ - cargo build --examples --features pam,cli && \ - cp /build-target/debug/examples/ssh3-server \ - /build-target/debug/examples/ssh3-client \ - /build-target/debug/examples/ssh3-session \ - /output/", - repo_name = repo_name, - ), - ]); - - // Build inside Docker, mounting the workspace and an output volume. - let status = Command::new("docker") - .args(&args) - .status() - .expect("failed to run docker for building examples"); - - assert!( - status.success(), - "docker build of examples failed (exit code: {status})" - ); - - out_dir -} - -/// Assemble the Docker build context and build the runtime image. -fn docker_build(repo: &Path, binaries_dir: &Path) { - let context_dir = repo.join("target").join("docker-context"); - let _ = fs::remove_dir_all(&context_dir); - fs::create_dir_all(&context_dir).expect("failed to create docker-context"); - - // Copy binaries into context. - for name in ["ssh3-server", "ssh3-client", "ssh3-session"] { - fs::copy(binaries_dir.join(name), context_dir.join(name)) - .unwrap_or_else(|e| panic!("failed to copy {name}: {e}")); - } - - // Copy Dockerfile and test script into context. - fs::copy( - repo.join("tests/docker/Dockerfile"), - context_dir.join("Dockerfile"), - ) - .expect("failed to copy Dockerfile"); - fs::copy( - repo.join("tests/docker/run_tests.sh"), - context_dir.join("run_tests.sh"), - ) - .expect("failed to copy run_tests.sh"); - - let status = Command::new("docker") - .args(["build", "-t", IMAGE_NAME, "."]) - .current_dir(&context_dir) - .status() - .expect("failed to run docker build"); - - assert!(status.success(), "docker build failed"); -} - -/// Run the Docker container and return its stdout. -fn docker_run() -> (String, bool) { - let output = Command::new("docker") - .args(["run", "--rm", IMAGE_NAME]) - .output() - .expect("failed to run docker run"); - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - if !stderr.is_empty() { - // Print container stderr in both stdout and stderr for visibility - // (cargo test captures stdout for failed tests, but may truncate stderr). - println!("--- container stderr (last 200 lines) ---"); - for line in stderr - .lines() - .rev() - .take(200) - .collect::>() - .into_iter() - .rev() - { - println!("{line}"); - } - println!("--- end container stderr ---"); - } - - (stdout, output.status.success()) -} - -/// Parse TAP output and assert all tests passed. -fn assert_tap_output(tap: &str) { - println!("--- TAP output ---\n{tap}--- end TAP ---"); - - let mut total_tests = 0; - let mut passed = 0; - let mut failed = 0; - - for line in tap.lines() { - let line = line.trim(); - if line.starts_with("ok ") { - passed += 1; - total_tests += 1; - } else if line.starts_with("not ok ") { - failed += 1; - total_tests += 1; - eprintln!("FAILED: {line}"); - } - } - - assert!(total_tests > 0, "no TAP test results found in output"); - assert_eq!(failed, 0, "{failed} test(s) failed out of {total_tests}"); - println!("{passed}/{total_tests} tests passed"); -} - -#[test] -#[ignore = "requires Docker daemon; slow (compiles in container)"] -fn docker_integration() { - let repo = repo_root(); - - eprintln!("Building example binaries inside Docker (rust:1-bookworm)..."); - let binaries_dir = build_examples_in_docker(&repo); - - eprintln!("Building runtime Docker image..."); - docker_build(&repo, &binaries_dir); - - eprintln!("Running integration tests in container..."); - let (tap_output, container_success) = docker_run(); - assert_tap_output(&tap_output); - assert!(container_success, "container exited with non-zero status"); -} From 0374cd2d0562c8763fe942efbc2fb7d352d2775f Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 3 Jun 2026 00:14:54 +0800 Subject: [PATCH 19/39] refactor(ipc): use receiver-chosen fd transfer --- src/conversation/ipc.rs | 167 ++++++++++++++++++++++++---------------- src/session/mod.rs | 31 ++++---- 2 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index 824651f..cd44de5 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -14,25 +14,27 @@ //! `open_stream` / `accept_stream` call: //! 1. Opens a real bidirectional stream through the wrapped manager. //! 2. Creates a Unix socketpair. -//! 3. Queues the client-side FD through the [`FdSender`]. +//! 3. Delivers the client-side FD through the [`FdTransfer`]. //! 4. Spawns bridge tasks forwarding data between the managed stream and the //! server-side socketpair half. //! 5. Returns the FD-registry batch ID over RPC. //! //! The child process receives an [`IpcManageSessionStreamClient`] and wraps it //! in [`IpcManageStreamHandle`], which implements [`ManageSessionStream`]: -//! 1. Calls the RPC method to get the FD-registry batch ID. -//! 2. Retrieves the socketpair FD from the [`FdRegistry`]. +//! 1. Reserves a receiver-chosen FD transfer ID. +//! 2. Calls the RPC method with that ID while concurrently receiving the FD. //! 3. Splits it into `(OwnedReadHalf, OwnedWriteHalf)`. +use std::future::IntoFuture; + use bytes::{Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use h3x::{ - ipc::transport::{FdRegistry, FdSender, WaitFdsError}, + ipc::transport::{FdDelivery, FdTransfer, WaitFdsError}, quic::ConnectionError, varint::VarInt, }; -use snafu::{OptionExt, Snafu}; +use snafu::Snafu; use tokio::{ io::{AsyncRead, AsyncWrite, AsyncWriteExt}, net::{ @@ -54,13 +56,12 @@ fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Resu /// Remoc RPC counterpart of [`ManageSessionStream`](super::ManageSessionStream) /// using FD passing for stream data. /// -/// Each method returns a [`VarInt`] — the FD-registry batch ID. The caller -/// passes it to [`FdRegistry::wait_fds`] to retrieve a single `OwnedFd` for a -/// bidirectional Unix socketpair. +/// Each method receives a receiver-chosen FD transfer ID and echoes it after +/// the FD delivery is acknowledged. #[remoc::rtc::remote] pub trait IpcManageSessionStream: Send + Sync { - async fn open_stream(&self) -> Result; - async fn accept_stream(&self) -> Result; + async fn open_stream(&self, fd_id: VarInt) -> Result; + async fn accept_stream(&self, fd_id: VarInt) -> Result; } // --------------------------------------------------------------------------- @@ -68,15 +69,15 @@ pub trait IpcManageSessionStream: Send + Sync { // --------------------------------------------------------------------------- /// Client-side handle wrapping an [`IpcManageSessionStreamClient`] and -/// [`FdRegistry`], implementing [`ManageSessionStream`]. +/// [`FdTransfer`], implementing [`ManageSessionStream`]. /// /// Each `open_stream` / `accept_stream` call: -/// 1. Calls the RPC to get a FD-registry batch ID. -/// 2. Waits for FDs from the registry. +/// 1. Reserves a receiver-chosen FD transfer ID. +/// 2. Calls the RPC with that ID while receiving the FD. /// 3. Converts the `OwnedFd` to a tokio `UnixStream` and splits it. pub struct IpcManageStreamHandle { rpc: IpcManageSessionStreamClient, - fd_registry: FdRegistry, + fd_transfer: FdTransfer, } /// Error from [`IpcManageStreamHandle`] operations. @@ -87,33 +88,47 @@ pub enum IpcManageStreamError { Rpc { source: ConnectionError }, #[snafu(display("failed to receive stream FD"))] ReceiveFd { source: WaitFdsError }, - #[snafu(display("expected 1 FD, got {actual}"))] - UnexpectedFdCount { actual: usize }, + #[snafu(display("unexpected stream fd batch size"))] + UnexpectedFdCount { + source: h3x::ipc::transport::TakeFdsError, + }, + #[snafu(display("peer responded with fd id {actual}, expected {expected}"))] + FdIdMismatch { expected: VarInt, actual: VarInt }, #[snafu(display("failed to convert FD to UnixStream"))] FromFd { source: std::io::Error }, } impl IpcManageStreamHandle { - pub fn new(rpc: IpcManageSessionStreamClient, fd_registry: FdRegistry) -> Self { - Self { rpc, fd_registry } + pub fn new(rpc: IpcManageSessionStreamClient, fd_transfer: FdTransfer) -> Self { + Self { rpc, fd_transfer } } async fn resolve_stream( &self, - fd_id: VarInt, + rpc: impl std::future::Future>, + receiver: h3x::ipc::transport::FdReceiver, ) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { use ipc_manage_stream_error::*; use snafu::ResultExt; - let fds = self - .fd_registry - .wait_fds(fd_id) - .await - .context(ReceiveFdSnafu)?; - let fd = fds - .into_iter() - .next() - .context(UnexpectedFdCountSnafu { actual: 0_usize })?; + let expected = receiver.id(); + let receive = receiver.into_future(); + tokio::pin!(rpc); + tokio::pin!(receive); + let (actual, received) = tokio::select! { + rpc_result = &mut rpc => { + let actual = rpc_result.context(RpcSnafu)?; + let received = receive.await.context(ReceiveFdSnafu)?; + (actual, received) + } + receive_result = &mut receive => { + let received = receive_result.context(ReceiveFdSnafu)?; + let actual = rpc.await.context(RpcSnafu)?; + (actual, received) + } + }; + snafu::ensure!(actual == expected, FdIdMismatchSnafu { expected, actual }); + let fd = received.into_one().context(UnexpectedFdCountSnafu)?; let stream = unix_stream_from_std(std::os::unix::net::UnixStream::from(fd)).context(FromFdSnafu)?; Ok(stream.into_split()) @@ -126,23 +141,23 @@ impl super::ManageSessionStream for IpcManageStreamHandle { type Error = IpcManageStreamError; async fn open_stream(&self) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { - use ipc_manage_stream_error::*; - use snafu::ResultExt; - - let fd_id = IpcManageSessionStream::open_stream(&self.rpc) - .await - .context(RpcSnafu)?; - self.resolve_stream(fd_id).await + let receiver = self.fd_transfer.receive(); + let fd_id = receiver.id(); + self.resolve_stream( + IpcManageSessionStream::open_stream(&self.rpc, fd_id), + receiver, + ) + .await } async fn accept_stream(&self) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { - use ipc_manage_stream_error::*; - use snafu::ResultExt; - - let fd_id = IpcManageSessionStream::accept_stream(&self.rpc) - .await - .context(RpcSnafu)?; - self.resolve_stream(fd_id).await + let receiver = self.fd_transfer.receive(); + let fd_id = receiver.id(); + self.resolve_stream( + IpcManageSessionStream::accept_stream(&self.rpc, fd_id), + receiver, + ) + .await } } @@ -154,7 +169,7 @@ impl super::ManageSessionStream for IpcManageStreamHandle { /// [`IpcManageSessionStream`] RPC trait. /// /// Each call opens a real managed stream, creates a Unix socketpair, spawns -/// bridge tasks, and queues the client-side FD through the [`FdSender`]. +/// bridge tasks, and delivers the client-side FD through the [`FdTransfer`]. /// /// Bridge tasks are spawned via [`tokio::spawn`] so they outlive this /// adapter. They terminate naturally when the Unix socketpair is closed @@ -164,18 +179,24 @@ impl super::ManageSessionStream for IpcManageStreamHandle { /// exit-status, EOF and Close messages — to the managed stream. pub struct IpcManageStreamAdapter { manage_stream: M, - fd_sender: FdSender, + fd_transfer: FdTransfer, } impl IpcManageStreamAdapter { - pub fn new(manage_stream: M, fd_sender: FdSender) -> Self { + pub fn new(manage_stream: M, fd_transfer: FdTransfer) -> Self { Self { manage_stream, - fd_sender, + fd_transfer, } } - fn bridge_and_queue(&self, reader: R, writer: W) -> Result + async fn bridge_and_deliver( + &self, + delivery: FdDelivery, + reader: R, + writer: W, + fd_id: VarInt, + ) -> Result where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, @@ -185,10 +206,12 @@ impl IpcManageStreamAdapter { cli.set_nonblocking(true) .map_err(|e| to_conn_error(e, "set_nonblocking"))?; - let fd_id = self - .fd_sender - .queue_fds(vec![cli.into()].into()) - .map_err(|e| to_conn_error(e, "queue_fds"))?; + let mut fds = h3x::ipc::transport::FdVec::new(); + fds.push(cli.into()); + delivery + .deliver(fds) + .await + .map_err(|e| to_conn_error(e, "deliver fds"))?; let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; let (srv_read, srv_write) = srv.into_split(); @@ -210,22 +233,28 @@ where M::StreamWriter: AsyncWrite + Unpin + Send + 'static, M::Error: Send + Sync + 'static, { - async fn open_stream(&self) -> Result { - let (reader, writer) = self - .manage_stream - .open_stream() + async fn open_stream(&self, fd_id: VarInt) -> Result { + let mut delivery = self.fd_transfer.delivery(fd_id); + let (reader, writer) = tokio::select! { + _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "open_stream")), + result = self.manage_stream.open_stream() => { + result.map_err(manage_stream_error_to_connection_error)? + } + }; + self.bridge_and_deliver(delivery, reader, writer, fd_id) .await - .map_err(manage_stream_error_to_connection_error)?; - self.bridge_and_queue(reader, writer) } - async fn accept_stream(&self) -> Result { - let (reader, writer) = self - .manage_stream - .accept_stream() + async fn accept_stream(&self, fd_id: VarInt) -> Result { + let mut delivery = self.fd_transfer.delivery(fd_id); + let (reader, writer) = tokio::select! { + _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "accept_stream")), + result = self.manage_stream.accept_stream() => { + result.map_err(manage_stream_error_to_connection_error)? + } + }; + self.bridge_and_deliver(delivery, reader, writer, fd_id) .await - .map_err(manage_stream_error_to_connection_error)?; - self.bridge_and_queue(reader, writer) } } @@ -356,20 +385,24 @@ mod tests { } #[tokio::test] + #[ignore = "fd delivery confirmation requires a dedicated mux driver test harness"] async fn ipc_adapter_accepts_generic_manage_session_stream() { let open_calls = Arc::new(AtomicUsize::new(0)); let manage_stream = MockManageStream { open_calls: open_calls.clone(), }; let (channel, _remote_fd) = MuxChannel::create_pair().expect("mux channel"); - let (sink, _stream) = channel.split().expect("split mux channel"); - let adapter = IpcManageStreamAdapter::new(manage_stream, sink.fd_sender()); + let (sink, stream) = channel.split().expect("split mux channel"); + let fd_transfer = stream.fd_transfer(sink.fd_sender()); + let adapter = IpcManageStreamAdapter::new(manage_stream, fd_transfer.clone()); + let receiver = fd_transfer.receive(); + let fd_id = receiver.id(); - let fd_id = IpcManageSessionStream::open_stream(&adapter) + let returned = IpcManageSessionStream::open_stream(&adapter, fd_id) .await .expect("open stream through adapter"); - assert_eq!(fd_id, VarInt::from_u32(0)); + assert_eq!(returned, VarInt::from_u32(0)); assert_eq!(open_calls.load(Ordering::SeqCst), 1); } diff --git a/src/session/mod.rs b/src/session/mod.rs index 68cd4dd..fa6e006 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -137,25 +137,30 @@ mod server { pub credential: crate::auth::AuthCredential, } + /// Authentication success payload. + /// + /// The child reserves `control_fd_id` before returning this value. The + /// parent delivers the control stream FD with that receiver-chosen ID while + /// invoking [`StartSessionFn`]. + #[derive(Serialize, Deserialize)] + pub struct AuthenticatedSession { + /// Inner remote function that starts the session. + pub start_session: StartSessionFn, + /// Receiver-chosen FD transfer ID for the control stream socketpair. + pub control_fd_id: VarInt, + } + /// Argument to the inner [`StartSessionFn`]: everything the child needs to /// construct a [`Conversation`](crate::conversation::Conversation) after /// authentication succeeds and the parent completes the HTTP upgrade. /// /// Stream data travels through Unix socketpairs via FD passing, not through - /// remoc serialization. The `manage_stream` field provides an RPC interface - /// that returns FD-registry batch IDs; the `control_fd_id` identifies the - /// pre-established control stream socketpair. + /// remoc serialization. The `manage_stream` field provides an RPC + /// interface that uses receiver-chosen FD transfer IDs. #[derive(Serialize, Deserialize)] pub struct SessionBootstrap { /// RPC client for opening/accepting QUIC streams via IPC FD passing. pub manage_stream: crate::conversation::ipc::IpcManageSessionStreamClient, - /// FD-registry batch ID for the control stream socketpair. - /// - /// The child calls [`FdRegistry::wait_fds`](h3x::ipc::transport::FdRegistry::wait_fds) - /// with this ID to receive a single `OwnedFd` for a bidirectional Unix - /// socketpair. One half carries data from the SSH3 client (control reader), - /// the other half carries data to the SSH3 client (control writer). - pub control_fd_id: VarInt, /// Unique session identifier. #[serde( serialize_with = "serialize_stream_id", @@ -235,10 +240,10 @@ mod server { /// Outer remote function: the child creates this and sends it to the parent. /// /// When the parent calls it with [`AuthRequest`], the child performs PAM - /// authentication. On success it returns a [`StartSessionFn`] continuation; - /// on failure it returns [`AuthError`]. + /// authentication. On success it returns an [`AuthenticatedSession`] + /// continuation; on failure it returns [`AuthError`]. pub type AuthenticateFn = - remoc::rfn::RFnOnce<(AuthRequest,), Result>; + remoc::rfn::RFnOnce<(AuthRequest,), Result>; /// Inner remote function: returned by [`AuthenticateFn`] on success. /// From 9108fe0abb017d9aab06c446fbabb41564309d72 Mon Sep 17 00:00:00 2001 From: eareimu Date: Wed, 3 Jun 2026 01:30:44 +0800 Subject: [PATCH 20/39] fix(ipc): retain owned bridge tasks --- Cargo.toml | 1 + src/conversation/ipc.rs | 144 ++++++++++++++++++++++++++++++++++------ 2 files changed, 125 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c59d358..15a9a72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ tokio = { version = "1", features = [ "process", "net", ] } +tokio-util = { version = "0.7", features = ["rt"] } base64 = "0.22" remoc = { version = "0.18", default-features = false, features = [ "remoc_macro", diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index cd44de5..2b8d894 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -25,7 +25,10 @@ //! 2. Calls the RPC method with that ID while concurrently receiving the FD. //! 3. Splits it into `(OwnedReadHalf, OwnedWriteHalf)`. -use std::future::IntoFuture; +use std::{ + future::{Future, IntoFuture}, + sync::Mutex, +}; use bytes::{Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; @@ -42,6 +45,7 @@ use tokio::{ unix::{OwnedReadHalf, OwnedWriteHalf}, }, }; +use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Result { @@ -116,16 +120,17 @@ impl IpcManageStreamHandle { tokio::pin!(rpc); tokio::pin!(receive); let (actual, received) = tokio::select! { - rpc_result = &mut rpc => { - let actual = rpc_result.context(RpcSnafu)?; - let received = receive.await.context(ReceiveFdSnafu)?; - (actual, received) - } + biased; receive_result = &mut receive => { let received = receive_result.context(ReceiveFdSnafu)?; let actual = rpc.await.context(RpcSnafu)?; (actual, received) } + rpc_result = &mut rpc => { + let actual = rpc_result.context(RpcSnafu)?; + let received = receive.await.context(ReceiveFdSnafu)?; + (actual, received) + } }; snafu::ensure!(actual == expected, FdIdMismatchSnafu { expected, actual }); let fd = received.into_one().context(UnexpectedFdCountSnafu)?; @@ -171,15 +176,14 @@ impl super::ManageSessionStream for IpcManageStreamHandle { /// Each call opens a real managed stream, creates a Unix socketpair, spawns /// bridge tasks, and delivers the client-side FD through the [`FdTransfer`]. /// -/// Bridge tasks are spawned via [`tokio::spawn`] so they outlive this -/// adapter. They terminate naturally when the Unix socketpair is closed -/// (i.e. when the child process drops its half). This is important because -/// the adapter may be dropped (via the remoc `ServerShared` lifecycle) -/// before the bridge has finished flushing final data — such as SSH -/// exit-status, EOF and Close messages — to the managed stream. +/// Bridge tasks are owned by this adapter through [`AbortOnDropHandle`]. They +/// may outlive the individual RPC call that created them, but dropping the +/// adapter also drops the bridge task handles so a remoc server lifecycle +/// teardown cannot leak stream-forwarding tasks. pub struct IpcManageStreamAdapter { manage_stream: M, fd_transfer: FdTransfer, + bridge_tasks: Mutex>>, } impl IpcManageStreamAdapter { @@ -187,9 +191,18 @@ impl IpcManageStreamAdapter { Self { manage_stream, fd_transfer, + bridge_tasks: Mutex::new(Vec::new()), } } + fn spawn_bridge_task(&self, task: impl Future + Send + 'static) { + let handle = AbortOnDropHandle::new(tokio::spawn(task.in_current_span())); + self.bridge_tasks + .lock() + .expect("bridge task registry should not be poisoned") + .push(handle); + } + async fn bridge_and_deliver( &self, delivery: FdDelivery, @@ -216,11 +229,8 @@ impl IpcManageStreamAdapter { let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; let (srv_read, srv_write) = srv.into_split(); - // Spawn bridge tasks independently so they are NOT aborted when this - // adapter is dropped. The tasks will terminate on their own once the - // Unix socketpair closes (child process exit / fd drop). - tokio::spawn(bridge_reader_to_unix(reader, srv_write).in_current_span()); - tokio::spawn(bridge_unix_to_writer(srv_read, writer).in_current_span()); + self.spawn_bridge_task(bridge_reader_to_unix(reader, srv_write)); + self.spawn_bridge_task(bridge_unix_to_writer(srv_read, writer)); Ok(fd_id) } @@ -236,6 +246,7 @@ where async fn open_stream(&self, fd_id: VarInt) -> Result { let mut delivery = self.fd_transfer.delivery(fd_id); let (reader, writer) = tokio::select! { + biased; _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "open_stream")), result = self.manage_stream.open_stream() => { result.map_err(manage_stream_error_to_connection_error)? @@ -248,6 +259,7 @@ where async fn accept_stream(&self, fd_id: VarInt) -> Result { let mut delivery = self.fd_transfer.delivery(fd_id); let (reader, writer) = tokio::select! { + biased; _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "accept_stream")), result = self.manage_stream.accept_stream() => { result.map_err(manage_stream_error_to_connection_error)? @@ -349,9 +361,14 @@ where #[cfg(test)] mod tests { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, + use std::{ + future::IntoFuture, + os::fd::OwnedFd, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; use h3x::{ipc::transport::MuxChannel, varint::VarInt}; @@ -384,6 +401,41 @@ mod tests { } } + #[derive(Clone, Debug, Default)] + struct BlockingManageStream { + held_peers: Arc>>, + } + + impl ManageSessionStream for BlockingManageStream { + type StreamReader = tokio::io::DuplexStream; + type StreamWriter = tokio::io::DuplexStream; + type Error = std::io::Error; + + async fn open_stream( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + let (reader, reader_peer) = tokio::io::duplex(64); + let (writer_peer, writer) = tokio::io::duplex(64); + let mut held_peers = self.held_peers.lock().expect("held peer lock"); + held_peers.push(reader_peer); + held_peers.push(writer_peer); + Ok((reader, writer)) + } + + async fn accept_stream( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { + self.open_stream().await + } + } + + fn mux_pair() -> (MuxChannel, MuxChannel) { + let (left, right) = std::os::unix::net::UnixStream::pair().expect("socketpair"); + let left = MuxChannel::from_fd(OwnedFd::from(left)).expect("left mux channel"); + let right = MuxChannel::from_fd(OwnedFd::from(right)).expect("right mux channel"); + (left, right) + } + #[tokio::test] #[ignore = "fd delivery confirmation requires a dedicated mux driver test harness"] async fn ipc_adapter_accepts_generic_manage_session_stream() { @@ -418,4 +470,56 @@ mod tests { assert_eq!(buf, [b'x']); } + + #[tokio::test] + async fn dropping_ipc_adapter_aborts_owned_bridge_tasks() { + let (server_mux, client_mux) = mux_pair(); + let (server_sink, server_stream) = server_mux.split().expect("server mux split"); + let server_fd_transfer = server_stream.fd_transfer(server_sink.fd_sender()); + let (client_sink, client_stream) = client_mux.split().expect("client mux split"); + let client_fd_transfer = client_stream.fd_transfer(client_sink.fd_sender()); + + let manage_stream = BlockingManageStream::default(); + let held_peers = manage_stream.held_peers.clone(); + let adapter = IpcManageStreamAdapter::new(manage_stream, server_fd_transfer); + let receiver = client_fd_transfer.receive(); + let fd_id = receiver.id(); + let (returned, received) = { + let call = IpcManageSessionStream::open_stream(&adapter, fd_id); + let receive = receiver.into_future(); + tokio::pin!(call); + tokio::pin!(receive); + tokio::join!(call, receive) + }; + assert_eq!(returned.expect("open stream"), fd_id); + let fd = received + .expect("receive delivered fd") + .into_one() + .expect("one stream fd"); + let mut stream = + unix_stream_from_std(std::os::unix::net::UnixStream::from(fd)).expect("tokio stream"); + + let mut buf = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(50), stream.read(&mut buf)) + .await + .is_err(), + "stream peer should remain open while adapter owns bridge tasks", + ); + + drop(adapter); + assert_eq!( + held_peers.lock().expect("held peer lock").len(), + 2, + "the test keeps the managed stream peers open after adapter drop", + ); + + let read = tokio::time::timeout(Duration::from_millis(200), stream.read(&mut buf)) + .await + .expect("bridge task drop should close the peer fd") + .expect("read after adapter drop"); + + assert_eq!(read, 0); + drop((server_sink, server_stream, client_sink, client_stream)); + } } From ce1e12ac68477290318c093f9bbad2d2daf88ee3 Mon Sep 17 00:00:00 2001 From: eareimu Date: Thu, 4 Jun 2026 00:25:54 +0800 Subject: [PATCH 21/39] fix(webtransport): use reset stream API --- src/conversation/tests.rs | 6 +++--- src/webtransport.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/conversation/tests.rs b/src/conversation/tests.rs index 74de9ff..915c4b7 100644 --- a/src/conversation/tests.rs +++ b/src/conversation/tests.rs @@ -10,7 +10,7 @@ use bytes::Bytes; use futures::{Sink, Stream, channel::mpsc}; use h3x::{ codec::{SinkWriter, StreamReader as H3xStreamReader}, - quic::{CancelStream, GetStreamId, StopStream, StreamError}, + quic::{GetStreamId, ResetStream, StopStream, StreamError}, }; // -- Mock stream types that implement h3x ReadStream / WriteStream ------ @@ -103,8 +103,8 @@ impl GetStreamId for TestQuicWriter { } } -impl CancelStream for TestQuicWriter { - fn poll_cancel( +impl ResetStream for TestQuicWriter { + fn poll_reset( self: Pin<&mut Self>, _cx: &mut Context, _code: VarInt, diff --git a/src/webtransport.rs b/src/webtransport.rs index f718afd..7e8c91f 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -447,7 +447,7 @@ mod tests { use bytes::Bytes; use futures::{Sink, SinkExt, Stream}; use h3x::{ - quic::{CancelStream, GetStreamId, StopStream}, + quic::{GetStreamId, ResetStream, StopStream}, stream_id::StreamId, }; use http::HeaderMap; @@ -549,8 +549,8 @@ mod tests { } } - impl CancelStream for TestWriteStream { - fn poll_cancel( + impl ResetStream for TestWriteStream { + fn poll_reset( self: Pin<&mut Self>, _cx: &mut Context<'_>, _code: VarInt, From e1c44bf9b0c90f0c909326ffd1cdffaa1eaa8776 Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 5 Jun 2026 22:36:46 +0800 Subject: [PATCH 22/39] fix: remove ipc fd delivery cancellation waits --- src/conversation/ipc.rs | 63 +++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index 2b8d894..487e835 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -61,7 +61,7 @@ fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Resu /// using FD passing for stream data. /// /// Each method receives a receiver-chosen FD transfer ID and echoes it after -/// the FD delivery is acknowledged. +/// the FD delivery is queued to the local mux writer FIFO. #[remoc::rtc::remote] pub trait IpcManageSessionStream: Send + Sync { async fn open_stream(&self, fd_id: VarInt) -> Result; @@ -244,27 +244,23 @@ where M::Error: Send + Sync + 'static, { async fn open_stream(&self, fd_id: VarInt) -> Result { - let mut delivery = self.fd_transfer.delivery(fd_id); - let (reader, writer) = tokio::select! { - biased; - _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "open_stream")), - result = self.manage_stream.open_stream() => { - result.map_err(manage_stream_error_to_connection_error)? - } - }; + let delivery = self.fd_transfer.delivery(fd_id); + let (reader, writer) = self + .manage_stream + .open_stream() + .await + .map_err(manage_stream_error_to_connection_error)?; self.bridge_and_deliver(delivery, reader, writer, fd_id) .await } async fn accept_stream(&self, fd_id: VarInt) -> Result { - let mut delivery = self.fd_transfer.delivery(fd_id); - let (reader, writer) = tokio::select! { - biased; - _ = delivery.cancelled() => return Err(to_conn_error("fd transfer cancelled", "accept_stream")), - result = self.manage_stream.accept_stream() => { - result.map_err(manage_stream_error_to_connection_error)? - } - }; + let delivery = self.fd_transfer.delivery(fd_id); + let (reader, writer) = self + .manage_stream + .accept_stream() + .await + .map_err(manage_stream_error_to_connection_error)?; self.bridge_and_deliver(delivery, reader, writer, fd_id) .await } @@ -339,8 +335,8 @@ pub async fn bridge_unix_to_message_writer( // Error helpers // --------------------------------------------------------------------------- -fn to_conn_error(err: impl std::fmt::Display, context: &str) -> ConnectionError { - tracing::warn!(%err, context, "ipc manage stream error"); +fn to_conn_error(err: impl std::error::Error, context: &str) -> ConnectionError { + tracing::warn!(error = %snafu::Report::from_error(&err), context, "ipc manage stream error"); h3x::quic::ApplicationError { code: h3x::error::Code::from(VarInt::from_u32(0)), reason: std::borrow::Cow::Owned(format!("ipc {context}: {err}")), @@ -371,7 +367,7 @@ mod tests { time::Duration, }; - use h3x::{ipc::transport::MuxChannel, varint::VarInt}; + use h3x::ipc::transport::MuxChannel; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::{IpcManageSessionStream, IpcManageStreamAdapter, unix_stream_from_std}; @@ -437,25 +433,32 @@ mod tests { } #[tokio::test] - #[ignore = "fd delivery confirmation requires a dedicated mux driver test harness"] async fn ipc_adapter_accepts_generic_manage_session_stream() { let open_calls = Arc::new(AtomicUsize::new(0)); let manage_stream = MockManageStream { open_calls: open_calls.clone(), }; - let (channel, _remote_fd) = MuxChannel::create_pair().expect("mux channel"); - let (sink, stream) = channel.split().expect("split mux channel"); - let fd_transfer = stream.fd_transfer(sink.fd_sender()); - let adapter = IpcManageStreamAdapter::new(manage_stream, fd_transfer.clone()); - let receiver = fd_transfer.receive(); + let (server_mux, client_mux) = mux_pair(); + let (server_sink, server_stream) = server_mux.split().expect("server mux split"); + let server_fd_transfer = server_stream.fd_transfer(server_sink.fd_sender()); + let (client_sink, client_stream) = client_mux.split().expect("client mux split"); + let client_fd_transfer = client_stream.fd_transfer(client_sink.fd_sender()); + let adapter = IpcManageStreamAdapter::new(manage_stream, server_fd_transfer); + let receiver = client_fd_transfer.receive(); let fd_id = receiver.id(); - let returned = IpcManageSessionStream::open_stream(&adapter, fd_id) - .await - .expect("open stream through adapter"); + let (returned, received) = { + let call = IpcManageSessionStream::open_stream(&adapter, fd_id); + let receive = receiver.into_future(); + tokio::pin!(call); + tokio::pin!(receive); + tokio::join!(call, receive) + }; - assert_eq!(returned, VarInt::from_u32(0)); + assert_eq!(returned.expect("open stream through adapter"), fd_id); + assert_eq!(received.expect("receive delivered fd").len(), 1); assert_eq!(open_calls.load(Ordering::SeqCst), 1); + drop((server_sink, server_stream, client_sink, client_stream)); } #[tokio::test] From 5a1c0b751be1aaf43c1b1abdf8c43df8d2396a5f Mon Sep 17 00:00:00 2001 From: eareimu Date: Fri, 5 Jun 2026 23:06:54 +0800 Subject: [PATCH 23/39] fix: prepare dssh ipc socket before fd delivery --- src/conversation/ipc.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs index 487e835..6db75f7 100644 --- a/src/conversation/ipc.rs +++ b/src/conversation/ipc.rs @@ -207,7 +207,7 @@ impl IpcManageStreamAdapter { &self, delivery: FdDelivery, reader: R, - writer: W, + mut writer: W, fd_id: VarInt, ) -> Result where @@ -218,16 +218,15 @@ impl IpcManageStreamAdapter { std::os::unix::net::UnixStream::pair().map_err(|e| to_conn_error(e, "socketpair"))?; cli.set_nonblocking(true) .map_err(|e| to_conn_error(e, "set_nonblocking"))?; + let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; + let (srv_read, srv_write) = srv.into_split(); let mut fds = h3x::ipc::transport::FdVec::new(); fds.push(cli.into()); - delivery - .deliver(fds) - .await - .map_err(|e| to_conn_error(e, "deliver fds"))?; - - let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; - let (srv_read, srv_write) = srv.into_split(); + if let Err(error) = delivery.deliver(fds).await { + let _ = writer.shutdown().await; + return Err(to_conn_error(error, "deliver fds")); + } self.spawn_bridge_task(bridge_reader_to_unix(reader, srv_write)); self.spawn_bridge_task(bridge_unix_to_writer(srv_read, writer)); From b3cac962f2e26111abe067825057c021c7f023d1 Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 7 Jun 2026 00:06:01 +0800 Subject: [PATCH 24/39] fix: update h3x hyper request error path --- src/webtransport.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webtransport.rs b/src/webtransport.rs index 7e8c91f..8d6718c 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -239,7 +239,7 @@ pub enum ClientConnectConversationError { }, #[snafu(display("failed to execute dssh webtransport connect request"))] Execute { - source: h3x::hyper::client::RequestError, + source: h3x::hyper::RequestError, }, #[snafu(display("failed to validate dssh peer version"))] PeerVersion { source: NegotiateVersionError }, From 2ac1d513bfc86925ce120245f6cd945f822d628b Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 7 Jun 2026 10:06:13 +0800 Subject: [PATCH 25/39] refactor: make conversation own webtransport session --- src/conversation.rs | 254 ++++++++++++----- src/conversation/channel.rs | 30 +- src/conversation/ipc.rs | 527 ------------------------------------ src/conversation/tests.rs | 373 ++++++++++++------------- src/forward/client.rs | 74 +++-- src/forward/reverse.rs | 269 +++++++++++++----- src/session/dispatcher.rs | 87 ++++-- src/session/mod.rs | 47 +--- src/session/process.rs | 42 +++ src/webtransport.rs | 312 ++------------------- 10 files changed, 731 insertions(+), 1284 deletions(-) delete mode 100644 src/conversation/ipc.rs diff --git a/src/conversation.rs b/src/conversation.rs index 9fc7706..4bfcb39 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -1,7 +1,7 @@ //! SSH3 conversation (session) abstraction. //! //! A *conversation* is the SSH3 equivalent of an SSH2 session — it manages -//! channels and global requests over a QUIC CONNECT stream. +//! channels and global requests over a WebTransport session. //! //! # Design //! @@ -45,18 +45,16 @@ use std::cell::UnsafeCell; use std::collections::BTreeSet; -use std::future::Future; use std::pin::pin; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use h3x::{ - codec::{DecodeExt, DecodeFrom, EncodeExt, EncodeInto}, + codec::{DecodeExt, DecodeFrom, EncodeExt, EncodeInto, SinkWriter, StreamReader}, stream_id::StreamId, varint::VarInt, }; use snafu::ResultExt; -use std::pin::Pin; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::sync::Notify; @@ -216,8 +214,8 @@ impl Drop for OrderedGuard { /// A global request that expects a reply (`want_reply = true`). /// /// Implementors define the payload and success response types. Encoding and -/// decoding bounds are checked at the call site against the concrete stream -/// types from [`ManageSessionStream`]. +/// decoding bounds are checked at the call site against the concrete streams +/// carried by [`Conversation`]. pub trait WantReplyGlobalRequest { /// Successful response type, decoded directly from the stream. type Success; @@ -326,33 +324,6 @@ impl DecodeFrom for EmptyPayload { } } -// =========================================================================== -// ManageSessionStream trait -// =========================================================================== - -/// Trait for managing QUIC stream creation and acceptance. -/// -/// Implementations handle the transport-specific framing (e.g. SSH3 signal -/// value and session ID). The [`Conversation`] receives streams already -/// positioned past transport framing. -pub trait ManageSessionStream: Send + Sync { - type StreamReader: AsyncRead + Unpin + Send; - type StreamWriter: AsyncWrite + Unpin + Send; - type Error: std::error::Error + Send + Sync + 'static; - - fn open_stream( - &self, - ) -> impl Future> + Send; - - fn accept_stream( - &self, - ) -> impl Future> + Send; -} - -/// IPC-based bridge using FD passing over [`h3x::ipc::transport::MuxChannel`]. -#[cfg(feature = "server")] -pub mod ipc; - // =========================================================================== // Conversation shared state // =========================================================================== @@ -440,29 +411,38 @@ where // Conversation // =========================================================================== -pub struct Conversation< - M: ManageSessionStream, - R = Pin>, - W = Pin>, -> { +type ConversationReader = StreamReader<::StreamReader>; +type ConversationWriter = SinkWriter<::StreamWriter>; +type ConversationSharedState = ConversationShared, ConversationWriter>; + +fn conversation_id_from_session(id: I) -> StreamId +where + I: Into, +{ + id.into() +} + +pub struct Conversation +where + S: h3x::webtransport::Session, +{ id: StreamId, peer_version: String, - shared: Arc>, - _manage_stream: M, + shared: Arc>, + session: S, } -impl Conversation +impl Conversation where - R: AsyncRead + Unpin + Send, - W: AsyncWrite + Unpin + Send, + S: h3x::webtransport::Session, { - pub fn new( - id: StreamId, + pub(crate) fn from_control_streams( + session: S, peer_version: impl Into, - control_stream_reader: R, - control_stream_writer: W, - manage_stream: M, + control_stream_reader: StreamReader, + control_stream_writer: SinkWriter, ) -> Self { + let id = conversation_id_from_session(session.id()); Self { id, peer_version: peer_version.into(), @@ -473,7 +453,7 @@ where ticket_pair_lock: std::sync::Mutex::new(()), auto_failures: std::sync::Mutex::new(BTreeSet::new()), }), - _manage_stream: manage_stream, + session, } } @@ -485,6 +465,118 @@ where &self.peer_version } + /// Open a DSSH conversation over a WebTransport session. + /// + /// This opens the DSSH control stream and writes the DSSH control stream + /// kind as the first field on that WebTransport bidirectional stream. + pub async fn open( + session: S, + peer_version: impl Into, + ) -> Result { + let (reader, writer) = + Self::open_stream_kind(&session, crate::webtransport::DSSH_CONTROL_STREAM_KIND) + .await + .context(crate::webtransport::open_conversation_error::OpenControlSnafu)?; + Ok(Self::from_control_streams( + session, + peer_version, + reader, + writer, + )) + } + + /// Accept a DSSH conversation over a WebTransport session. + /// + /// This accepts the DSSH control stream and validates that its first field + /// is the DSSH control stream kind. + pub async fn accept( + session: S, + peer_version: impl Into, + ) -> Result { + let (reader, writer) = + Self::accept_stream_kind(&session, crate::webtransport::DSSH_CONTROL_STREAM_KIND) + .await + .context(crate::webtransport::accept_conversation_error::AcceptControlSnafu)?; + Ok(Self::from_control_streams( + session, + peer_version, + reader, + writer, + )) + } + + async fn open_channel_stream( + &self, + ) -> Result< + (StreamReader, SinkWriter), + crate::webtransport::WebTransportStreamError, + > { + Self::open_stream_kind(&self.session, crate::webtransport::DSSH_CHANNEL_STREAM_KIND).await + } + + async fn accept_channel_stream( + &self, + ) -> Result< + (StreamReader, SinkWriter), + crate::webtransport::WebTransportStreamError, + > { + Self::accept_stream_kind(&self.session, crate::webtransport::DSSH_CHANNEL_STREAM_KIND).await + } + + async fn open_stream_kind( + session: &S, + kind: VarInt, + ) -> Result< + (StreamReader, SinkWriter), + crate::webtransport::WebTransportStreamError, + > { + let (reader, writer) = session + .open_bi() + .await + .context(crate::webtransport::web_transport_stream_error::OpenBiSnafu)?; + let writer = Self::write_stream_kind(writer, kind).await?; + Ok((StreamReader::new(reader), writer)) + } + + async fn accept_stream_kind( + session: &S, + expected: VarInt, + ) -> Result< + (StreamReader, SinkWriter), + crate::webtransport::WebTransportStreamError, + > { + let (reader, writer) = session + .accept_bi() + .await + .context(crate::webtransport::web_transport_stream_error::AcceptBiSnafu)?; + let mut reader = StreamReader::new(reader); + let actual = reader + .decode_one::() + .await + .context(crate::webtransport::web_transport_stream_error::DecodeStreamKindSnafu)?; + if actual != expected { + return Err( + crate::webtransport::WebTransportStreamError::UnexpectedStreamKind { kind: actual }, + ); + } + Ok((reader, SinkWriter::new(writer))) + } + + async fn write_stream_kind( + writer: S::StreamWriter, + kind: VarInt, + ) -> Result, crate::webtransport::WebTransportStreamError> { + let mut writer = SinkWriter::new(writer); + writer + .encode_one(kind) + .await + .context(crate::webtransport::web_transport_stream_error::EncodeStreamKindSnafu)?; + AsyncWriteExt::flush(&mut writer) + .await + .context(crate::webtransport::web_transport_stream_error::FlushStreamKindSnafu)?; + Ok(writer) + } + /// Send a global request that expects a reply and wait for the response. /// /// Multiple concurrent calls are safe; the ticket mechanism ensures @@ -492,7 +584,7 @@ where /// /// `PE` and `SE` are the encode/decode error types of the payload and /// success response respectively. They are inferred from the trait bounds. - pub async fn request( + pub async fn send_global_request( &self, request: &RQ, ) -> Result> @@ -500,8 +592,9 @@ where RQ: WantReplyGlobalRequest, PE: std::error::Error + Send + Sync + 'static, SE: std::error::Error + Send + Sync + 'static, - for<'w> RQ::Payload: EncodeInto<&'w mut W, Output = (), Error = PE>, - for<'r> RQ::Success: DecodeFrom<&'r mut R, Error = SE>, + for<'w> RQ::Payload: + EncodeInto<&'w mut SinkWriter, Output = (), Error = PE>, + for<'r> RQ::Success: DecodeFrom<&'r mut StreamReader, Error = SE>, { use self::global::send_request_error::*; @@ -575,11 +668,15 @@ where } /// Send a global notification (no reply expected). - pub async fn notify(&self, notice: &N) -> Result<(), SendNotifyError> + pub async fn send_global_notification( + &self, + notice: &N, + ) -> Result<(), SendNotifyError> where N: NotifyGlobalRequest, PE: std::error::Error + Send + Sync + 'static, - for<'w> N::Payload: EncodeInto<&'w mut W, Output = (), Error = PE>, + for<'w> N::Payload: + EncodeInto<&'w mut SinkWriter, Output = (), Error = PE>, { use self::global::send_notify_error::*; @@ -626,9 +723,14 @@ where /// Returns an [`IncomingGlobal`] that holds a reader guard for the caller /// to decode the payload. The reader guard **must** be released (via /// [`IncomingGlobalRequest::decode_payload`] or - /// [`IncomingGlobalNotice::decode_payload`]) before the next `accept()` - /// can proceed. - pub async fn accept(&self) -> Result, AcceptError> { + /// [`IncomingGlobalNotice::decode_payload`]) before the next + /// `accept_global_request()` can proceed. + pub async fn accept_global_request( + &self, + ) -> Result< + IncomingGlobal, SinkWriter>, + AcceptError, + > { use self::global::accept_error::*; let read_ticket = self.shared.reader.take_ticket(); @@ -692,29 +794,27 @@ where /// Open a new channel. /// - /// The transport framing (signal value and session ID) is written by the - /// [`ManageSessionStream`] implementation. This method writes the remaining - /// channel header fields: `max_message_size`, `channel_type`, and the - /// type-specific payload. + /// The WebTransport session carries the stream lifetime. This method only + /// writes the DSSH channel stream kind and SSH channel header fields: + /// `max_message_size`, `channel_type`, and the type-specific payload. /// /// Returns the (reader, writer) pair for subsequent channel communication. pub async fn open_channel( &self, channel: &C, max_message_size: VarInt, - ) -> Result<(M::StreamReader, M::StreamWriter), OpenChannelError> + ) -> Result< + (StreamReader, SinkWriter), + OpenChannelError, + > where C: ChannelOpen, PE: std::error::Error + Send + Sync + 'static, - for<'w> C: EncodeInto<&'w mut M::StreamWriter, Output = (), Error = PE>, + for<'w> C: EncodeInto<&'w mut SinkWriter, Output = (), Error = PE>, { use self::channel::open_channel_error::*; - let (mut reader, mut writer) = self - ._manage_stream - .open_stream() - .await - .context(OpenStreamSnafu)?; + let (mut reader, mut writer) = self.open_channel_stream().await.context(OpenStreamSnafu)?; writer .encode_one(max_message_size) @@ -743,20 +843,24 @@ where /// Accept an incoming channel. /// - /// The transport framing (signal value and session ID) has already been - /// consumed by the [`ManageSessionStream`] implementation. This method - /// reads the remaining channel header fields: `max_message_size` and - /// `channel_type`. + /// The WebTransport session carries the stream lifetime. This method + /// accepts a WebTransport bidirectional stream, validates the DSSH channel + /// stream kind, and reads the SSH channel header fields: `max_message_size` + /// and `channel_type`. /// /// Returns an [`IncomingChannel`] holding the channel type string and the /// stream pair. The caller inspects the type string and then calls /// [`IncomingChannel::decode_payload`] to decode the type-specific payload. - pub async fn accept_channel(&self) -> Result, AcceptChannelError> { + pub async fn accept_channel( + &self, + ) -> Result< + IncomingChannel, SinkWriter>, + AcceptChannelError, + > { use self::channel::accept_channel_error::*; let (mut reader, writer) = self - ._manage_stream - .accept_stream() + .accept_channel_stream() .await .context(AcceptStreamSnafu)?; diff --git a/src/conversation/channel.rs b/src/conversation/channel.rs index 0183c32..de704a8 100644 --- a/src/conversation/channel.rs +++ b/src/conversation/channel.rs @@ -9,10 +9,10 @@ use crate::channel::ChannelOpenFailure; use crate::codec::{CodecError, SshBool, SshString}; use super::{ - ManageSessionStream, NotifyChannelRequest, SSH_MSG_CHANNEL_CLOSE, SSH_MSG_CHANNEL_DATA, - SSH_MSG_CHANNEL_EOF, SSH_MSG_CHANNEL_EXTENDED_DATA, SSH_MSG_CHANNEL_FAILURE, - SSH_MSG_CHANNEL_OPEN_CONFIRMATION, SSH_MSG_CHANNEL_OPEN_FAILURE, SSH_MSG_CHANNEL_REQUEST, - SSH_MSG_CHANNEL_SUCCESS, WantReplyChannelRequest, + NotifyChannelRequest, SSH_MSG_CHANNEL_CLOSE, SSH_MSG_CHANNEL_DATA, SSH_MSG_CHANNEL_EOF, + SSH_MSG_CHANNEL_EXTENDED_DATA, SSH_MSG_CHANNEL_FAILURE, SSH_MSG_CHANNEL_OPEN_CONFIRMATION, + SSH_MSG_CHANNEL_OPEN_FAILURE, SSH_MSG_CHANNEL_REQUEST, SSH_MSG_CHANNEL_SUCCESS, + WantReplyChannelRequest, }; // =========================================================================== @@ -258,22 +258,22 @@ pub enum WriteChannelOpenFailureError { /// /// Unlike global requests, channels have independent streams. Dropping this /// struct simply closes the streams — it does **not** poison the session. -pub struct IncomingChannel { +pub struct IncomingChannel { channel_type: SshString, max_message_size: VarInt, - reader: M::StreamReader, - writer: M::StreamWriter, + reader: R, + writer: W, } -impl IncomingChannel { +impl IncomingChannel { /// Create an `IncomingChannel` from its constituent parts. /// /// Used by [`super::Conversation::accept_channel`]. pub(super) fn new( channel_type: SshString, max_message_size: VarInt, - reader: M::StreamReader, - writer: M::StreamWriter, + reader: R, + writer: W, ) -> Self { Self { channel_type, @@ -298,11 +298,9 @@ impl IncomingChannel { /// /// Returns the decoded payload together with a [`PendingChannel`] that /// must be accepted or rejected to complete the channel handshake. - pub async fn decode_payload( - mut self, - ) -> Result<(T, PendingChannel), DE> + pub async fn decode_payload(mut self) -> Result<(T, PendingChannel), DE> where - T: for<'r> DecodeFrom<&'r mut M::StreamReader, Error = DE>, + T: for<'r> DecodeFrom<&'r mut R, Error = DE>, { let value = T::decode_from(&mut self.reader).await?; Ok(( @@ -318,7 +316,7 @@ impl IncomingChannel { /// /// Useful for channel types that carry no additional payload (e.g. /// `"session"` channels). - pub fn skip_payload(self) -> PendingChannel { + pub fn skip_payload(self) -> PendingChannel { PendingChannel { reader: self.reader, writer: self.writer, @@ -329,7 +327,7 @@ impl IncomingChannel { /// /// Useful when passing streams to a handler that performs its own payload /// decoding (e.g. direct forwarding handlers). - pub fn into_raw_parts(self) -> (M::StreamReader, M::StreamWriter) { + pub fn into_raw_parts(self) -> (R, W) { (self.reader, self.writer) } } diff --git a/src/conversation/ipc.rs b/src/conversation/ipc.rs deleted file mode 100644 index 6db75f7..0000000 --- a/src/conversation/ipc.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! IPC bridge for [`ManageSessionStream`](super::ManageSessionStream). -//! -//! Replaces the remoc-serialized stream proxy ([`super::remoc`]) with direct -//! FD passing over a [`MuxChannel`]. Stream data travels through Unix -//! socketpairs instead of being serialized through remoc, eliminating the -//! per-byte serialization overhead. -//! -//! # Architecture -//! -//! The gateway process wraps a [`ManageSessionStream`](super::ManageSessionStream) -//! implementation (for example a WebTransport-backed stream manager) in an -//! [`IpcManageStreamAdapter`] and -//! serves the generated [`IpcManageSessionStreamServerShared`]. Each -//! `open_stream` / `accept_stream` call: -//! 1. Opens a real bidirectional stream through the wrapped manager. -//! 2. Creates a Unix socketpair. -//! 3. Delivers the client-side FD through the [`FdTransfer`]. -//! 4. Spawns bridge tasks forwarding data between the managed stream and the -//! server-side socketpair half. -//! 5. Returns the FD-registry batch ID over RPC. -//! -//! The child process receives an [`IpcManageSessionStreamClient`] and wraps it -//! in [`IpcManageStreamHandle`], which implements [`ManageSessionStream`]: -//! 1. Reserves a receiver-chosen FD transfer ID. -//! 2. Calls the RPC method with that ID while concurrently receiving the FD. -//! 3. Splits it into `(OwnedReadHalf, OwnedWriteHalf)`. - -use std::{ - future::{Future, IntoFuture}, - sync::Mutex, -}; - -use bytes::{Bytes, BytesMut}; -use futures::{SinkExt, StreamExt}; -use h3x::{ - ipc::transport::{FdDelivery, FdTransfer, WaitFdsError}, - quic::ConnectionError, - varint::VarInt, -}; -use snafu::Snafu; -use tokio::{ - io::{AsyncRead, AsyncWrite, AsyncWriteExt}, - net::{ - UnixStream, - unix::{OwnedReadHalf, OwnedWriteHalf}, - }, -}; -use tokio_util::task::AbortOnDropHandle; -use tracing::Instrument; - -fn unix_stream_from_std(stream: std::os::unix::net::UnixStream) -> std::io::Result { - stream.set_nonblocking(true)?; - UnixStream::from_std(stream) -} - -// --------------------------------------------------------------------------- -// RPC trait -// --------------------------------------------------------------------------- - -/// Remoc RPC counterpart of [`ManageSessionStream`](super::ManageSessionStream) -/// using FD passing for stream data. -/// -/// Each method receives a receiver-chosen FD transfer ID and echoes it after -/// the FD delivery is queued to the local mux writer FIFO. -#[remoc::rtc::remote] -pub trait IpcManageSessionStream: Send + Sync { - async fn open_stream(&self, fd_id: VarInt) -> Result; - async fn accept_stream(&self, fd_id: VarInt) -> Result; -} - -// --------------------------------------------------------------------------- -// Client → ManageSessionStream -// --------------------------------------------------------------------------- - -/// Client-side handle wrapping an [`IpcManageSessionStreamClient`] and -/// [`FdTransfer`], implementing [`ManageSessionStream`]. -/// -/// Each `open_stream` / `accept_stream` call: -/// 1. Reserves a receiver-chosen FD transfer ID. -/// 2. Calls the RPC with that ID while receiving the FD. -/// 3. Converts the `OwnedFd` to a tokio `UnixStream` and splits it. -pub struct IpcManageStreamHandle { - rpc: IpcManageSessionStreamClient, - fd_transfer: FdTransfer, -} - -/// Error from [`IpcManageStreamHandle`] operations. -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum IpcManageStreamError { - #[snafu(display("manage stream RPC failed"))] - Rpc { source: ConnectionError }, - #[snafu(display("failed to receive stream FD"))] - ReceiveFd { source: WaitFdsError }, - #[snafu(display("unexpected stream fd batch size"))] - UnexpectedFdCount { - source: h3x::ipc::transport::TakeFdsError, - }, - #[snafu(display("peer responded with fd id {actual}, expected {expected}"))] - FdIdMismatch { expected: VarInt, actual: VarInt }, - #[snafu(display("failed to convert FD to UnixStream"))] - FromFd { source: std::io::Error }, -} - -impl IpcManageStreamHandle { - pub fn new(rpc: IpcManageSessionStreamClient, fd_transfer: FdTransfer) -> Self { - Self { rpc, fd_transfer } - } - - async fn resolve_stream( - &self, - rpc: impl std::future::Future>, - receiver: h3x::ipc::transport::FdReceiver, - ) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { - use ipc_manage_stream_error::*; - use snafu::ResultExt; - - let expected = receiver.id(); - let receive = receiver.into_future(); - tokio::pin!(rpc); - tokio::pin!(receive); - let (actual, received) = tokio::select! { - biased; - receive_result = &mut receive => { - let received = receive_result.context(ReceiveFdSnafu)?; - let actual = rpc.await.context(RpcSnafu)?; - (actual, received) - } - rpc_result = &mut rpc => { - let actual = rpc_result.context(RpcSnafu)?; - let received = receive.await.context(ReceiveFdSnafu)?; - (actual, received) - } - }; - snafu::ensure!(actual == expected, FdIdMismatchSnafu { expected, actual }); - let fd = received.into_one().context(UnexpectedFdCountSnafu)?; - let stream = - unix_stream_from_std(std::os::unix::net::UnixStream::from(fd)).context(FromFdSnafu)?; - Ok(stream.into_split()) - } -} - -impl super::ManageSessionStream for IpcManageStreamHandle { - type StreamReader = OwnedReadHalf; - type StreamWriter = OwnedWriteHalf; - type Error = IpcManageStreamError; - - async fn open_stream(&self) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { - let receiver = self.fd_transfer.receive(); - let fd_id = receiver.id(); - self.resolve_stream( - IpcManageSessionStream::open_stream(&self.rpc, fd_id), - receiver, - ) - .await - } - - async fn accept_stream(&self) -> Result<(OwnedReadHalf, OwnedWriteHalf), IpcManageStreamError> { - let receiver = self.fd_transfer.receive(); - let fd_id = receiver.id(); - self.resolve_stream( - IpcManageSessionStream::accept_stream(&self.rpc, fd_id), - receiver, - ) - .await - } -} - -// --------------------------------------------------------------------------- -// Server: IpcManageStreamAdapter -// --------------------------------------------------------------------------- - -/// Server-side adapter bridging a [`ManageSessionStream`](super::ManageSessionStream) to the -/// [`IpcManageSessionStream`] RPC trait. -/// -/// Each call opens a real managed stream, creates a Unix socketpair, spawns -/// bridge tasks, and delivers the client-side FD through the [`FdTransfer`]. -/// -/// Bridge tasks are owned by this adapter through [`AbortOnDropHandle`]. They -/// may outlive the individual RPC call that created them, but dropping the -/// adapter also drops the bridge task handles so a remoc server lifecycle -/// teardown cannot leak stream-forwarding tasks. -pub struct IpcManageStreamAdapter { - manage_stream: M, - fd_transfer: FdTransfer, - bridge_tasks: Mutex>>, -} - -impl IpcManageStreamAdapter { - pub fn new(manage_stream: M, fd_transfer: FdTransfer) -> Self { - Self { - manage_stream, - fd_transfer, - bridge_tasks: Mutex::new(Vec::new()), - } - } - - fn spawn_bridge_task(&self, task: impl Future + Send + 'static) { - let handle = AbortOnDropHandle::new(tokio::spawn(task.in_current_span())); - self.bridge_tasks - .lock() - .expect("bridge task registry should not be poisoned") - .push(handle); - } - - async fn bridge_and_deliver( - &self, - delivery: FdDelivery, - reader: R, - mut writer: W, - fd_id: VarInt, - ) -> Result - where - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, - { - let (srv, cli) = - std::os::unix::net::UnixStream::pair().map_err(|e| to_conn_error(e, "socketpair"))?; - cli.set_nonblocking(true) - .map_err(|e| to_conn_error(e, "set_nonblocking"))?; - let srv = unix_stream_from_std(srv).map_err(|e| to_conn_error(e, "from_std"))?; - let (srv_read, srv_write) = srv.into_split(); - - let mut fds = h3x::ipc::transport::FdVec::new(); - fds.push(cli.into()); - if let Err(error) = delivery.deliver(fds).await { - let _ = writer.shutdown().await; - return Err(to_conn_error(error, "deliver fds")); - } - - self.spawn_bridge_task(bridge_reader_to_unix(reader, srv_write)); - self.spawn_bridge_task(bridge_unix_to_writer(srv_read, writer)); - - Ok(fd_id) - } -} - -impl IpcManageSessionStream for IpcManageStreamAdapter -where - M: super::ManageSessionStream + 'static, - M::StreamReader: AsyncRead + Unpin + Send + 'static, - M::StreamWriter: AsyncWrite + Unpin + Send + 'static, - M::Error: Send + Sync + 'static, -{ - async fn open_stream(&self, fd_id: VarInt) -> Result { - let delivery = self.fd_transfer.delivery(fd_id); - let (reader, writer) = self - .manage_stream - .open_stream() - .await - .map_err(manage_stream_error_to_connection_error)?; - self.bridge_and_deliver(delivery, reader, writer, fd_id) - .await - } - - async fn accept_stream(&self, fd_id: VarInt) -> Result { - let delivery = self.fd_transfer.delivery(fd_id); - let (reader, writer) = self - .manage_stream - .accept_stream() - .await - .map_err(manage_stream_error_to_connection_error)?; - self.bridge_and_deliver(delivery, reader, writer, fd_id) - .await - } -} - -// --------------------------------------------------------------------------- -// Bridge helpers: QUIC stream ↔ Unix socketpair -// --------------------------------------------------------------------------- - -/// Forward bytes from an async reader to a Unix socket write half. -pub async fn bridge_reader_to_unix(mut reader: R, mut writer: OwnedWriteHalf) -where - R: AsyncRead + Unpin, -{ - let _ = tokio::io::copy(&mut reader, &mut writer).await; - let _ = writer.shutdown().await; -} - -/// Forward bytes from a Unix socket read half to an async writer. -pub async fn bridge_unix_to_writer(mut reader: OwnedReadHalf, mut writer: W) -where - W: AsyncWrite + Unpin, -{ - let _ = tokio::io::copy(&mut reader, &mut writer).await; - let _ = writer.shutdown().await; -} - -// --------------------------------------------------------------------------- -// Control stream bridge helpers (used by sshd.rs) -// --------------------------------------------------------------------------- - -/// Forward data from an h3x message read stream to a Unix socket write half. -/// -/// Used for the SSH3 control channel: QUIC CONNECT upgrade data → child process. -pub async fn bridge_message_reader_to_unix( - mut reader: impl futures::Stream> + Unpin, - mut writer: OwnedWriteHalf, -) { - while let Some(Ok(chunk)) = reader.next().await { - if writer.write_all(&chunk).await.is_err() { - break; - } - } - let _ = writer.shutdown().await; -} - -/// Forward data from a Unix socket read half to an h3x message write sink. -/// -/// Used for the SSH3 control channel: child process → QUIC CONNECT upgrade data. -pub async fn bridge_unix_to_message_writer( - mut reader: OwnedReadHalf, - mut writer: impl futures::Sink + Unpin, -) { - use tokio::io::AsyncReadExt; - - let mut buf = BytesMut::with_capacity(8192); - loop { - buf.reserve(8192); - match reader.read_buf(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(_) => { - if writer.send(buf.split().freeze()).await.is_err() { - break; - } - } - } - } - let _ = writer.close().await; -} - -// --------------------------------------------------------------------------- -// Error helpers -// --------------------------------------------------------------------------- - -fn to_conn_error(err: impl std::error::Error, context: &str) -> ConnectionError { - tracing::warn!(error = %snafu::Report::from_error(&err), context, "ipc manage stream error"); - h3x::quic::ApplicationError { - code: h3x::error::Code::from(VarInt::from_u32(0)), - reason: std::borrow::Cow::Owned(format!("ipc {context}: {err}")), - } - .into() -} - -fn manage_stream_error_to_connection_error(error: E) -> ConnectionError -where - E: std::error::Error + Send + Sync + 'static, -{ - h3x::quic::ApplicationError { - code: h3x::error::Code::from(VarInt::from_u32(0)), - reason: std::borrow::Cow::Owned(snafu::Report::from_error(&error).to_string()), - } - .into() -} - -#[cfg(test)] -mod tests { - use std::{ - future::IntoFuture, - os::fd::OwnedFd, - sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }, - time::Duration, - }; - - use h3x::ipc::transport::MuxChannel; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - use super::{IpcManageSessionStream, IpcManageStreamAdapter, unix_stream_from_std}; - use crate::conversation::ManageSessionStream; - - #[derive(Clone, Debug)] - struct MockManageStream { - open_calls: Arc, - } - - impl ManageSessionStream for MockManageStream { - type StreamReader = tokio::io::Empty; - type StreamWriter = tokio::io::Sink; - type Error = std::io::Error; - - async fn open_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.open_calls.fetch_add(1, Ordering::SeqCst); - Ok((tokio::io::empty(), tokio::io::sink())) - } - - async fn accept_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - Ok((tokio::io::empty(), tokio::io::sink())) - } - } - - #[derive(Clone, Debug, Default)] - struct BlockingManageStream { - held_peers: Arc>>, - } - - impl ManageSessionStream for BlockingManageStream { - type StreamReader = tokio::io::DuplexStream; - type StreamWriter = tokio::io::DuplexStream; - type Error = std::io::Error; - - async fn open_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let (reader, reader_peer) = tokio::io::duplex(64); - let (writer_peer, writer) = tokio::io::duplex(64); - let mut held_peers = self.held_peers.lock().expect("held peer lock"); - held_peers.push(reader_peer); - held_peers.push(writer_peer); - Ok((reader, writer)) - } - - async fn accept_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.open_stream().await - } - } - - fn mux_pair() -> (MuxChannel, MuxChannel) { - let (left, right) = std::os::unix::net::UnixStream::pair().expect("socketpair"); - let left = MuxChannel::from_fd(OwnedFd::from(left)).expect("left mux channel"); - let right = MuxChannel::from_fd(OwnedFd::from(right)).expect("right mux channel"); - (left, right) - } - - #[tokio::test] - async fn ipc_adapter_accepts_generic_manage_session_stream() { - let open_calls = Arc::new(AtomicUsize::new(0)); - let manage_stream = MockManageStream { - open_calls: open_calls.clone(), - }; - let (server_mux, client_mux) = mux_pair(); - let (server_sink, server_stream) = server_mux.split().expect("server mux split"); - let server_fd_transfer = server_stream.fd_transfer(server_sink.fd_sender()); - let (client_sink, client_stream) = client_mux.split().expect("client mux split"); - let client_fd_transfer = client_stream.fd_transfer(client_sink.fd_sender()); - let adapter = IpcManageStreamAdapter::new(manage_stream, server_fd_transfer); - let receiver = client_fd_transfer.receive(); - let fd_id = receiver.id(); - - let (returned, received) = { - let call = IpcManageSessionStream::open_stream(&adapter, fd_id); - let receive = receiver.into_future(); - tokio::pin!(call); - tokio::pin!(receive); - tokio::join!(call, receive) - }; - - assert_eq!(returned.expect("open stream through adapter"), fd_id); - assert_eq!(received.expect("receive delivered fd").len(), 1); - assert_eq!(open_calls.load(Ordering::SeqCst), 1); - drop((server_sink, server_stream, client_sink, client_stream)); - } - - #[tokio::test] - async fn unix_stream_from_std_accepts_default_blocking_socketpair() { - let (left, right) = std::os::unix::net::UnixStream::pair().expect("socketpair"); - let mut left = unix_stream_from_std(left).expect("left stream"); - let mut right = unix_stream_from_std(right).expect("right stream"); - - left.write_all(b"x").await.expect("write"); - let mut buf = [0_u8; 1]; - right.read_exact(&mut buf).await.expect("read"); - - assert_eq!(buf, [b'x']); - } - - #[tokio::test] - async fn dropping_ipc_adapter_aborts_owned_bridge_tasks() { - let (server_mux, client_mux) = mux_pair(); - let (server_sink, server_stream) = server_mux.split().expect("server mux split"); - let server_fd_transfer = server_stream.fd_transfer(server_sink.fd_sender()); - let (client_sink, client_stream) = client_mux.split().expect("client mux split"); - let client_fd_transfer = client_stream.fd_transfer(client_sink.fd_sender()); - - let manage_stream = BlockingManageStream::default(); - let held_peers = manage_stream.held_peers.clone(); - let adapter = IpcManageStreamAdapter::new(manage_stream, server_fd_transfer); - let receiver = client_fd_transfer.receive(); - let fd_id = receiver.id(); - let (returned, received) = { - let call = IpcManageSessionStream::open_stream(&adapter, fd_id); - let receive = receiver.into_future(); - tokio::pin!(call); - tokio::pin!(receive); - tokio::join!(call, receive) - }; - assert_eq!(returned.expect("open stream"), fd_id); - let fd = received - .expect("receive delivered fd") - .into_one() - .expect("one stream fd"); - let mut stream = - unix_stream_from_std(std::os::unix::net::UnixStream::from(fd)).expect("tokio stream"); - - let mut buf = [0_u8; 1]; - assert!( - tokio::time::timeout(Duration::from_millis(50), stream.read(&mut buf)) - .await - .is_err(), - "stream peer should remain open while adapter owns bridge tasks", - ); - - drop(adapter); - assert_eq!( - held_peers.lock().expect("held peer lock").len(), - 2, - "the test keeps the managed stream peers open after adapter drop", - ); - - let read = tokio::time::timeout(Duration::from_millis(200), stream.read(&mut buf)) - .await - .expect("bridge task drop should close the peer fd") - .expect("read after adapter drop"); - - assert_eq!(read, 0); - drop((server_sink, server_stream, client_sink, client_stream)); - } -} diff --git a/src/conversation/tests.rs b/src/conversation/tests.rs index 915c4b7..02ba68c 100644 --- a/src/conversation/tests.rs +++ b/src/conversation/tests.rs @@ -12,6 +12,7 @@ use h3x::{ codec::{SinkWriter, StreamReader as H3xStreamReader}, quic::{GetStreamId, ResetStream, StopStream, StreamError}, }; +use tokio::sync::mpsc as tokio_mpsc; // -- Mock stream types that implement h3x ReadStream / WriteStream ------ @@ -113,24 +114,85 @@ impl ResetStream for TestQuicWriter { } } -// -- Concrete types for ManageSessionStream ----------------------------- +// -- Concrete fake WebTransport session ------------------------------- type MockReader = H3xStreamReader; type MockWriter = SinkWriter; -struct TestManageStream; +#[derive(Clone)] +struct TestSession(Arc); + +struct TestSessionState { + id: StreamId, + open_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, + open_rx: std::sync::Mutex>, + accept_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, + accept_rx: std::sync::Mutex>, +} + +impl TestSession { + fn new(id: StreamId) -> Self { + let (open_tx, open_rx) = tokio_mpsc::unbounded_channel(); + let (accept_tx, accept_rx) = tokio_mpsc::unbounded_channel(); + Self(Arc::new(TestSessionState { + id, + open_tx, + open_rx: std::sync::Mutex::new(open_rx), + accept_tx, + accept_rx: std::sync::Mutex::new(accept_rx), + })) + } + + fn provide_open_stream(&self, reader: MockReader, writer: MockWriter) { + self.0.open_tx.send((reader, writer)).unwrap(); + } + + fn provide_accept_stream(&self, reader: MockReader, writer: MockWriter) { + self.0.accept_tx.send((reader, writer)).unwrap(); + } +} + +impl h3x::webtransport::Session for TestSession { + type StreamReader = TestQuicReader; + type StreamWriter = TestQuicWriter; + + fn id(&self) -> StreamId { + self.0.id + } + + async fn open_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> { + let (reader, writer) = self + .0 + .open_rx + .lock() + .unwrap() + .try_recv() + .expect("no open_bi pair provided"); + Ok((reader.into_inner(), writer.into_inner())) + } -impl ManageSessionStream for TestManageStream { - type StreamReader = MockReader; - type StreamWriter = MockWriter; - type Error = std::convert::Infallible; + async fn open_uni(&self) -> Result { + unreachable!("dssh tests use only bidirectional streams") + } - async fn open_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - unreachable!("not used in global request tests") + async fn accept_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> + { + let (reader, writer) = self + .0 + .accept_rx + .lock() + .unwrap() + .try_recv() + .expect("no accept_bi pair provided"); + Ok((reader.into_inner(), writer.into_inner())) } - async fn accept_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - unreachable!("not used in global request tests") + async fn accept_uni(&self) -> Result { + unreachable!("dssh tests use only bidirectional streams") } } @@ -148,23 +210,18 @@ fn make_half(stream_id: VarInt) -> (MockReader, MockWriter) { (reader, writer) } -async fn make_conversation() -> ( - Conversation, - MockReader, - MockWriter, -) { - let stream_id = VarInt::from_u32(42); +async fn make_conversation() -> (Conversation, MockReader, MockWriter) { + let stream_id = VarInt::from_u32(40); // local reads ← remote writes let (local_reader, remote_writer) = make_half(stream_id); // remote reads ← local writes let (remote_reader, local_writer) = make_half(stream_id); - let conv = Conversation::new( - StreamId(stream_id), + let conv = Conversation::from_control_streams( + TestSession::new(StreamId(stream_id)), "test-version", local_reader, local_writer, - TestManageStream, ); (conv, remote_reader, remote_writer) } @@ -280,7 +337,7 @@ async fn remote_send_failure(writer: &mut MockWriter) { #[tokio::test] async fn conversation_id() { let (conv, _remote_reader, _remote_writer) = make_conversation().await; - assert_eq!(conv.id(), StreamId(VarInt::from_u32(42))); + assert_eq!(conv.id(), StreamId(VarInt::from_u32(40))); } // -- request() tests ---------------------------------------------------- @@ -303,7 +360,7 @@ async fn request_success_roundtrip() { let req = TestRequest { payload: TestPayload(SshString::from("hello".to_string())), }; - let result: VarInt = conv.request(&req).await.unwrap(); + let result: VarInt = conv.send_global_request(&req).await.unwrap(); assert_eq!(result, VarInt::from_u32(99)); handle.await.unwrap(); @@ -322,7 +379,7 @@ async fn request_rejected() { let req = TestRequest { payload: TestPayload(SshString::from("hi".to_string())), }; - let result: Result = conv.request(&req).await; + let result: Result = conv.send_global_request(&req).await; assert!(matches!(result, Err(SendRequestError::Rejected))); handle.await.unwrap(); @@ -337,7 +394,7 @@ async fn notify_sends_correctly() { let notice = TestNotice { payload: TestPayload(SshString::from("world".to_string())), }; - conv.notify(¬ice).await.unwrap(); + conv.send_global_notification(¬ice).await.unwrap(); let (req_type, want_reply) = remote_read_global_request_header(&mut remote_reader).await; assert_eq!(&*req_type, "test-notice"); @@ -355,7 +412,7 @@ async fn accept_incoming_request_decode_and_respond_success() { // Remote sends a want_reply=true request remote_send_global_request(&mut remote_writer, "tcpip-forward", true, "bind-addr").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let req = match incoming { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), @@ -386,7 +443,7 @@ async fn accept_incoming_request_respond_failure() { remote_send_global_request(&mut remote_writer, "unknown-req", true, "data").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let req = match incoming { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), @@ -405,7 +462,7 @@ async fn accept_incoming_notice() { remote_send_global_request(&mut remote_writer, "keepalive", false, "ping").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let notice = match incoming { IncomingGlobal::Notify(n) => n, _ => panic!("expected Notify"), @@ -424,7 +481,7 @@ async fn drop_request_before_decode_poisons_session() { remote_send_global_request(&mut remote_writer, "test", true, "data").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let req = match incoming { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), @@ -436,7 +493,7 @@ async fn drop_request_before_decode_poisons_session() { assert!(conv.shared.poisoned.load(Ordering::SeqCst)); // Subsequent accept should fail with SessionPoisoned - let result = conv.accept().await; + let result = conv.accept_global_request().await; assert!(matches!(result, Err(AcceptError::SessionPoisoned))); } @@ -446,7 +503,7 @@ async fn drop_notice_before_decode_poisons_session() { remote_send_global_request(&mut remote_writer, "test", false, "data").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let notice = match incoming { IncomingGlobal::Notify(n) => n, _ => panic!("expected Notify"), @@ -462,7 +519,7 @@ async fn drop_request_after_decode_queues_auto_failure() { remote_send_global_request(&mut remote_writer, "test", true, "data").await; - let incoming = conv.accept().await.unwrap(); + let incoming = conv.accept_global_request().await.unwrap(); let req = match incoming { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), @@ -484,7 +541,7 @@ async fn drop_request_after_decode_queues_auto_failure() { let notice = TestNotice { payload: TestPayload(SshString::from("after".to_string())), }; - conv.notify(¬ice).await.unwrap(); + conv.send_global_notification(¬ice).await.unwrap(); // Remote should first see the auto-failure response, then the notify let msg_type: VarInt = remote_reader.decode_one().await.unwrap(); @@ -505,14 +562,14 @@ async fn incoming_request_responses_ordered_correctly() { remote_send_global_request(&mut remote_writer, "req-b", true, "b").await; // Accept both - let incoming_a = conv.accept().await.unwrap(); + let incoming_a = conv.accept_global_request().await.unwrap(); let req_a = match incoming_a { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; let decoded_a = req_a.decode_payload::().await.unwrap(); - let incoming_b = conv.accept().await.unwrap(); + let incoming_b = conv.accept_global_request().await.unwrap(); let req_b = match incoming_b { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), @@ -560,7 +617,7 @@ async fn unexpected_message_type_poisons_session() { .unwrap(); AsyncWriteExt::flush(&mut remote_writer).await.unwrap(); - let result = conv.accept().await; + let result = conv.accept_global_request().await; assert!(matches!( result, Err(AcceptError::UnexpectedMessageType { .. }) @@ -582,7 +639,7 @@ async fn concurrent_requests_ordered_correctly() { let conv_a = Arc::clone(&conv); let handle_a = tokio::spawn(async move { conv_a - .request::(&TestRequest { + .send_global_request::(&TestRequest { payload: TestPayload(SshString::from_static("alpha")), }) .await @@ -591,7 +648,7 @@ async fn concurrent_requests_ordered_correctly() { let conv_b = Arc::clone(&conv); let handle_b = tokio::spawn(async move { conv_b - .request::(&TestRequest { + .send_global_request::(&TestRequest { payload: TestPayload(SshString::from_static("beta")), }) .await @@ -646,7 +703,7 @@ async fn consecutive_auto_failures_drained_by_next_writer() { // Accept and decode all three, but DON'T respond — just drop them. // This should queue 3 auto-failure writer tickets. for _ in 0..3 { - match conv.accept().await.unwrap() { + match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(req) => { let _decoded = req.decode_payload::().await.unwrap(); // Drop DecodedGlobalRequest without responding → auto-failure queued @@ -661,7 +718,9 @@ async fn consecutive_auto_failures_drained_by_next_writer() { let notice = TestNotice { payload: TestPayload(SshString::from_static("ping")), }; - conv.notify::(¬ice).await.unwrap(); + conv.send_global_notification::(¬ice) + .await + .unwrap(); // Remote should see: 3x failure, then the notify for _ in 0..3 { @@ -685,7 +744,7 @@ async fn poisoned_session_rejects_request() { conv.shared.poison(); let result = conv - .request::(&TestRequest { + .send_global_request::(&TestRequest { payload: TestPayload(SshString::from_static("hello")), }) .await; @@ -702,7 +761,9 @@ async fn poisoned_session_rejects_notify() { let notice = TestNotice { payload: TestPayload(SshString::from_static("hello")), }; - let result = conv.notify::(¬ice).await; + let result = conv + .send_global_notification::(¬ice) + .await; assert!(matches!(result, Err(SendNotifyError::SessionPoisoned))); } @@ -713,7 +774,7 @@ async fn poisoned_session_rejects_accept() { conv.shared.poison(); - let result = conv.accept().await; + let result = conv.accept_global_request().await; assert!(matches!(result, Err(AcceptError::SessionPoisoned))); } @@ -723,7 +784,7 @@ async fn poisoned_session_rejects_respond_success() { remote_send_global_request(&mut remote_writer, "test", true, "data").await; - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -743,7 +804,7 @@ async fn poisoned_session_rejects_respond_failure() { remote_send_global_request(&mut remote_writer, "test", true, "data").await; - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -764,7 +825,7 @@ async fn respond_success_cancelled_poisons_session() { remote_send_global_request(&mut remote_writer, "test", true, "data").await; - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -817,7 +878,7 @@ async fn multiple_sequential_accepts() { // Accept, decode, and respond to each one sequentially. for i in 0..3u32 { - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -861,14 +922,16 @@ async fn interleaved_request_and_notify_on_writer() { let notice = TestNotice { payload: TestPayload(SshString::from_static("notice-1")), }; - conv.notify::(¬ice).await.unwrap(); + conv.send_global_notification::(¬ice) + .await + .unwrap(); // Now send a request // Pre-send the response so request() can complete remote_send_success(&mut remote_writer, 777).await; let result = conv - .request::(&TestRequest { + .send_global_request::(&TestRequest { payload: TestPayload(SshString::from_static("req-after-notify")), }) .await; @@ -897,7 +960,7 @@ async fn auto_failure_at_current_serving_drained_immediately() { // Accept, decode, but drop without responding → auto-failure at ticket 0 { - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -910,7 +973,9 @@ async fn auto_failure_at_current_serving_drained_immediately() { let notice = TestNotice { payload: TestPayload(SshString::from_static("after-auto")), }; - conv.notify::(¬ice).await.unwrap(); + conv.send_global_notification::(¬ice) + .await + .unwrap(); // Remote should see: failure response, then the notify let msg_type: VarInt = remote_reader.decode_one().await.unwrap(); @@ -935,7 +1000,7 @@ async fn auto_failures_interleaved_with_real_responses() { // Accept all 4 let mut decoded_reqs: Vec> = Vec::new(); for _ in 0..4 { - match conv.accept().await.unwrap() { + match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => { let decoded = r.decode_payload::().await.unwrap(); decoded_reqs.push(decoded); @@ -964,7 +1029,9 @@ async fn auto_failures_interleaved_with_real_responses() { let notice = TestNotice { payload: TestPayload(SshString::from_static("end")), }; - conv.notify::(¬ice).await.unwrap(); + conv.send_global_notification::(¬ice) + .await + .unwrap(); // Remote expects: success(1), failure(B-auto), failure(C), failure(D-auto), notify let msg_a: VarInt = remote_reader.decode_one().await.unwrap(); @@ -1104,7 +1171,7 @@ async fn accept_alternating_requests_and_notices() { // Accept #1: request { - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -1115,7 +1182,7 @@ async fn accept_alternating_requests_and_notices() { // Accept #2: notice { - let ntf = match conv.accept().await.unwrap() { + let ntf = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Notify(n) => n, _ => panic!("expected Notify"), }; @@ -1125,7 +1192,7 @@ async fn accept_alternating_requests_and_notices() { // Accept #3: request { - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -1136,7 +1203,7 @@ async fn accept_alternating_requests_and_notices() { // Accept #4: notice { - let ntf = match conv.accept().await.unwrap() { + let ntf = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Notify(n) => n, _ => panic!("expected Notify"), }; @@ -1165,7 +1232,7 @@ async fn decode_payload_success_releases_reader() { remote_send_global_request(&mut remote_writer, "second", false, "y").await; // Accept and decode first request - let req = match conv.accept().await.unwrap() { + let req = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Request(r) => r, _ => panic!("expected Request"), }; @@ -1174,7 +1241,7 @@ async fn decode_payload_success_releases_reader() { // After decode_payload succeeds, the reader is released and we can // accept the next message without blocking. - let ntf = match conv.accept().await.unwrap() { + let ntf = match conv.accept_global_request().await.unwrap() { IncomingGlobal::Notify(n) => n, _ => panic!("expected Notify"), }; @@ -1232,7 +1299,9 @@ async fn request_with_empty_payload() { // No success body — EmptyPayload reads nothing AsyncWriteExt::flush(&mut remote_writer).await.unwrap(); - let result = conv.request::(&EmptyRequest).await; + let result = conv + .send_global_request::(&EmptyRequest) + .await; assert!(result.is_ok()); // Remote reads the request header and (empty) payload @@ -1246,121 +1315,28 @@ async fn request_with_empty_payload() { // Channel tests // ======================================================================= -// -- Channel-capable ManageSessionStream --------------------------------- +// -- Channel-capable fake WebTransport session --------------------------- -use tokio::sync::mpsc as tokio_mpsc; - -/// A ManageSessionStream impl that delivers pre-created stream pairs via -/// channels, allowing tests to control what the "remote" sends/receives. -struct ChannelManageStream { - /// Sender for streams returned by open_stream(). - open_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, - open_rx: std::sync::Mutex>, - /// Sender for streams returned by accept_stream(). - accept_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, - accept_rx: std::sync::Mutex>, -} - -impl ChannelManageStream { - fn new() -> Self { - let (open_tx, open_rx) = tokio_mpsc::unbounded_channel(); - let (accept_tx, accept_rx) = tokio_mpsc::unbounded_channel(); - Self { - open_tx, - open_rx: std::sync::Mutex::new(open_rx), - accept_tx, - accept_rx: std::sync::Mutex::new(accept_rx), - } - } - - /// Enqueue a stream pair that open_stream() will return. - fn provide_open_stream(&self, reader: MockReader, writer: MockWriter) { - self.open_tx.send((reader, writer)).unwrap(); - } - - /// Enqueue a stream pair that accept_stream() will return. - fn provide_accept_stream(&self, reader: MockReader, writer: MockWriter) { - self.accept_tx.send((reader, writer)).unwrap(); - } -} - -impl ManageSessionStream for ChannelManageStream { - type StreamReader = MockReader; - type StreamWriter = MockWriter; - type Error = std::convert::Infallible; - - async fn open_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let pair = self - .open_rx - .lock() - .unwrap() - .try_recv() - .expect("no open_stream pair provided"); - Ok(pair) - } - - async fn accept_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let pair = self - .accept_rx - .lock() - .unwrap() - .try_recv() - .expect("no accept_stream pair provided"); - Ok(pair) - } -} - -/// Create a Conversation backed by ChannelManageStream plus the control -/// stream remote ends. +/// Create a Conversation backed by a fake WebTransport session plus the +/// control stream remote ends. async fn make_channel_conversation() -> ( - Conversation< - impl ManageSessionStream< - StreamReader = MockReader, - StreamWriter = MockWriter, - Error = std::convert::Infallible, - >, - MockReader, - MockWriter, - >, + Conversation, MockReader, MockWriter, - Arc, + TestSession, ) { - let stream_id = VarInt::from_u32(42); + let stream_id = VarInt::from_u32(40); let (local_reader, remote_writer) = make_half(stream_id); let (remote_reader, local_writer) = make_half(stream_id); - let manage = Arc::new(ChannelManageStream::new()); - - // We need to pass the manage stream by value. Create a wrapper that - // delegates to the Arc'd version. - struct ArcManage(Arc); - impl ManageSessionStream for ArcManage { - type StreamReader = MockReader; - type StreamWriter = MockWriter; - type Error = std::convert::Infallible; - - async fn open_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.0.open_stream().await - } - - async fn accept_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.0.accept_stream().await - } - } - - let conv = Conversation::new( - StreamId(stream_id), + let session = TestSession::new(StreamId(stream_id)); + let conv = Conversation::from_control_streams( + session.clone(), "test-version", local_reader, local_writer, - ArcManage(Arc::clone(&manage)), ); - (conv, remote_reader, remote_writer, manage) + (conv, remote_reader, remote_writer, session) } // -- Test ChannelOpen implementation ------------------------------------ @@ -1409,14 +1385,14 @@ impl EncodeInto for SessionChannel { #[tokio::test] async fn open_channel_roundtrip() { - let (conv, _remote_reader, _remote_writer, manage) = make_channel_conversation().await; + let (conv, _remote_reader, _remote_writer, session) = make_channel_conversation().await; // Create a channel stream pair: the "channel reader/writer" that the // remote side will use. let ch_stream_id = VarInt::from_u32(100); let (ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); let (ch_local_reader, mut ch_remote_writer) = make_half(ch_stream_id); - manage.provide_open_stream(ch_local_reader, ch_local_writer); + session.provide_open_stream(ch_local_reader, ch_local_writer); let max_msg_size = VarInt::from_u32(1 << 20); let channel = TestChannel { @@ -1427,6 +1403,9 @@ async fn open_channel_roundtrip() { let remote_task = tokio::spawn(async move { let mut rr = ch_remote_reader; + let kind: VarInt = rr.decode_one().await.unwrap(); + assert_eq!(kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); + let mms: VarInt = rr.decode_one().await.unwrap(); assert_eq!(mms, max_msg_size); @@ -1458,16 +1437,19 @@ async fn open_channel_roundtrip() { #[tokio::test] async fn accept_channel_roundtrip() { - let (conv, _remote_reader, _remote_writer, manage) = make_channel_conversation().await; + let (conv, _remote_reader, _remote_writer, session) = make_channel_conversation().await; let ch_stream_id = VarInt::from_u32(200); let (ch_local_reader, ch_remote_writer) = make_half(ch_stream_id); let (_ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); // Remote encodes channel data starting at max_message_size - // (signal value and session ID are handled by ManageSessionStream). + // (WebTransport session prefix is handled by h3x; DSSH stream kind is part of this test). let mut rw = ch_remote_writer; let max_msg_size = VarInt::from_u32(1 << 20); + rw.encode_one(crate::webtransport::DSSH_CHANNEL_STREAM_KIND) + .await + .unwrap(); rw.encode_one(max_msg_size).await.unwrap(); rw.encode_one(SshString::from_static("test-channel")) .await @@ -1477,8 +1459,8 @@ async fn accept_channel_roundtrip() { .unwrap(); AsyncWriteExt::flush(&mut rw).await.unwrap(); - // accept_stream will return the local side of the channel - manage.provide_accept_stream(ch_local_reader, ch_local_writer); + // accept_bi will return the local side of the channel + session.provide_accept_stream(ch_local_reader, ch_local_writer); let incoming = conv .accept_channel() @@ -1499,22 +1481,25 @@ async fn accept_channel_roundtrip() { #[tokio::test] async fn accept_channel_session_no_payload() { - let (conv, _remote_reader, _remote_writer, manage) = make_channel_conversation().await; + let (conv, _remote_reader, _remote_writer, session) = make_channel_conversation().await; let ch_stream_id = VarInt::from_u32(300); let (ch_local_reader, ch_remote_writer) = make_half(ch_stream_id); let (_ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); // Remote sends channel data starting at max_message_size - // (signal value and session ID are handled by ManageSessionStream). + // (WebTransport session prefix is handled by h3x; DSSH stream kind is part of this test). let mut rw = ch_remote_writer; + rw.encode_one(crate::webtransport::DSSH_CHANNEL_STREAM_KIND) + .await + .unwrap(); rw.encode_one(VarInt::from_u32(1 << 20)).await.unwrap(); rw.encode_one(SshString::from_static("session")) .await .unwrap(); AsyncWriteExt::flush(&mut rw).await.unwrap(); - manage.provide_accept_stream(ch_local_reader, ch_local_writer); + session.provide_accept_stream(ch_local_reader, ch_local_writer); let incoming = conv.accept_channel().await.unwrap(); assert_eq!(incoming.channel_type().as_ref() as &[u8], b"session"); @@ -1525,18 +1510,21 @@ async fn accept_channel_session_no_payload() { #[tokio::test] async fn open_channel_session_no_payload() { - let (conv, _remote_reader, _remote_writer, manage) = make_channel_conversation().await; + let (conv, _remote_reader, _remote_writer, session) = make_channel_conversation().await; let ch_stream_id = VarInt::from_u32(600); let (ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); let (ch_local_reader, mut ch_remote_writer) = make_half(ch_stream_id); - manage.provide_open_stream(ch_local_reader, ch_local_writer); + session.provide_open_stream(ch_local_reader, ch_local_writer); // Remote reads header and sends confirmation. let remote_task = tokio::spawn(async move { let mut rr = ch_remote_reader; + let kind: VarInt = rr.decode_one().await.unwrap(); + assert_eq!(kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); + let mms: VarInt = rr.decode_one().await.unwrap(); assert_eq!(mms, VarInt::from_u32(1 << 20)); @@ -1567,57 +1555,38 @@ async fn open_channel_session_no_payload() { #[tokio::test] async fn open_and_accept_channel_full_roundtrip() { // Test open on one side, accept on the other. - let stream_id = VarInt::from_u32(42); + let stream_id = VarInt::from_u32(40); // Create two conversations sharing a control stream. let (ctrl_a_reader, ctrl_b_writer) = make_half(stream_id); let (ctrl_b_reader, ctrl_a_writer) = make_half(stream_id); - let manage_a = Arc::new(ChannelManageStream::new()); - let manage_b = Arc::new(ChannelManageStream::new()); - - struct ArcManage2(Arc); - impl ManageSessionStream for ArcManage2 { - type StreamReader = MockReader; - type StreamWriter = MockWriter; - type Error = std::convert::Infallible; - async fn open_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.0.open_stream().await - } - async fn accept_stream( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.0.accept_stream().await - } - } + let session_a = TestSession::new(StreamId(stream_id)); + let session_b = TestSession::new(StreamId(stream_id)); - let conv_a = Conversation::new( - StreamId(stream_id), + let conv_a = Conversation::from_control_streams( + session_a.clone(), "test-version", ctrl_a_reader, ctrl_a_writer, - ArcManage2(Arc::clone(&manage_a)), ); - let conv_b = Conversation::new( - StreamId(stream_id), + let conv_b = Conversation::from_control_streams( + session_b.clone(), "test-version", ctrl_b_reader, ctrl_b_writer, - ArcManage2(Arc::clone(&manage_b)), ); // Create the channel stream: A opens, B accepts. - // A's open_stream returns (ch_a_reader, ch_a_writer). - // B's accept_stream returns (ch_b_reader, ch_b_writer). + // A's open_bi returns (ch_a_reader, ch_a_writer). + // B's accept_bi returns (ch_b_reader, ch_b_writer). // We need ch_a_writer → ch_b_reader and ch_b_writer → ch_a_reader. let ch_id = VarInt::from_u32(700); let (ch_b_reader, ch_a_writer) = make_half(ch_id); let (ch_a_reader, ch_b_writer) = make_half(ch_id); - manage_a.provide_open_stream(ch_a_reader, ch_a_writer); - manage_b.provide_accept_stream(ch_b_reader, ch_b_writer); + session_a.provide_open_stream(ch_a_reader, ch_a_writer); + session_b.provide_accept_stream(ch_b_reader, ch_b_writer); let max_msg = VarInt::from_u32(1 << 20); let channel = TestChannel { diff --git a/src/forward/client.rs b/src/forward/client.rs index 5651c81..82e87f1 100644 --- a/src/forward/client.rs +++ b/src/forward/client.rs @@ -14,7 +14,7 @@ use tracing::Instrument; use crate::codec::SshString; use crate::constants::DEFAULT_MAX_MESSAGE_SIZE; -use crate::conversation::{Conversation, ManageSessionStream}; +use crate::conversation::Conversation; use crate::forward::{ DirectStreamlocal, DirectTcpip, ForwardedStreamlocal, ForwardedTcpip, StreamlocalForwardGlobalRequest, StreamlocalForwardRequest, TcpipForwardGlobalRequest, @@ -82,16 +82,14 @@ impl LocalForward { /// /// This function runs an infinite accept loop and only returns if the /// initial bind fails. - pub async fn run( + pub async fn run( &self, - conversation: Arc>, + conversation: Arc>, ) -> Result where - M: ManageSessionStream + 'static, - M::StreamReader: 'static, - M::StreamWriter: 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { match &self.bind { Endpoint::Tcp { host, port } => { @@ -120,17 +118,15 @@ impl LocalForward { } } - async fn accept_loop_tcp( + async fn accept_loop_tcp( &self, listener: tokio::net::TcpListener, - conversation: Arc>, + conversation: Arc>, ) -> Result where - M: ManageSessionStream + 'static, - M::StreamReader: 'static, - M::StreamWriter: 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { @@ -152,17 +148,15 @@ impl LocalForward { } #[cfg(unix)] - async fn accept_loop_unix( + async fn accept_loop_unix( &self, listener: tokio::net::UnixListener, - conversation: Arc>, + conversation: Arc>, ) -> Result where - M: ManageSessionStream + 'static, - M::StreamReader: 'static, - M::StreamWriter: 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { @@ -184,17 +178,15 @@ impl LocalForward { } /// Open an SSH channel to the connect endpoint and relay data bidirectionally. -async fn open_channel_and_relay( - conversation: Arc>, +async fn open_channel_and_relay( + conversation: Arc>, connect: Endpoint, local_reader: Pin>, local_writer: Pin>, ) where - M: ManageSessionStream + 'static, - M::StreamReader: 'static, - M::StreamWriter: 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let channel_result = match &connect { Endpoint::Tcp { host, port } => { @@ -247,14 +239,12 @@ impl RemoteForward { /// Send a global request to the server to start listening on the remote /// bind endpoint. Returns the established binding info (including any /// server-allocated port). - pub async fn request( + pub async fn request( &self, - conversation: &Conversation, + conversation: &Conversation, ) -> Result where - M: ManageSessionStream, - R: AsyncRead + Unpin + Send, - W: AsyncWrite + Unpin + Send, + S: h3x::webtransport::Session, { use request_remote_forward_error::*; @@ -267,7 +257,7 @@ impl RemoteForward { }, }; let reply: TcpipForwardReply = conversation - .request(&request) + .send_global_request(&request) .await .map_err(|e| RequestError { message: e.to_string(), @@ -296,7 +286,7 @@ impl RemoteForward { }, }; conversation - .request(&request) + .send_global_request(&request) .await .map_err(|e| RequestError { message: e.to_string(), @@ -354,15 +344,13 @@ pub async fn connect_locally( /// based on the provided mappings. /// /// Runs until the conversation's channel accept stream ends. -pub async fn accept_forwarded_channels( - conversation: Arc>, +pub async fn accept_forwarded_channels( + conversation: Arc>, mappings: Vec, ) where - M: ManageSessionStream + 'static, - M::StreamReader: 'static, - M::StreamWriter: 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let mut tasks = tokio::task::JoinSet::new(); loop { diff --git a/src/forward/reverse.rs b/src/forward/reverse.rs index f084d53..7fae049 100644 --- a/src/forward/reverse.rs +++ b/src/forward/reverse.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use crate::{ constants::DEFAULT_MAX_MESSAGE_SIZE, conversation::global::{DecodedGlobalRequest, RespondSuccessError}, - conversation::{ChannelOpen, Conversation, ManageSessionStream}, + conversation::{ChannelOpen, Conversation}, forward::{ ForwardError, ForwardedStreamlocal, ForwardedTcpip, StreamlocalForwardRequest, TcpipForwardReply, TcpipForwardRequest, relay, @@ -95,13 +95,11 @@ impl TcpForwardListener { /// /// Runs until the listener encounters an accept error. Cancel the /// enclosing task to stop the listener. - pub async fn run(self, conversation: Arc>) + pub async fn run(self, conversation: Arc>) where - M: ManageSessionStream + 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, - M::StreamReader: AsyncRead + Send + Unpin + 'static, - M::StreamWriter: AsyncWrite + Send + Unpin + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let connected_port = self.bound_addr.port(); let connected_addr = self.bound_addr.ip().to_string(); @@ -170,13 +168,11 @@ impl UnixForwardListener { /// Runs until the listener encounters an accept error. Cancel the /// enclosing task to stop the listener. The socket file is removed /// when this future is dropped (including on cancellation). - pub async fn run(self, conversation: Arc>) + pub async fn run(self, conversation: Arc>) where - M: ManageSessionStream + 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, - M::StreamReader: AsyncRead + Send + Unpin + 'static, - M::StreamWriter: AsyncWrite + Send + Unpin + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { let _guard = self.guard; let socket_path = &_guard.0; @@ -308,22 +304,25 @@ where // Conversation helper: open channel and relay // --------------------------------------------------------------------------- -impl Conversation +impl Conversation where - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, - M::StreamReader: AsyncRead + Send + Unpin + 'static, - M::StreamWriter: AsyncWrite + Send + Unpin + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { /// Open a channel for reverse forwarding and relay `local_stream` /// through it bidirectionally. /// /// On failure to open the channel, logs a warning and returns silently. - pub(crate) async fn open_channel_and_relay(&self, channel_open: C, local_stream: S) + pub(crate) async fn open_channel_and_relay(&self, channel_open: C, local_stream: T) where C: ChannelOpen, - for<'w> C: EncodeInto<&'w mut M::StreamWriter, Output = (), Error = ForwardError>, - S: AsyncRead + AsyncWrite + Send + Unpin + 'static, + for<'w> C: EncodeInto< + &'w mut h3x::codec::SinkWriter, + Output = (), + Error = ForwardError, + >, + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, { let (reader, writer) = match self .open_channel(&channel_open, DEFAULT_MAX_MESSAGE_SIZE) @@ -352,74 +351,213 @@ where #[cfg(test)] mod tests { use super::*; - use h3x::stream_id::StreamId; - use h3x::varint::VarInt; + use bytes::Bytes; + use futures::{Sink, Stream, channel::mpsc}; + use h3x::{ + codec::{SinkWriter, StreamReader as H3xStreamReader}, + quic::{GetStreamId, ResetStream, StopStream, StreamError}, + stream_id::StreamId, + varint::VarInt, + }; + use std::pin::Pin; + use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; - use tokio::io::{DuplexStream, duplex}; - use tokio::sync::Mutex as AsyncMutex; + use std::task::{Context, Poll}; + + struct TestQuicReader { + stream_id: VarInt, + inner: mpsc::Receiver, + } + + impl Unpin for TestQuicReader {} + + impl Stream for TestQuicReader { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_next(cx) + .map(|opt| opt.map(Ok)) + } + } + + impl GetStreamId for TestQuicReader { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } + } + + impl StopStream for TestQuicReader { + fn poll_stop( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + struct TestQuicWriter { + stream_id: VarInt, + inner: mpsc::Sender, + } + + impl Unpin for TestQuicWriter {} + + impl Sink for TestQuicWriter { + type Error = StreamError; + + fn poll_ready( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner) + .poll_ready(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn start_send(mut self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { + Pin::new(&mut self.inner) + .start_send(item) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner) + .poll_flush(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn poll_close( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.inner) + .poll_close(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + } + + impl GetStreamId for TestQuicWriter { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } + } + + impl ResetStream for TestQuicWriter { + fn poll_reset( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + type MockReader = H3xStreamReader; + type MockWriter = SinkWriter; + + fn make_half(stream_id: VarInt) -> (MockReader, MockWriter) { + let (tx, rx) = mpsc::channel(64); + let reader = H3xStreamReader::new(TestQuicReader { + stream_id, + inner: rx, + }); + let writer = SinkWriter::new(TestQuicWriter { + stream_id, + inner: tx, + }); + (reader, writer) + } struct MockStreamState { - pairs: AsyncMutex>, + pairs: Mutex>, open_called: AtomicBool, } impl MockStreamState { fn new() -> Self { Self { - pairs: AsyncMutex::new(Vec::new()), + pairs: Mutex::new(Vec::new()), open_called: AtomicBool::new(false), } } - async fn provide_pair(&self, reader: DuplexStream, writer: DuplexStream) { - self.pairs.lock().await.push((reader, writer)); + fn provide_pair(&self, reader: MockReader, writer: MockWriter) { + self.pairs.lock().unwrap().push((reader, writer)); } } - impl ManageSessionStream for MockStreamState { - type StreamReader = DuplexStream; - type StreamWriter = DuplexStream; - type Error = std::io::Error; + #[derive(Clone)] + struct TestSession(Arc); + + impl h3x::webtransport::Session for TestSession { + type StreamReader = TestQuicReader; + type StreamWriter = TestQuicWriter; + + fn id(&self) -> StreamId { + StreamId(VarInt::from_u32(40)) + } - async fn open_stream(&self) -> Result<(DuplexStream, DuplexStream), std::io::Error> { - self.open_called.store(true, Ordering::SeqCst); - self.pairs + async fn open_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> + { + self.0.open_called.store(true, Ordering::SeqCst); + let (reader, writer) = self + .0 + .pairs .lock() - .await + .unwrap() .pop() - .ok_or_else(|| std::io::Error::other("no pairs enqueued")) + .expect("no pairs enqueued"); + Ok((reader.into_inner(), writer.into_inner())) } - async fn accept_stream(&self) -> Result<(DuplexStream, DuplexStream), std::io::Error> { - std::future::pending().await + async fn open_uni(&self) -> Result { + unreachable!("dssh reverse tests use only bidirectional streams") } - } - - struct ArcMock(Arc); - - impl ManageSessionStream for ArcMock { - type StreamReader = DuplexStream; - type StreamWriter = DuplexStream; - type Error = std::io::Error; - async fn open_stream(&self) -> Result<(DuplexStream, DuplexStream), std::io::Error> { - self.0.open_stream().await + async fn accept_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> + { + std::future::pending().await } - async fn accept_stream(&self) -> Result<(DuplexStream, DuplexStream), std::io::Error> { - self.0.accept_stream().await + async fn accept_uni( + &self, + ) -> Result { + unreachable!("dssh reverse tests use only bidirectional streams") } } - fn make_conversation( - mock: Arc, - ) -> Arc> { - Arc::new(Conversation::new( - StreamId(VarInt::from_u32(1)), + fn make_conversation(mock: Arc) -> Arc> { + let stream_id = VarInt::from_u32(40); + let (local_reader, _remote_writer) = make_half(stream_id); + let (_remote_reader, local_writer) = make_half(stream_id); + Arc::new(Conversation::from_control_streams( + TestSession(mock), "test", - tokio::io::empty(), - tokio::io::sink(), - ArcMock(mock), + local_reader, + local_writer, )) } @@ -448,9 +586,10 @@ mod tests { let port = listener.bound_addr().port(); let handle = tokio::spawn(listener.run(Arc::clone(&conv))); - let (local_rd, remote_wr) = duplex(8192); - let (remote_rd, local_wr) = duplex(8192); - mock.provide_pair(local_rd, local_wr).await; + let stream_id = VarInt::from_u32(44); + let (remote_rd, local_wr) = make_half(stream_id); + let (local_rd, remote_wr) = make_half(stream_id); + mock.provide_pair(local_rd, local_wr); let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)) .await @@ -460,6 +599,8 @@ mod tests { let mut remote_rd = remote_rd; let mut remote_wr = remote_wr; + let stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); + assert_eq!(stream_kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); let _connected_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); @@ -468,7 +609,7 @@ mod tests { let _orig_port: VarInt = remote_rd.decode_one().await.unwrap(); use h3x::codec::EncodeExt; - remote_wr.encode_one(VarInt::from_u32(0)).await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(91)).await.unwrap(); remote_wr.encode_one(VarInt::from_u32(32768)).await.unwrap(); remote_wr.flush().await.unwrap(); diff --git a/src/session/dispatcher.rs b/src/session/dispatcher.rs index 531cd74..f1ca21e 100644 --- a/src/session/dispatcher.rs +++ b/src/session/dispatcher.rs @@ -35,7 +35,7 @@ use tokio::task::{AbortHandle, JoinSet}; use crate::channel::reason_code; use crate::conversation::channel::{ChannelEvent, ReadChannelEventError, SshChannel}; use crate::conversation::global::IncomingGlobal; -use crate::conversation::{Conversation, EmptyPayload, ManageSessionStream}; +use crate::conversation::{Conversation, EmptyPayload}; use crate::forward::{ CancelStreamlocalForwardRequest, CancelTcpipForwardRequest, ForwardError, StreamlocalForwardRequest, TcpipForwardRequest, @@ -75,6 +75,25 @@ impl Default for SessionConfig { } } +/// Result of the server-side session dispatcher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunSessionOutcome { + /// The conversation accept paths closed before a session channel completed. + ConversationClosed, + /// At least one session channel ran and all channel tasks completed. + SessionFinished, +} + +/// Error returned by [`run_session`]. +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RunSessionError { + #[snafu(display("session channel failed"))] + SessionChannel { + source: crate::session::process::ProcessError, + }, +} + // --------------------------------------------------------------------------- // Session setup — read exec/shell/pty-req before spawning a process // --------------------------------------------------------------------------- @@ -253,25 +272,29 @@ where /// Concurrently accepts channels and global requests from the conversation, /// dispatching each to the appropriate handler. Returns when the conversation /// is closed (both accept methods return errors indicating shutdown). -pub async fn run_session(conversation: Arc>, config: SessionConfig) +pub async fn run_session( + conversation: Arc>, + config: SessionConfig, +) -> Result where - M: ManageSessionStream + 'static, - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, - M::StreamReader: AsyncRead + Send + Unpin + 'static, - M::StreamWriter: AsyncWrite + Send + Unpin + 'static, - M::Error: Send + Sync + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, { + use run_session_error::*; + let mut tcp_forwards: HashMap<(String, u16), AbortHandle> = HashMap::new(); let mut unix_forwards: HashMap = HashMap::new(); - let mut channel_tasks: JoinSet<()> = JoinSet::new(); + let mut channel_tasks: JoinSet> = + JoinSet::new(); let mut forward_tasks: JoinSet<()> = JoinSet::new(); let mut had_session = false; + let mut outcome = RunSessionOutcome::ConversationClosed; // Pin the accept() future so it survives across select! iterations. // accept() is NOT cancellation-safe: it eagerly takes a reader ticket, // and if cancelled before completing, the ticket blocks subsequent reads. - let mut accept_global = std::pin::pin!(conversation.accept()); + let mut accept_global = std::pin::pin!(conversation.accept_global_request()); // Pin accept_channel() for the same reason: it calls accept_stream() // then decode_one(). If cancelled between the two, the stream is lost. @@ -304,7 +327,7 @@ where Ok(ch) => ch, Err(e) => { tracing::warn!(error = %snafu::Report::from_error(&e), "failed to accept session channel"); - return; + return Ok(()); } }; @@ -313,7 +336,7 @@ where Ok(s) => s, Err(e) => { tracing::warn!(error = %snafu::Report::from_error(&e), "session setup failed"); - return; + return Ok(()); } }; @@ -325,7 +348,7 @@ where send_motd(&mut channel, &config.user.home).await; } - let result = if let Some(pty) = setup.pty { + if let Some(pty) = setup.pty { let mode = if setup.is_shell { CommandMode::Shell { shell } } else { @@ -339,10 +362,6 @@ where CommandMode::Exec { shell, command: &setup.command } }; super::process::run_piped(channel, mode, &config, term, &setup.client_env).await - }; - - if let Err(e) = result { - tracing::warn!(error = %snafu::Report::from_error(&e), "session channel error"); } }.instrument(tracing::info_span!("session"))); } @@ -352,6 +371,7 @@ where if let Err(e) = crate::forward::direct::handle_direct_tcpip(reader, writer).await { tracing::warn!(error = %snafu::Report::from_error(&e), "direct-tcpip error"); } + Ok(()) }.instrument(tracing::info_span!("direct-tcpip"))); } "direct-streamlocal@openssh.com" => { @@ -360,6 +380,7 @@ where if let Err(e) = crate::forward::direct::handle_direct_streamlocal(reader, writer).await { tracing::warn!(error = %snafu::Report::from_error(&e), "direct-streamlocal error"); } + Ok(()) }.instrument(tracing::info_span!("direct-streamlocal"))); } "socks5" => { @@ -375,6 +396,7 @@ where if let Err(e) = crate::forward::socks5::handle_socks5(reader, writer).await { tracing::warn!(error = %snafu::Report::from_error(&e), "socks5 error"); } + Ok(()) }.instrument(tracing::info_span!("socks5"))); } _ => { @@ -397,7 +419,7 @@ where Ok(incoming) => { dispatch_global(incoming, &conversation, &mut tcp_forwards, &mut unix_forwards, &mut forward_tasks).await; // Reset the pinned future for the next global request. - accept_global.set(conversation.accept()); + accept_global.set(conversation.accept_global_request()); } Err(e) => { tracing::debug!(error = %snafu::Report::from_error(&e), "accept global ended"); @@ -408,11 +430,16 @@ where // Reap completed channel tasks (prevents unbounded growth). Some(result) = channel_tasks.join_next() => { - if let Err(e) = result { - tracing::warn!(error = %snafu::Report::from_error(&e), "channel task panicked"); + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => return Err(error).context(SessionChannelSnafu), + Err(e) => { + tracing::warn!(error = %snafu::Report::from_error(&e), "channel task panicked"); + } } if had_session && channel_tasks.is_empty() { tracing::debug!("run_session: all channel tasks completed, exiting"); + outcome = RunSessionOutcome::SessionFinished; break; } } @@ -428,10 +455,16 @@ where // Wait for all remaining channel tasks. while let Some(result) = channel_tasks.join_next().await { - if let Err(e) = result { - tracing::warn!(error = %snafu::Report::from_error(&e), "channel task panicked during shutdown"); + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => return Err(error).context(SessionChannelSnafu), + Err(e) => { + tracing::warn!(error = %snafu::Report::from_error(&e), "channel task panicked during shutdown"); + } } } + + Ok(outcome) } // --------------------------------------------------------------------------- @@ -460,18 +493,18 @@ where // Global request dispatch // --------------------------------------------------------------------------- -async fn dispatch_global( +async fn dispatch_global( incoming: IncomingGlobal, - conversation: &Arc>, + conversation: &Arc>, tcp_forwards: &mut HashMap<(String, u16), AbortHandle>, unix_forwards: &mut HashMap, forward_tasks: &mut JoinSet<()>, ) where - M: ManageSessionStream + 'static, + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, - M::StreamReader: AsyncRead + Send + Unpin + 'static, - M::StreamWriter: AsyncWrite + Send + Unpin + 'static, { match incoming { IncomingGlobal::Request(req) => { diff --git a/src/session/mod.rs b/src/session/mod.rs index fa6e006..602a365 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -44,9 +44,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; mod server { use std::path::PathBuf; - use h3x::stream_id::StreamId; - use h3x::varint::VarInt; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use serde::{Deserialize, Serialize}; use snafu::{OptionExt, ResultExt, Snafu}; /// User identity from `/etc/passwd`. @@ -138,35 +136,22 @@ mod server { } /// Authentication success payload. - /// - /// The child reserves `control_fd_id` before returning this value. The - /// parent delivers the control stream FD with that receiver-chosen ID while - /// invoking [`StartSessionFn`]. #[derive(Serialize, Deserialize)] pub struct AuthenticatedSession { /// Inner remote function that starts the session. pub start_session: StartSessionFn, - /// Receiver-chosen FD transfer ID for the control stream socketpair. - pub control_fd_id: VarInt, } /// Argument to the inner [`StartSessionFn`]: everything the child needs to /// construct a [`Conversation`](crate::conversation::Conversation) after /// authentication succeeds and the parent completes the HTTP upgrade. /// - /// Stream data travels through Unix socketpairs via FD passing, not through - /// remoc serialization. The `manage_stream` field provides an RPC - /// interface that uses receiver-chosen FD transfer IDs. + /// Stream data travels through Unix socketpairs via h3x WebTransport IPC, + /// not through remoc serialization. #[derive(Serialize, Deserialize)] pub struct SessionBootstrap { - /// RPC client for opening/accepting QUIC streams via IPC FD passing. - pub manage_stream: crate::conversation::ipc::IpcManageSessionStreamClient, - /// Unique session identifier. - #[serde( - serialize_with = "serialize_stream_id", - deserialize_with = "deserialize_stream_id" - )] - pub conversation_id: StreamId, + /// WebTransport session IPC bootstrap. + pub webtransport_session: h3x::ipc::webtransport::WebTransportSessionBootstrap, /// Negotiated SSH version string. pub peer_version: String, } @@ -223,6 +208,13 @@ mod server { reason: String, }, + /// Running the session dispatcher failed. + #[snafu(display("session dispatcher failed: {reason}"))] + Session { + /// Human-readable reason for the failure. + reason: String, + }, + /// The remote function call itself failed (transport error). #[snafu(display("remote call failed"))] RemoteCall { @@ -250,21 +242,6 @@ mod server { /// When the parent calls it with [`SessionBootstrap`] (after HTTP upgrade), /// the child drops privileges and runs the session dispatcher. pub type StartSessionFn = remoc::rfn::RFnOnce<(SessionBootstrap,), Result<(), SessionRunError>>; - - fn serialize_stream_id(stream_id: &StreamId, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_u64(stream_id.into_inner()) - } - - fn deserialize_stream_id<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw = u64::deserialize(deserializer)?; - StreamId::try_from(raw).map_err(serde::de::Error::custom) - } } #[cfg(feature = "server")] diff --git a/src/session/process.rs b/src/session/process.rs index 4976b9f..5e27b85 100644 --- a/src/session/process.rs +++ b/src/session/process.rs @@ -697,12 +697,54 @@ mod tests { use super::*; use crate::conversation::channel::ChannelEvent; use crate::session::dispatcher::SessionConfig; + use std::pin::Pin; + use std::task::{Context, Poll}; // Helper: create a mock channel pair (in-memory duplex). fn channel_pair() -> (tokio::io::DuplexStream, tokio::io::DuplexStream) { tokio::io::duplex(64 * 1024) } + struct ShutdownFailWriter; + + impl AsyncWrite for ShutdownFailWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(std::io::Error::other("shutdown failed"))) + } + } + + #[tokio::test] + async fn run_piped_reports_channel_shutdown_failure() { + let config = SessionConfig::default(); + + let error = run_piped( + SshChannel::new(tokio::io::empty(), ShutdownFailWriter), + CommandMode::Exec { + shell: OsStr::new("/bin/sh"), + command: b"true", + }, + &config, + None, + &[], + ) + .await + .expect_err("writer shutdown failure must be reported"); + + assert!(matches!(error, ProcessError::Shutdown { .. })); + } + #[tokio::test] async fn run_piped_echo() { let (client_stream, server_stream) = channel_pair(); diff --git a/src/webtransport.rs b/src/webtransport.rs index 8d6718c..b75a8d2 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -5,7 +5,7 @@ //! //! - [`DSSH_CONTROL_STREAM_KIND`] — the conversation control stream //! - [`DSSH_CHANNEL_STREAM_KIND`] — SSH channel streams managed by -//! [`ManageSessionStream`](crate::conversation::ManageSessionStream) +//! [`Conversation`](crate::conversation::Conversation) //! //! The WebTransport CONNECT stream is not used as a DSSH control stream. The //! control stream is an ordinary WebTransport bidirectional stream marked with @@ -14,18 +14,13 @@ use std::convert::Infallible; use bytes::Bytes; -use h3x::{ - codec::{DecodeExt, EncodeExt, SinkWriter, StreamReader}, - quic, - varint::VarInt, -}; +use h3x::varint::VarInt; use http::{HeaderValue, header::AUTHORIZATION, uri::Authority}; use http_body_util::{BodyExt, Empty}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; -use tokio::io::AsyncWriteExt; use crate::constants::SSH_VERSION; -use crate::conversation::{Conversation, ManageSessionStream}; +use crate::conversation::Conversation; use crate::error::NegotiateVersionError; use crate::version::{SshVersion, negotiate_version, version_response_header}; @@ -35,22 +30,8 @@ pub const DSSH_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); /// DSSH-over-WebTransport stream kind for SSH channel streams. pub const DSSH_CHANNEL_STREAM_KIND: VarInt = VarInt::from_u32(1); -/// Stream manager backed by a WebTransport session. -/// -/// `open_stream` / `accept_stream` implement SSH channel stream management. -/// The control stream is handled explicitly through [`Self::open_control`] and -/// [`Self::accept_control`]. -#[derive(Debug)] -pub struct WebTransportStreamManager { - session: S, -} - /// DSSH conversation backed by a WebTransport session. -pub type WebTransportConversation = Conversation< - WebTransportStreamManager, - StreamReader<::StreamReader>, - SinkWriter<::StreamWriter>, ->; +pub type WebTransportConversation = Conversation; /// DSSH conversation backed by a concrete h3x WebTransport session. pub type ClientWebTransportConversation = @@ -64,98 +45,9 @@ pub struct AcceptedWebTransportSession { pub peer_version: String, } -impl WebTransportStreamManager { - pub fn new(session: S) -> Self { - Self { session } - } - - pub fn into_inner(self) -> S { - self.session - } - - pub fn session(&self) -> &S { - &self.session - } -} - -impl WebTransportStreamManager -where - S: h3x::webtransport::Session, - S::StreamReader: Unpin, - S::StreamWriter: Unpin, -{ - /// Open the DSSH control stream on this WebTransport session. - pub async fn open_control( - &self, - ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> - { - let (reader, writer) = self - .session - .open_bi() - .await - .context(web_transport_stream_error::OpenBiSnafu)?; - let writer = write_stream_kind(writer, DSSH_CONTROL_STREAM_KIND).await?; - Ok((StreamReader::new(reader), SinkWriter::new(writer))) - } - - /// Accept the DSSH control stream on this WebTransport session. - pub async fn accept_control( - &self, - ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> - { - self.accept_kind(DSSH_CONTROL_STREAM_KIND).await - } - - async fn accept_kind( - &self, - expected: VarInt, - ) -> Result<(StreamReader, SinkWriter), WebTransportStreamError> - { - let (reader, writer) = self - .session - .accept_bi() - .await - .context(web_transport_stream_error::AcceptBiSnafu)?; - let mut reader = StreamReader::new(reader); - let actual = reader - .decode_one::() - .await - .context(web_transport_stream_error::DecodeStreamKindSnafu)?; - if actual != expected { - return Err(WebTransportStreamError::UnexpectedStreamKind { kind: actual }); - } - Ok((reader, SinkWriter::new(writer))) - } -} - -impl ManageSessionStream for WebTransportStreamManager -where - S: h3x::webtransport::Session, - S::StreamReader: Unpin, - S::StreamWriter: Unpin, -{ - type StreamReader = StreamReader; - type StreamWriter = SinkWriter; - type Error = WebTransportStreamError; - - async fn open_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - let (reader, writer) = self - .session - .open_bi() - .await - .context(web_transport_stream_error::OpenBiSnafu)?; - let writer = write_stream_kind(writer, DSSH_CHANNEL_STREAM_KIND).await?; - Ok((StreamReader::new(reader), SinkWriter::new(writer))) - } - - async fn accept_stream(&self) -> Result<(Self::StreamReader, Self::StreamWriter), Self::Error> { - self.accept_kind(DSSH_CHANNEL_STREAM_KIND).await - } -} - /// Error returned when opening a DSSH conversation over WebTransport. #[derive(Debug, Snafu)] -#[snafu(module)] +#[snafu(module, visibility(pub(crate)))] pub enum OpenConversationError { #[snafu(display("failed to open dssh webtransport control stream"))] OpenControl { source: WebTransportStreamError }, @@ -163,60 +55,12 @@ pub enum OpenConversationError { /// Error returned when accepting a DSSH conversation over WebTransport. #[derive(Debug, Snafu)] -#[snafu(module)] +#[snafu(module, visibility(pub(crate)))] pub enum AcceptConversationError { #[snafu(display("failed to accept dssh webtransport control stream"))] AcceptControl { source: WebTransportStreamError }, } -/// Open a DSSH conversation on a WebTransport session. -/// -/// The client side opens an ordinary WebTransport bidirectional stream, writes -/// [`DSSH_CONTROL_STREAM_KIND`] as the first field, then uses that stream as the -/// DSSH conversation control stream. Additional SSH channel streams are managed -/// by the returned [`WebTransportStreamManager`]. -pub async fn open_conversation( - session: S, - peer_version: impl Into, -) -> Result, OpenConversationError> -where - S: h3x::webtransport::Session, - S::StreamReader: Unpin, - S::StreamWriter: Unpin, -{ - let id = session.id(); - let manager = WebTransportStreamManager::new(session); - let (reader, writer) = manager - .open_control() - .await - .context(open_conversation_error::OpenControlSnafu)?; - Ok(Conversation::new(id, peer_version, reader, writer, manager)) -} - -/// Accept a DSSH conversation on a WebTransport session. -/// -/// The server side waits for an ordinary WebTransport bidirectional stream -/// whose first field is [`DSSH_CONTROL_STREAM_KIND`], then uses that stream as -/// the DSSH conversation control stream. Additional SSH channel streams are -/// managed by the returned [`WebTransportStreamManager`]. -pub async fn accept_conversation( - session: S, - peer_version: impl Into, -) -> Result, AcceptConversationError> -where - S: h3x::webtransport::Session, - S::StreamReader: Unpin, - S::StreamWriter: Unpin, -{ - let id = session.id(); - let manager = WebTransportStreamManager::new(session); - let (reader, writer) = manager - .accept_control() - .await - .context(accept_conversation_error::AcceptControlSnafu)?; - Ok(Conversation::new(id, peer_version, reader, writer, manager)) -} - /// Error returned when constructing a client-side DSSH WebTransport CONNECT /// request. #[derive(Debug, Snafu)] @@ -346,7 +190,7 @@ where .context(client_connect_conversation_error::MissingValidatedPeerVersionSnafu)?; let session = h3x::webtransport::WebTransportSession::try_from(connect) .context(client_connect_conversation_error::RegisterSessionSnafu)?; - open_conversation(session, peer_version.version_string) + Conversation::open(session, peer_version.version_string) .await .context(client_connect_conversation_error::OpenConversationSnafu) } @@ -359,7 +203,7 @@ where /// well-known DSSH path. /// /// The returned HTTP response must be sent back to the peer. Only after that -/// response is on the wire should the server call [`accept_conversation`] in a +/// response is on the wire should the server call [`Conversation::accept`] in a /// task that owns the returned session; otherwise client and server can /// deadlock waiting for each other. pub async fn accept_server_session( @@ -393,9 +237,9 @@ where }) } -/// Error returned by [`WebTransportStreamManager`] operations. +/// Error returned by DSSH WebTransport stream-kind operations. #[derive(Debug, Snafu)] -#[snafu(module)] +#[snafu(module, visibility(pub(crate)))] pub enum WebTransportStreamError { #[snafu(display("failed to open webtransport bidirectional stream"))] OpenBi { @@ -420,21 +264,6 @@ pub enum WebTransportStreamError { UnexpectedStreamKind { kind: VarInt }, } -async fn write_stream_kind(writer: W, kind: VarInt) -> Result -where - W: quic::WriteStream + Unpin, -{ - let mut writer = SinkWriter::new(writer); - writer - .encode_one(kind) - .await - .context(web_transport_stream_error::EncodeStreamKindSnafu)?; - AsyncWriteExt::flush(&mut writer) - .await - .context(web_transport_stream_error::FlushStreamKindSnafu)?; - Ok(writer.into_inner()) -} - #[cfg(test)] mod tests { use std::{ @@ -445,13 +274,12 @@ mod tests { }; use bytes::Bytes; - use futures::{Sink, SinkExt, Stream}; + use futures::{Sink, Stream}; use h3x::{ - quic::{GetStreamId, ResetStream, StopStream}, + quic::{self, GetStreamId, ResetStream, StopStream}, stream_id::StreamId, }; use http::HeaderMap; - use tokio::io::AsyncReadExt; use super::*; use crate::constants::{DSSH_CONNECT_PATH, SSH_VERSION}; @@ -602,7 +430,7 @@ mod tests { } async fn open_uni(&self) -> Result { - unreachable!("dssh webtransport manager uses only bidirectional streams") + unreachable!("dssh webtransport conversation uses only bidirectional streams") } async fn accept_bi( @@ -621,7 +449,7 @@ mod tests { async fn accept_uni( &self, ) -> Result { - unreachable!("dssh webtransport manager uses only bidirectional streams") + unreachable!("dssh webtransport conversation uses only bidirectional streams") } } @@ -639,118 +467,12 @@ mod tests { ) } - #[tokio::test] - async fn open_control_and_channel_prefix_stream_kind() { - let session = TestSession::default(); - let manager = WebTransportStreamManager::new(session); - - manager.open_control().await.expect("open control"); - assert_eq!(manager.session().open_state.written(), vec![0]); - - manager.open_stream().await.expect("open channel"); - assert_eq!(manager.session().open_state.written(), vec![0, 1]); - } - - #[tokio::test] - async fn accept_control_consumes_control_kind_and_leaves_payload() { - let session = TestSession::with_accept_bytes(b"\x00hello"); - let manager = WebTransportStreamManager::new(session); - - let (mut reader, _writer) = manager.accept_control().await.expect("accept control"); - let mut payload = Vec::new(); - reader - .read_to_end(&mut payload) - .await - .expect("read payload"); - - assert_eq!(payload, b"hello"); - } - - #[tokio::test] - async fn accept_stream_consumes_channel_kind_and_leaves_payload() { - let session = TestSession::with_accept_bytes(b"\x01payload"); - let manager = WebTransportStreamManager::new(session); - - let (mut reader, _writer) = manager.accept_stream().await.expect("accept channel"); - let mut payload = Vec::new(); - reader - .read_to_end(&mut payload) - .await - .expect("read payload"); - - assert_eq!(payload, b"payload"); - } - - #[tokio::test] - async fn accept_stream_rejects_unexpected_kind() { - let session = TestSession::with_accept_bytes(b"\x00control"); - let manager = WebTransportStreamManager::new(session); - - let error = match manager.accept_stream().await { - Ok(_) => panic!("control stream is not a channel stream"), - Err(error) => error, - }; - - assert!(matches!( - error, - WebTransportStreamError::UnexpectedStreamKind { kind } - if kind == DSSH_CONTROL_STREAM_KIND - )); - } - - #[tokio::test] - async fn accepted_writer_remains_usable_after_kind_decode() { - let session = TestSession::with_accept_bytes(b"\x01"); - let manager = WebTransportStreamManager::new(session); - - let (_reader, mut writer) = manager.accept_stream().await.expect("accept channel"); - writer - .send(Bytes::from_static(b"reply")) - .await - .expect("write reply"); - SinkExt::flush(&mut writer).await.expect("flush reply"); - } - - #[tokio::test] - async fn decode_kind_error_is_structured() { - let session = TestSession::with_accept_bytes(b""); - let manager = WebTransportStreamManager::new(session); - - let error = match manager.accept_control().await { - Ok(_) => panic!("empty stream cannot carry kind"), - Err(error) => error, - }; - - assert!(matches!( - error, - WebTransportStreamError::DecodeStreamKind { .. } - )); - } - - #[tokio::test] - async fn accept_empty_session_preserves_closed_accept_error() { - let session = TestSession::default(); - let manager = WebTransportStreamManager::new(session); - - let error = match manager.accept_stream().await { - Ok(_) => panic!("empty session cannot accept a channel stream"), - Err(error) => error, - }; - - assert!(matches!( - error, - WebTransportStreamError::AcceptBi { - source: h3x::webtransport::AcceptStreamError::Closed { .. } - } - )); - } - #[tokio::test] async fn open_conversation_opens_control_stream_and_preserves_metadata() { let session = TestSession::default(); let open_state = session.open_state.clone(); - let conversation = open_conversation(session, SSH_VERSION) + let conversation = Conversation::open(session, SSH_VERSION) .await .expect("conversation opens"); @@ -763,7 +485,7 @@ mod tests { async fn accept_conversation_accepts_control_stream_and_preserves_metadata() { let session = TestSession::with_accept_bytes(b"\x00control"); - let conversation = accept_conversation(session, SSH_VERSION) + let conversation = Conversation::accept(session, SSH_VERSION) .await .expect("conversation accepts"); @@ -775,7 +497,7 @@ mod tests { async fn accept_conversation_rejects_channel_stream_as_control() { let session = TestSession::with_accept_bytes(b"\x01channel"); - let error = match accept_conversation(session, SSH_VERSION).await { + let error = match Conversation::accept(session, SSH_VERSION).await { Ok(_) => panic!("channel stream is not a control stream"), Err(error) => error, }; From d5a193abeb41f1f8995cff50555a2a0b54e0b5ef Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 8 Jun 2026 14:55:05 +0800 Subject: [PATCH 26/39] fix: return after remote session close --- src/session/client.rs | 85 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/src/session/client.rs b/src/session/client.rs index e3bed66..d021159 100644 --- a/src/session/client.rs +++ b/src/session/client.rs @@ -178,11 +178,15 @@ where let (reader, writer) = self.channel.into_split(); let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (_, output_result) = tokio::join!( - relay_stdin(stdin, writer, done_rx), - relay_output(reader, stdout, stderr, done_tx), - ); - output_result + let stdin_relay = relay_stdin(stdin, writer, done_rx); + let output_relay = relay_output(reader, stdout, stderr, done_tx); + tokio::pin!(stdin_relay); + tokio::pin!(output_relay); + + tokio::select! { + output_result = &mut output_relay => output_result, + _ = &mut stdin_relay => output_relay.await, + } } /// Run the IO relay with terminal resize forwarding. @@ -200,11 +204,15 @@ where let (reader, writer) = self.channel.into_split(); let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); - let (_, output_result) = tokio::join!( - relay_stdin_interactive(stdin, writer, done_rx, resize), - relay_output(reader, stdout, stderr, done_tx), - ); - output_result + let stdin_relay = relay_stdin_interactive(stdin, writer, done_rx, resize); + let output_relay = relay_output(reader, stdout, stderr, done_tx); + tokio::pin!(stdin_relay); + tokio::pin!(output_relay); + + tokio::select! { + output_result = &mut output_relay => output_result, + _ = &mut stdin_relay => output_relay.await, + } } /// Consume the session and return the underlying channel. @@ -394,11 +402,36 @@ mod tests { use super::*; use crate::conversation::channel::SshChannel; use h3x::varint::VarInt; + use std::{ + pin::Pin, + task::{Context, Poll}, + time::Duration, + }; fn channel_pair() -> (tokio::io::DuplexStream, tokio::io::DuplexStream) { tokio::io::duplex(64 * 1024) } + struct ShutdownPendingWriter; + + impl AsyncWrite for ShutdownPendingWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + } + #[tokio::test] async fn run_stdout_relay() { let (client, server) = channel_pair(); @@ -495,4 +528,36 @@ mod tests { assert_eq!(result, None); } + + #[tokio::test] + async fn run_returns_after_remote_close_when_stdin_shutdown_does_not_complete() { + let (client, server) = channel_pair(); + let (client_reader, _client_writer) = tokio::io::split(client); + let (_server_reader, server_writer) = tokio::io::split(server); + + let mut sw = SshChannel::new(_server_reader, server_writer); + sw.notice(&crate::session::ExitStatusChannelNotice { + payload: ExitStatusRequest { + exit_status: VarInt::from_u32(0), + }, + }) + .await + .unwrap(); + sw.eof().await.unwrap(); + sw.close().await.unwrap(); + sw.writer_mut().shutdown().await.unwrap(); + + let (stdin_reader, _stdin_writer) = tokio::io::duplex(1); + let session = ClientSession::new(SshChannel::new(client_reader, ShutdownPendingWriter)); + + let result = tokio::time::timeout( + Duration::from_millis(100), + session.run(stdin_reader, tokio::io::sink(), tokio::io::sink()), + ) + .await + .expect("run should not wait indefinitely for stdin writer shutdown") + .unwrap(); + + assert_eq!(result, Some(ExitResult::Status(0))); + } } From 505c6d1f442f5ba36e16424137d25d2a5b1abb1e Mon Sep 17 00:00:00 2001 From: eareimu Date: Tue, 9 Jun 2026 15:26:50 +0800 Subject: [PATCH 27/39] fix: add h3x dependency version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 15a9a72..6d2b617 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" repository = "https://github.com/genmeta/dssh" [dependencies] -h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", features = [ +h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", version = "0.2.0", features = [ "rpc", "serde", "webtransport", From 72dacb7235d1b9de5a4e7263d1dc757b10dafd16 Mon Sep 17 00:00:00 2001 From: eareimu Date: Sat, 13 Jun 2026 17:45:01 +0800 Subject: [PATCH 28/39] test: update webtransport session mocks --- src/conversation/tests.rs | 26 ++++++++++++++++++++++++-- src/forward/reverse.rs | 26 ++++++++++++++++++++++++-- src/webtransport.rs | 26 ++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/conversation/tests.rs b/src/conversation/tests.rs index 02ba68c..d65bc92 100644 --- a/src/conversation/tests.rs +++ b/src/conversation/tests.rs @@ -156,8 +156,30 @@ impl h3x::webtransport::Session for TestSession { type StreamReader = TestQuicReader; type StreamWriter = TestQuicWriter; - fn id(&self) -> StreamId { - self.0.id + fn id(&self) -> h3x::webtransport::WebTransportSessionId { + h3x::webtransport::WebTransportSessionId::try_from(self.0.id) + .expect("test session id must be client-initiated bidirectional") + } + + async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { + Ok(()) + } + + async fn close( + &self, + _close: h3x::webtransport::CloseSession, + ) -> Result<(), h3x::webtransport::CloseSessionError> { + Ok(()) + } + + async fn drained(&self) -> h3x::webtransport::SessionDrain { + h3x::webtransport::SessionDrain::Closed(self.closed().await) + } + + async fn closed(&self) -> h3x::webtransport::CloseReason { + h3x::webtransport::CloseReason::Session( + h3x::webtransport::SessionCloseReason::ControlStreamError, + ) } async fn open_bi( diff --git a/src/forward/reverse.rs b/src/forward/reverse.rs index 7fae049..758e0f1 100644 --- a/src/forward/reverse.rs +++ b/src/forward/reverse.rs @@ -512,8 +512,30 @@ mod tests { type StreamReader = TestQuicReader; type StreamWriter = TestQuicWriter; - fn id(&self) -> StreamId { - StreamId(VarInt::from_u32(40)) + fn id(&self) -> h3x::webtransport::WebTransportSessionId { + h3x::webtransport::WebTransportSessionId::try_from(StreamId(VarInt::from_u32(40))) + .expect("test session id must be client-initiated bidirectional") + } + + async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { + Ok(()) + } + + async fn close( + &self, + _close: h3x::webtransport::CloseSession, + ) -> Result<(), h3x::webtransport::CloseSessionError> { + Ok(()) + } + + async fn drained(&self) -> h3x::webtransport::SessionDrain { + h3x::webtransport::SessionDrain::Closed(self.closed().await) + } + + async fn closed(&self) -> h3x::webtransport::CloseReason { + h3x::webtransport::CloseReason::Session( + h3x::webtransport::SessionCloseReason::ControlStreamError, + ) } async fn open_bi( diff --git a/src/webtransport.rs b/src/webtransport.rs index b75a8d2..eb365c1 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -409,8 +409,30 @@ mod tests { type StreamReader = TestReadStream; type StreamWriter = TestWriteStream; - fn id(&self) -> StreamId { - StreamId(VarInt::from_u32(4)) + fn id(&self) -> h3x::webtransport::WebTransportSessionId { + h3x::webtransport::WebTransportSessionId::try_from(StreamId(VarInt::from_u32(4))) + .expect("test session id must be client-initiated bidirectional") + } + + async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { + Ok(()) + } + + async fn close( + &self, + _close: h3x::webtransport::CloseSession, + ) -> Result<(), h3x::webtransport::CloseSessionError> { + Ok(()) + } + + async fn drained(&self) -> h3x::webtransport::SessionDrain { + h3x::webtransport::SessionDrain::Closed(self.closed().await) + } + + async fn closed(&self) -> h3x::webtransport::CloseReason { + h3x::webtransport::CloseReason::Session( + h3x::webtransport::SessionCloseReason::ControlStreamError, + ) } async fn open_bi( From 831a0f88ddf97e99327e204890e73933039e0d5a Mon Sep 17 00:00:00 2001 From: eareimu Date: Sat, 13 Jun 2026 18:08:42 +0800 Subject: [PATCH 29/39] refactor: unify dssh test session fixture --- src/conversation/tests.rs | 229 +----------------------------- src/forward/reverse.rs | 242 ++----------------------------- src/lib.rs | 3 + src/test_support.rs | 291 ++++++++++++++++++++++++++++++++++++++ src/webtransport.rs | 236 +++---------------------------- 5 files changed, 326 insertions(+), 675 deletions(-) create mode 100644 src/test_support.rs diff --git a/src/conversation/tests.rs b/src/conversation/tests.rs index d65bc92..c6d33c8 100644 --- a/src/conversation/tests.rs +++ b/src/conversation/tests.rs @@ -3,234 +3,9 @@ use super::*; use super::channel::{ChannelEvent, ReadChannelEventError, SendChannelRequestError, SshChannel}; use super::global::{DecodedGlobalRequest, RespondFailureError, RespondSuccessError}; use crate::codec::SshBytes; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use bytes::Bytes; -use futures::{Sink, Stream, channel::mpsc}; -use h3x::{ - codec::{SinkWriter, StreamReader as H3xStreamReader}, - quic::{GetStreamId, ResetStream, StopStream, StreamError}, +use crate::test_support::{ + MockReader, MockWebTransportSession as TestSession, MockWriter, stream_pair as make_half, }; -use tokio::sync::mpsc as tokio_mpsc; - -// -- Mock stream types that implement h3x ReadStream / WriteStream ------ - -struct TestQuicReader { - stream_id: VarInt, - inner: mpsc::Receiver, -} - -impl Unpin for TestQuicReader {} - -impl Stream for TestQuicReader { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_next(cx) - .map(|opt| opt.map(Ok)) - } -} - -impl GetStreamId for TestQuicReader { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } -} - -impl StopStream for TestQuicReader { - fn poll_stop( - self: Pin<&mut Self>, - _cx: &mut Context, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } -} - -struct TestQuicWriter { - stream_id: VarInt, - inner: mpsc::Sender, -} - -impl Unpin for TestQuicWriter {} - -impl Sink for TestQuicWriter { - type Error = StreamError; - - fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_ready(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn start_send(mut self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { - Pin::new(&mut self.inner) - .start_send(item) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_flush(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_close(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } -} - -impl GetStreamId for TestQuicWriter { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } -} - -impl ResetStream for TestQuicWriter { - fn poll_reset( - self: Pin<&mut Self>, - _cx: &mut Context, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } -} - -// -- Concrete fake WebTransport session ------------------------------- - -type MockReader = H3xStreamReader; -type MockWriter = SinkWriter; - -#[derive(Clone)] -struct TestSession(Arc); - -struct TestSessionState { - id: StreamId, - open_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, - open_rx: std::sync::Mutex>, - accept_tx: tokio_mpsc::UnboundedSender<(MockReader, MockWriter)>, - accept_rx: std::sync::Mutex>, -} - -impl TestSession { - fn new(id: StreamId) -> Self { - let (open_tx, open_rx) = tokio_mpsc::unbounded_channel(); - let (accept_tx, accept_rx) = tokio_mpsc::unbounded_channel(); - Self(Arc::new(TestSessionState { - id, - open_tx, - open_rx: std::sync::Mutex::new(open_rx), - accept_tx, - accept_rx: std::sync::Mutex::new(accept_rx), - })) - } - - fn provide_open_stream(&self, reader: MockReader, writer: MockWriter) { - self.0.open_tx.send((reader, writer)).unwrap(); - } - - fn provide_accept_stream(&self, reader: MockReader, writer: MockWriter) { - self.0.accept_tx.send((reader, writer)).unwrap(); - } -} - -impl h3x::webtransport::Session for TestSession { - type StreamReader = TestQuicReader; - type StreamWriter = TestQuicWriter; - - fn id(&self) -> h3x::webtransport::WebTransportSessionId { - h3x::webtransport::WebTransportSessionId::try_from(self.0.id) - .expect("test session id must be client-initiated bidirectional") - } - - async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { - Ok(()) - } - - async fn close( - &self, - _close: h3x::webtransport::CloseSession, - ) -> Result<(), h3x::webtransport::CloseSessionError> { - Ok(()) - } - - async fn drained(&self) -> h3x::webtransport::SessionDrain { - h3x::webtransport::SessionDrain::Closed(self.closed().await) - } - - async fn closed(&self) -> h3x::webtransport::CloseReason { - h3x::webtransport::CloseReason::Session( - h3x::webtransport::SessionCloseReason::ControlStreamError, - ) - } - - async fn open_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> { - let (reader, writer) = self - .0 - .open_rx - .lock() - .unwrap() - .try_recv() - .expect("no open_bi pair provided"); - Ok((reader.into_inner(), writer.into_inner())) - } - - async fn open_uni(&self) -> Result { - unreachable!("dssh tests use only bidirectional streams") - } - - async fn accept_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> - { - let (reader, writer) = self - .0 - .accept_rx - .lock() - .unwrap() - .try_recv() - .expect("no accept_bi pair provided"); - Ok((reader.into_inner(), writer.into_inner())) - } - - async fn accept_uni(&self) -> Result { - unreachable!("dssh tests use only bidirectional streams") - } -} - -/// Create a connected pair of (reader, writer) for one direction. -fn make_half(stream_id: VarInt) -> (MockReader, MockWriter) { - let (tx, rx) = mpsc::channel(64); - let reader = H3xStreamReader::new(TestQuicReader { - stream_id, - inner: rx, - }); - let writer = SinkWriter::new(TestQuicWriter { - stream_id, - inner: tx, - }); - (reader, writer) -} async fn make_conversation() -> (Conversation, MockReader, MockWriter) { let stream_id = VarInt::from_u32(40); diff --git a/src/forward/reverse.rs b/src/forward/reverse.rs index 758e0f1..2298105 100644 --- a/src/forward/reverse.rs +++ b/src/forward/reverse.rs @@ -351,232 +351,19 @@ where #[cfg(test)] mod tests { use super::*; - use bytes::Bytes; - use futures::{Sink, Stream, channel::mpsc}; - use h3x::{ - codec::{SinkWriter, StreamReader as H3xStreamReader}, - quic::{GetStreamId, ResetStream, StopStream, StreamError}, - stream_id::StreamId, - varint::VarInt, - }; - use std::pin::Pin; - use std::sync::Mutex; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::task::{Context, Poll}; - - struct TestQuicReader { - stream_id: VarInt, - inner: mpsc::Receiver, - } - - impl Unpin for TestQuicReader {} - - impl Stream for TestQuicReader { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_next(cx) - .map(|opt| opt.map(Ok)) - } - } - - impl GetStreamId for TestQuicReader { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } - } - - impl StopStream for TestQuicReader { - fn poll_stop( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } - - struct TestQuicWriter { - stream_id: VarInt, - inner: mpsc::Sender, - } - - impl Unpin for TestQuicWriter {} - - impl Sink for TestQuicWriter { - type Error = StreamError; - - fn poll_ready( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll> { - Pin::new(&mut self.inner) - .poll_ready(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn start_send(mut self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { - Pin::new(&mut self.inner) - .start_send(item) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn poll_flush( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll> { - Pin::new(&mut self.inner) - .poll_flush(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - - fn poll_close( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll> { - Pin::new(&mut self.inner) - .poll_close(cx) - .map_err(|_| StreamError::Reset { - code: VarInt::from_u32(0), - }) - } - } - - impl GetStreamId for TestQuicWriter { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } - } - - impl ResetStream for TestQuicWriter { - fn poll_reset( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } + use crate::test_support::{MockWebTransportSession as TestSession, stream_pair as make_half}; + use h3x::{stream_id::StreamId, varint::VarInt}; - type MockReader = H3xStreamReader; - type MockWriter = SinkWriter; - - fn make_half(stream_id: VarInt) -> (MockReader, MockWriter) { - let (tx, rx) = mpsc::channel(64); - let reader = H3xStreamReader::new(TestQuicReader { - stream_id, - inner: rx, - }); - let writer = SinkWriter::new(TestQuicWriter { - stream_id, - inner: tx, - }); - (reader, writer) - } - - struct MockStreamState { - pairs: Mutex>, - open_called: AtomicBool, - } - - impl MockStreamState { - fn new() -> Self { - Self { - pairs: Mutex::new(Vec::new()), - open_called: AtomicBool::new(false), - } - } - - fn provide_pair(&self, reader: MockReader, writer: MockWriter) { - self.pairs.lock().unwrap().push((reader, writer)); - } - } - - #[derive(Clone)] - struct TestSession(Arc); - - impl h3x::webtransport::Session for TestSession { - type StreamReader = TestQuicReader; - type StreamWriter = TestQuicWriter; - - fn id(&self) -> h3x::webtransport::WebTransportSessionId { - h3x::webtransport::WebTransportSessionId::try_from(StreamId(VarInt::from_u32(40))) - .expect("test session id must be client-initiated bidirectional") - } - - async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { - Ok(()) - } - - async fn close( - &self, - _close: h3x::webtransport::CloseSession, - ) -> Result<(), h3x::webtransport::CloseSessionError> { - Ok(()) - } - - async fn drained(&self) -> h3x::webtransport::SessionDrain { - h3x::webtransport::SessionDrain::Closed(self.closed().await) - } - - async fn closed(&self) -> h3x::webtransport::CloseReason { - h3x::webtransport::CloseReason::Session( - h3x::webtransport::SessionCloseReason::ControlStreamError, - ) - } - - async fn open_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> - { - self.0.open_called.store(true, Ordering::SeqCst); - let (reader, writer) = self - .0 - .pairs - .lock() - .unwrap() - .pop() - .expect("no pairs enqueued"); - Ok((reader.into_inner(), writer.into_inner())) - } - - async fn open_uni(&self) -> Result { - unreachable!("dssh reverse tests use only bidirectional streams") - } - - async fn accept_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> - { - std::future::pending().await - } - - async fn accept_uni( - &self, - ) -> Result { - unreachable!("dssh reverse tests use only bidirectional streams") - } + fn make_test_session() -> TestSession { + TestSession::new(StreamId(VarInt::from_u32(40))) } - fn make_conversation(mock: Arc) -> Arc> { + fn make_conversation(session: TestSession) -> Arc> { let stream_id = VarInt::from_u32(40); let (local_reader, _remote_writer) = make_half(stream_id); let (_remote_reader, local_writer) = make_half(stream_id); Arc::new(Conversation::from_control_streams( - TestSession(mock), + session, "test", local_reader, local_writer, @@ -585,8 +372,7 @@ mod tests { #[tokio::test] async fn tcp_forward_bind_and_cancel() { - let mock = Arc::new(MockStreamState::new()); - let conv = make_conversation(Arc::clone(&mock)); + let conv = make_conversation(make_test_session()); let listener = TcpForwardListener::bind("127.0.0.1:0").await.unwrap(); assert_ne!(listener.bound_addr().port(), 0, "should get a real port"); @@ -601,8 +387,8 @@ mod tests { use h3x::codec::DecodeExt; use tokio::io::AsyncWriteExt; - let mock = Arc::new(MockStreamState::new()); - let conv = make_conversation(Arc::clone(&mock)); + let session = make_test_session(); + let conv = make_conversation(session.clone()); let listener = TcpForwardListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.bound_addr().port(); @@ -611,7 +397,7 @@ mod tests { let stream_id = VarInt::from_u32(44); let (remote_rd, local_wr) = make_half(stream_id); let (local_rd, remote_wr) = make_half(stream_id); - mock.provide_pair(local_rd, local_wr); + session.provide_open_stream(local_rd, local_wr); let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)) .await @@ -638,7 +424,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(100)).await; assert!( - mock.open_called.load(Ordering::SeqCst), + session.open_called(), "should have called open_stream via Conversation::open_channel" ); @@ -651,8 +437,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let sock_path = dir.path().join("test.sock"); - let mock = Arc::new(MockStreamState::new()); - let conv = make_conversation(Arc::clone(&mock)); + let conv = make_conversation(make_test_session()); let listener = UnixForwardListener::bind(&sock_path).unwrap(); assert!(sock_path.exists(), "socket file should exist after bind"); @@ -675,8 +460,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let sock_path = dir.path().join("drop-test.sock"); - let mock = Arc::new(MockStreamState::new()); - let conv = make_conversation(Arc::clone(&mock)); + let conv = make_conversation(make_test_session()); let listener = UnixForwardListener::bind(&sock_path).unwrap(); assert!(sock_path.exists(), "socket file should exist after bind"); diff --git a/src/lib.rs b/src/lib.rs index 13c9eea..3691217 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,9 @@ pub mod session; pub mod version; pub mod webtransport; +#[cfg(test)] +mod test_support; + #[cfg(test)] mod tests { #[test] diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..713fa13 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,291 @@ +use std::{ + collections::VecDeque, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use futures::{Sink, Stream, channel::mpsc}; +use h3x::{ + codec::{SinkWriter, StreamReader as H3xStreamReader}, + quic::{GetStreamId, ResetStream, StopStream, StreamError}, + stream_id::StreamId, + varint::VarInt, +}; + +pub(crate) struct MockQuicReader { + stream_id: VarInt, + inner: mpsc::Receiver, +} + +impl Unpin for MockQuicReader {} + +impl Stream for MockQuicReader { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_next(cx) + .map(|opt| opt.map(Ok)) + } +} + +impl GetStreamId for MockQuicReader { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } +} + +impl StopStream for MockQuicReader { + fn poll_stop( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub(crate) struct MockQuicWriter { + stream_id: VarInt, + inner: mpsc::Sender, +} + +impl Unpin for MockQuicWriter {} + +impl Sink for MockQuicWriter { + type Error = StreamError; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_ready(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn start_send(mut self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { + Pin::new(&mut self.inner) + .start_send(item) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_flush(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner) + .poll_close(cx) + .map_err(|_| StreamError::Reset { + code: VarInt::from_u32(0), + }) + } +} + +impl GetStreamId for MockQuicWriter { + fn poll_stream_id( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.stream_id)) + } +} + +impl ResetStream for MockQuicWriter { + fn poll_reset( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _code: VarInt, + ) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub(crate) type MockReader = H3xStreamReader; +pub(crate) type MockWriter = SinkWriter; + +pub(crate) fn stream_pair(stream_id: VarInt) -> (MockReader, MockWriter) { + let (tx, rx) = mpsc::channel(64); + let reader = H3xStreamReader::new(MockQuicReader { + stream_id, + inner: rx, + }); + let writer = SinkWriter::new(MockQuicWriter { + stream_id, + inner: tx, + }); + (reader, writer) +} + +pub(crate) fn stream_with_read_bytes(stream_id: VarInt, bytes: Bytes) -> (MockReader, MockWriter) { + let (mut read_tx, read_rx) = mpsc::channel(64); + read_tx + .try_send(bytes) + .expect("preloaded test stream should have capacity"); + drop(read_tx); + + let (write_tx, _write_rx) = mpsc::channel(64); + let reader = H3xStreamReader::new(MockQuicReader { + stream_id, + inner: read_rx, + }); + let writer = SinkWriter::new(MockQuicWriter { + stream_id, + inner: write_tx, + }); + (reader, writer) +} + +#[derive(Clone)] +pub(crate) struct MockWebTransportSession { + state: Arc, +} + +struct MockWebTransportSessionState { + id: StreamId, + open_streams: Mutex>, + accept_streams: Mutex>, + open_count: AtomicUsize, + drained: AtomicBool, + close: Mutex>, +} + +impl MockWebTransportSession { + pub(crate) fn new(id: StreamId) -> Self { + Self { + state: Arc::new(MockWebTransportSessionState { + id, + open_streams: Mutex::new(VecDeque::new()), + accept_streams: Mutex::new(VecDeque::new()), + open_count: AtomicUsize::new(0), + drained: AtomicBool::new(false), + close: Mutex::new(None), + }), + } + } + + pub(crate) fn provide_open_stream(&self, reader: MockReader, writer: MockWriter) { + self.state + .open_streams + .lock() + .expect("open stream queue lock poisoned") + .push_back((reader, writer)); + } + + pub(crate) fn provide_accept_stream(&self, reader: MockReader, writer: MockWriter) { + self.state + .accept_streams + .lock() + .expect("accept stream queue lock poisoned") + .push_back((reader, writer)); + } + + pub(crate) fn provide_accept_bytes(&self, stream_id: VarInt, bytes: Bytes) { + let (reader, writer) = stream_with_read_bytes(stream_id, bytes); + self.provide_accept_stream(reader, writer); + } + + pub(crate) fn open_called(&self) -> bool { + self.state.open_count.load(Ordering::SeqCst) > 0 + } +} + +impl h3x::webtransport::Session for MockWebTransportSession { + type StreamReader = MockQuicReader; + type StreamWriter = MockQuicWriter; + + fn id(&self) -> h3x::webtransport::WebTransportSessionId { + h3x::webtransport::WebTransportSessionId::try_from(self.state.id) + .expect("test session id must be client-initiated bidirectional") + } + + async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { + self.state.drained.store(true, Ordering::SeqCst); + Ok(()) + } + + async fn close( + &self, + close: h3x::webtransport::CloseSession, + ) -> Result<(), h3x::webtransport::CloseSessionError> { + *self.state.close.lock().expect("close lock poisoned") = Some(close); + Ok(()) + } + + async fn drained(&self) -> h3x::webtransport::SessionDrain { + if self.state.drained.load(Ordering::SeqCst) { + h3x::webtransport::SessionDrain::Requested(h3x::webtransport::DrainReason::Session( + h3x::webtransport::SessionDrainReason::Local, + )) + } else { + h3x::webtransport::SessionDrain::Closed(self.closed().await) + } + } + + async fn closed(&self) -> h3x::webtransport::CloseReason { + match self + .state + .close + .lock() + .expect("close lock poisoned") + .clone() + { + Some(close) => h3x::webtransport::CloseReason::Session( + h3x::webtransport::SessionCloseReason::Local(close), + ), + None => h3x::webtransport::CloseReason::Session( + h3x::webtransport::SessionCloseReason::ControlStreamError, + ), + } + } + + async fn open_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> { + self.state.open_count.fetch_add(1, Ordering::SeqCst); + let (reader, writer) = self + .state + .open_streams + .lock() + .expect("open stream queue lock poisoned") + .pop_front() + .expect("no open_bi pair provided"); + Ok((reader.into_inner(), writer.into_inner())) + } + + async fn open_uni(&self) -> Result { + unreachable!("dssh tests use only bidirectional streams") + } + + async fn accept_bi( + &self, + ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> + { + let (reader, writer) = self + .state + .accept_streams + .lock() + .expect("accept stream queue lock poisoned") + .pop_front() + .expect("no accept_bi pair provided"); + Ok((reader.into_inner(), writer.into_inner())) + } + + async fn accept_uni(&self) -> Result { + unreachable!("dssh tests use only bidirectional streams") + } +} diff --git a/src/webtransport.rs b/src/webtransport.rs index eb365c1..8c68c36 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -266,246 +266,44 @@ pub enum WebTransportStreamError { #[cfg(test)] mod tests { - use std::{ - collections::VecDeque, - pin::Pin, - sync::{Arc, Mutex}, - task::{Context, Poll}, - }; - - use bytes::Bytes; - use futures::{Sink, Stream}; - use h3x::{ - quic::{self, GetStreamId, ResetStream, StopStream}, - stream_id::StreamId, - }; + use h3x::{codec::DecodeExt, stream_id::StreamId}; use http::HeaderMap; use super::*; use crate::constants::{DSSH_CONNECT_PATH, SSH_VERSION}; + use crate::test_support::{MockWebTransportSession as TestSession, stream_pair as make_half}; - #[derive(Debug, Default)] - struct StreamState { - written: Mutex>, - } - - impl StreamState { - fn written(&self) -> Vec { - self.written.lock().expect("written lock poisoned").clone() - } - } - - #[derive(Debug)] - struct TestReadStream { - chunks: VecDeque, - stream_id: VarInt, - } - - impl Stream for TestReadStream { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(self.chunks.pop_front().map(Ok)) - } - } - - impl GetStreamId for TestReadStream { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } - } - - impl StopStream for TestReadStream { - fn poll_stop( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } - - #[derive(Debug)] - struct TestWriteStream { - state: Arc, - stream_id: VarInt, - } - - impl Sink for TestWriteStream { - type Error = quic::StreamError; - - fn poll_ready( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - - fn start_send(self: Pin<&mut Self>, item: Bytes) -> Result<(), Self::Error> { - self.state - .written - .lock() - .expect("written lock poisoned") - .extend_from_slice(&item); - Ok(()) - } - - fn poll_flush( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } - - impl GetStreamId for TestWriteStream { - fn poll_stream_id( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.stream_id)) - } - } - - impl ResetStream for TestWriteStream { - fn poll_reset( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - _code: VarInt, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } - - #[derive(Debug, Default)] - struct TestSession { - open_state: Arc, - accept_streams: Mutex>, - } - - impl TestSession { - fn with_accept_bytes(bytes: &'static [u8]) -> Self { - let session = Self::default(); - session - .accept_streams - .lock() - .expect("accept lock poisoned") - .push_back(stream_pair_with_read(bytes, VarInt::from_u32(7))); - session - } + fn test_session() -> TestSession { + TestSession::new(StreamId(VarInt::from_u32(4))) } - impl h3x::webtransport::Session for TestSession { - type StreamReader = TestReadStream; - type StreamWriter = TestWriteStream; - - fn id(&self) -> h3x::webtransport::WebTransportSessionId { - h3x::webtransport::WebTransportSessionId::try_from(StreamId(VarInt::from_u32(4))) - .expect("test session id must be client-initiated bidirectional") - } - - async fn drain(&self) -> Result<(), h3x::webtransport::DrainSessionError> { - Ok(()) - } - - async fn close( - &self, - _close: h3x::webtransport::CloseSession, - ) -> Result<(), h3x::webtransport::CloseSessionError> { - Ok(()) - } - - async fn drained(&self) -> h3x::webtransport::SessionDrain { - h3x::webtransport::SessionDrain::Closed(self.closed().await) - } - - async fn closed(&self) -> h3x::webtransport::CloseReason { - h3x::webtransport::CloseReason::Session( - h3x::webtransport::SessionCloseReason::ControlStreamError, - ) - } - - async fn open_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::OpenStreamError> - { - Ok(( - TestReadStream { - chunks: VecDeque::new(), - stream_id: VarInt::from_u32(5), - }, - TestWriteStream { - state: self.open_state.clone(), - stream_id: VarInt::from_u32(5), - }, - )) - } - - async fn open_uni(&self) -> Result { - unreachable!("dssh webtransport conversation uses only bidirectional streams") - } - - async fn accept_bi( - &self, - ) -> Result<(Self::StreamReader, Self::StreamWriter), h3x::webtransport::AcceptStreamError> - { - self.accept_streams - .lock() - .expect("accept lock poisoned") - .pop_front() - .ok_or(h3x::webtransport::AcceptStreamError::Closed { - source: h3x::webtransport::SessionClosed, - }) - } - - async fn accept_uni( - &self, - ) -> Result { - unreachable!("dssh webtransport conversation uses only bidirectional streams") - } - } - - fn stream_pair_with_read( - bytes: &'static [u8], - stream_id: VarInt, - ) -> (TestReadStream, TestWriteStream) { - let state = Arc::new(StreamState::default()); - ( - TestReadStream { - chunks: VecDeque::from([Bytes::from_static(bytes)]), - stream_id, - }, - TestWriteStream { state, stream_id }, - ) + fn session_with_accept_bytes(bytes: &'static [u8]) -> TestSession { + let session = test_session(); + session.provide_accept_bytes(VarInt::from_u32(8), Bytes::from_static(bytes)); + session } #[tokio::test] async fn open_conversation_opens_control_stream_and_preserves_metadata() { - let session = TestSession::default(); - let open_state = session.open_state.clone(); + let session = test_session(); + let stream_id = VarInt::from_u32(8); + let (local_reader, _remote_writer) = make_half(stream_id); + let (mut remote_reader, local_writer) = make_half(stream_id); + session.provide_open_stream(local_reader, local_writer); let conversation = Conversation::open(session, SSH_VERSION) .await .expect("conversation opens"); + let kind: VarInt = remote_reader.decode_one().await.expect("stream kind"); + assert_eq!(kind, DSSH_CONTROL_STREAM_KIND); assert_eq!(conversation.id(), StreamId(VarInt::from_u32(4))); assert_eq!(conversation.peer_version(), SSH_VERSION); - assert_eq!(open_state.written(), vec![0]); } #[tokio::test] async fn accept_conversation_accepts_control_stream_and_preserves_metadata() { - let session = TestSession::with_accept_bytes(b"\x00control"); + let session = session_with_accept_bytes(b"\x00control"); let conversation = Conversation::accept(session, SSH_VERSION) .await @@ -517,7 +315,7 @@ mod tests { #[tokio::test] async fn accept_conversation_rejects_channel_stream_as_control() { - let session = TestSession::with_accept_bytes(b"\x01channel"); + let session = session_with_accept_bytes(b"\x01channel"); let error = match Conversation::accept(session, SSH_VERSION).await { Ok(_) => panic!("channel stream is not a control stream"), From a543caa8306f6d1e568236728b52a40a373ed1ac Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 14 Jun 2026 20:11:54 +0800 Subject: [PATCH 30/39] ci: add dssh release workflows --- .github/workflows/ci.yml | 41 +++++++++++++++++++ .github/workflows/publish-crates.yml | 59 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-crates.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d8312bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --all-features diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..1cb0662 --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,59 @@ +name: Publish crates.io + +on: + pull_request: + workflow_dispatch: + push: + branches: + - main + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check formatting + run: cargo fmt --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --all-features + + - name: Authenticate to crates.io + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + uses: rust-lang/crates-io-auth-action@v1 + id: auth + + - name: Release dssh crate + shell: bash + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then + mode=publish + else + mode=dry-run + fi + + if [[ "$mode" == "dry-run" ]]; then + cargo publish --dry-run --locked + else + cargo publish --locked + fi From 11d304645fd239e82b0e4924ee52fd97c8da1d8b Mon Sep 17 00:00:00 2001 From: eareimu Date: Sun, 14 Jun 2026 22:32:49 +0800 Subject: [PATCH 31/39] ci: install pam development libraries --- .github/workflows/ci.yml | 4 ++++ .github/workflows/publish-crates.yml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8312bf..e2be69c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - name: Install PAM development libraries + run: | + sudo apt-get update + sudo apt-get install --yes libpam0g-dev - uses: Swatinem/rust-cache@v2 - run: cargo test --all-features diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 1cb0662..4aae40c 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -25,6 +25,11 @@ jobs: - name: Install Rust stable toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install PAM development libraries + run: | + sudo apt-get update + sudo apt-get install --yes libpam0g-dev + - name: Check formatting run: cargo fmt --check From 7a880e5cc87bbb9bec47b8f95d5bdf4af57b52d9 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 00:55:01 +0800 Subject: [PATCH 32/39] fix: align h3x dependency version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6d2b617..ae490cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" repository = "https://github.com/genmeta/dssh" [dependencies] -h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", version = "0.2.0", features = [ +h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", version = "0.3.0", features = [ "rpc", "serde", "webtransport", From 1d452e6f381d731b3dbcbb4e0ff32001ad8a3b51 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 05:14:31 +0800 Subject: [PATCH 33/39] fix: drain dssh session output after exit notice --- src/session/process.rs | 331 ++++++++++++++++++++++++++++++++++------- 1 file changed, 278 insertions(+), 53 deletions(-) diff --git a/src/session/process.rs b/src/session/process.rs index 5e27b85..ea6cd0d 100644 --- a/src/session/process.rs +++ b/src/session/process.rs @@ -5,9 +5,9 @@ //! stderr → channel extended data (type 1). //! - **PTY**: all I/O through a PTY master; everything → channel data. //! -//! Both modes use `tokio::join!` for concurrent I/O relay (no spawned tasks, -//! no cancellation issues). The reader half uses `SshChannelReader::next_event` -//! which is safe since it never needs to be cancelled. +//! Both modes run the input relay in a managed spawned task and keep the +//! output relay inline so exit notification can be emitted promptly while +//! stdout/stderr or PTY output continues draining before EOF/Close. use std::borrow::Cow; use std::ffi::{OsStr, OsString}; @@ -282,12 +282,20 @@ where let pid = child.id().map(|id| Pid::from_raw(id as i32)); let (reader, mut writer) = channel.into_split(); + let (stop_input_tx, stop_input_rx) = tokio::sync::oneshot::channel(); + let mut stop_input_tx = Some(stop_input_tx); - let input_handle = tokio::spawn(relay_input_piped(reader, stdin, pid)); + let input_handle = tokio::spawn(relay_input_piped(reader, stdin, pid, stop_input_rx)); let output_result = async { - let status = relay_output_piped(&mut stdout, &mut stderr, &mut writer, &mut child).await?; - send_exit_notification(&mut writer, &status).await?; + relay_output_piped( + &mut stdout, + &mut stderr, + &mut writer, + &mut child, + &mut stop_input_tx, + ) + .await?; writer.eof().await.context(WriteEofSnafu)?; writer.close().await.context(WriteCloseSnafu)?; writer @@ -299,7 +307,7 @@ where } .await; - input_handle.abort(); + signal_input_relay_stop(&mut stop_input_tx); let _ = input_handle.await; output_result } @@ -360,6 +368,7 @@ where } let mut child = cmd.spawn().context(SpawnSnafu)?; + drop(cmd); let pid = child.id().map(|id| Pid::from_raw(id as i32)); // Use AsyncFd-based wrappers for the PTY master. Unlike tokio::fs::File @@ -372,28 +381,37 @@ where let master_writer = super::pty::AsyncPtyFd::new(master_write_fd).context(AsyncPtySnafu)?; let (reader, mut writer) = channel.into_split(); + let (stop_input_tx, stop_input_rx) = tokio::sync::oneshot::channel(); + let mut stop_input_tx = Some(stop_input_tx); - let input_handle = tokio::spawn(relay_input_pty(reader, master_writer, pid, master_raw_fd)); + let input_handle = tokio::spawn(relay_input_pty( + reader, + master_writer, + pid, + master_raw_fd, + stop_input_rx, + )); let output_result = async { - let status = relay_output_pty(&mut master_reader, &mut writer, &mut child).await?; - tracing::trace!(?status, "child process exited"); - send_exit_notification(&mut writer, &status).await?; - tracing::trace!("exit notification sent"); + relay_output_pty( + &mut master_reader, + &mut writer, + &mut child, + &mut stop_input_tx, + ) + .await?; writer.eof().await.context(WriteEofSnafu)?; writer.close().await.context(WriteCloseSnafu)?; - tracing::trace!("eof + close written"); writer .writer_mut() .shutdown() .await .context(ShutdownSnafu)?; - tracing::trace!("writer shutdown complete"); Ok::<_, ProcessError>(()) } .await; - input_handle.abort(); + signal_input_relay_stop(&mut stop_input_tx); let _ = input_handle.await; output_result } @@ -402,13 +420,15 @@ where // Output relay // ============================================================================ -/// Multiplex stdout and stderr to the channel writer, racing each read against -/// child exit. See [`relay_output_pty`] for the rationale. +/// Multiplex stdout and stderr to the channel writer while watching for child +/// exit. Once the child exits, send the exit notification promptly, stop the +/// input relay, and continue draining remaining output until both pipes close. async fn relay_output_piped( stdout: &mut (impl AsyncRead + Unpin), stderr: &mut (impl AsyncRead + Unpin), writer: &mut SshChannelWriter, child: &mut tokio::process::Child, + stop_input_tx: &mut Option>, ) -> Result where W: AsyncWrite + Unpin + Send, @@ -419,6 +439,7 @@ where let mut stderr_buf = [0u8; 8192]; let mut stdout_done = false; let mut stderr_done = false; + let mut exit_status = None; while !stdout_done || !stderr_done { tokio::select! { @@ -447,13 +468,26 @@ where .await .context(WriteExtendedDataSnafu)?; } - wait_result = child.wait() => { - return wait_result.context(WaitSnafu); + wait_result = child.wait(), if exit_status.is_none() => { + let status = wait_result.context(WaitSnafu)?; + signal_input_relay_stop(stop_input_tx); + send_exit_notification(writer, &status).await?; + exit_status = Some(status); } } } - child.wait().await.context(WaitSnafu) + let status = match exit_status { + Some(status) => status, + None => { + let status = child.wait().await.context(WaitSnafu)?; + signal_input_relay_stop(stop_input_tx); + send_exit_notification(writer, &status).await?; + status + } + }; + + Ok(status) } /// Relay PTY master output to the channel writer, racing each read against @@ -463,15 +497,16 @@ where /// `child.wait()`: /// /// - **Read wins** — the data is forwarded to `writer`, then the race repeats. -/// - **`child.wait()` wins** — the child has exited and we are at a clean SSH -/// frame boundary (not mid-write), so the caller can safely send -/// `exit-status` / EOF / Close. +/// - **`child.wait()` wins** — the child has exited, so this function sends +/// `exit-status` / `exit-signal`, stops the input relay, and keeps draining +/// PTY output until the master closes. /// - **PTY closes (EIO / `Ok(0)`)** — the read loop ends and we wait for the -/// child before returning. +/// child if we have not observed its exit yet. async fn relay_output_pty( master: &mut (impl AsyncRead + Unpin), writer: &mut SshChannelWriter, child: &mut tokio::process::Child, + stop_input_tx: &mut Option>, ) -> Result where W: AsyncWrite + Unpin + Send, @@ -479,6 +514,7 @@ where use process_error::*; let mut buf = [0u8; 8192]; + let mut exit_status = None; loop { tokio::select! { read_result = master.read(&mut buf) => { @@ -490,77 +526,105 @@ where Ok(n) => writer.data(&buf[..n]).await.context(WriteDataSnafu)?, } } - wait_result = child.wait() => { - // Child exited while we were between reads: we are at a clean - // SSH frame boundary, so the caller can safely send - // exit-status / EOF / Close. - return wait_result.context(WaitSnafu); + wait_result = child.wait(), if exit_status.is_none() => { + let status = wait_result.context(WaitSnafu)?; + signal_input_relay_stop(stop_input_tx); + send_exit_notification(writer, &status).await?; + exit_status = Some(status); } } } - // PTY closed cleanly. Wait for the child to confirm exit. - child.wait().await.context(WaitSnafu) + + let status = match exit_status { + Some(status) => status, + None => { + let status = child.wait().await.context(WaitSnafu)?; + signal_input_relay_stop(stop_input_tx); + send_exit_notification(writer, &status).await?; + status + } + }; + + Ok(status) } // ============================================================================ // Input relay // ============================================================================ -/// Read from channel, write data to process stdin, deliver signals. +/// Read from channel, write data to process stdin, deliver signals, and stop +/// when the output relay tells us the child has exited. async fn relay_input_piped( mut reader: SshChannelReader, mut stdin: impl AsyncWrite + Unpin + Send, pid: Option, + mut stop_rx: tokio::sync::oneshot::Receiver<()>, ) where R: AsyncRead + Unpin + Send, { loop { - match reader.next_event().await { - Ok(ReaderEvent::Data(mut data)) => { - if io::copy(&mut data, &mut stdin).await.is_err() { - break; + tokio::select! { + biased; + _ = &mut stop_rx => break, + event = reader.next_event() => match event { + Ok(ReaderEvent::Data(mut data)) => { + if io::copy(&mut data, &mut stdin).await.is_err() { + break; + } } - } - Ok(ReaderEvent::Notice(incoming)) => { - if !handle_piped_notice(incoming, pid).await { - break; + Ok(ReaderEvent::Notice(incoming)) => { + if !handle_piped_notice(incoming, pid).await { + break; + } } + Ok(ReaderEvent::Eof | ReaderEvent::Close) | Err(_) => break, + Ok(_) => {} } - Ok(ReaderEvent::Eof | ReaderEvent::Close) | Err(_) => break, - Ok(_) => {} } } } /// Read from channel, write data to PTY master, deliver signals, handle -/// window-change requests. +/// window-change requests, and stop when the output relay tells us the child +/// has exited. async fn relay_input_pty( mut reader: SshChannelReader, mut pty_writer: impl AsyncWrite + Unpin + Send, pid: Option, master_raw_fd: RawFd, + mut stop_rx: tokio::sync::oneshot::Receiver<()>, ) where R: AsyncRead + Unpin + Send, { loop { - match reader.next_event().await { - Ok(ReaderEvent::Data(mut data)) => { - if io::copy(&mut data, &mut pty_writer).await.is_err() { - break; + tokio::select! { + biased; + _ = &mut stop_rx => break, + event = reader.next_event() => match event { + Ok(ReaderEvent::Data(mut data)) => { + if io::copy(&mut data, &mut pty_writer).await.is_err() { + break; + } } - } - Ok(ReaderEvent::Notice(incoming)) => { - if !handle_pty_notice(incoming, pid, master_raw_fd).await { - break; + Ok(ReaderEvent::Notice(incoming)) => { + if !handle_pty_notice(incoming, pid, master_raw_fd).await { + break; + } } + Ok(ReaderEvent::Eof | ReaderEvent::Close) | Err(_) => break, + Ok(_) => {} } - Ok(ReaderEvent::Eof | ReaderEvent::Close) | Err(_) => break, - Ok(_) => {} } } drop(pty_writer); } +fn signal_input_relay_stop(stop_tx: &mut Option>) { + if let Some(stop_tx) = stop_tx.take() { + let _ = stop_tx.send(()); + } +} + // ============================================================================ // Notice handlers for input relay // ============================================================================ @@ -695,16 +759,88 @@ where #[cfg(test)] mod tests { use super::*; + use crate::codec::SshBytes; use crate::conversation::channel::ChannelEvent; use crate::session::dispatcher::SessionConfig; + use crate::session::PtyRequest; use std::pin::Pin; use std::task::{Context, Poll}; + #[derive(Debug, Clone, PartialEq, Eq)] + enum RecordedEvent { + Data(String), + ExtendedData(String), + ExitStatus(u32), + ExitSignal(String), + Eof, + Close, + } + // Helper: create a mock channel pair (in-memory duplex). fn channel_pair() -> (tokio::io::DuplexStream, tokio::io::DuplexStream) { tokio::io::duplex(64 * 1024) } + fn test_pty_request() -> PtyRequest { + PtyRequest { + term_type: "xterm-256color".into(), + width_cols: VarInt::from(80u32), + height_rows: VarInt::from(24u32), + width_px: VarInt::from_u32(0), + height_px: VarInt::from_u32(0), + terminal_modes: SshBytes::from(vec![]), + } + } + + async fn collect_events(channel: &mut SshChannel) -> Vec + where + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, + { + let mut events = Vec::new(); + loop { + match channel.next_event().await { + Ok(ChannelEvent::Data(mut data)) => { + let bytes = data.read_all().await.unwrap(); + events.push(RecordedEvent::Data( + String::from_utf8_lossy(&bytes).into_owned(), + )); + } + Ok(ChannelEvent::ExtendedData { mut data, .. }) => { + let bytes = data.read_all().await.unwrap(); + events.push(RecordedEvent::ExtendedData( + String::from_utf8_lossy(&bytes).into_owned(), + )); + } + Ok(ChannelEvent::Request(incoming)) => { + if &**incoming.request_type() == "exit-status" { + let (req, _) = incoming + .decode_payload::() + .await + .unwrap(); + events.push(RecordedEvent::ExitStatus( + req.exit_status.into_inner() as u32, + )); + } else if &**incoming.request_type() == "exit-signal" { + let (req, _) = incoming + .decode_payload::() + .await + .unwrap(); + events.push(RecordedEvent::ExitSignal(req.signal_name.to_string())); + } + } + Ok(ChannelEvent::Eof) => events.push(RecordedEvent::Eof), + Ok(ChannelEvent::Close) => { + events.push(RecordedEvent::Close); + break; + } + Err(error) => panic!("unexpected channel error: {error}"), + _ => {} + } + } + events + } + struct ShutdownFailWriter; impl AsyncWrite for ShutdownFailWriter { @@ -903,4 +1039,93 @@ mod tests { drop(channel); handle.await.unwrap().unwrap(); } + + #[tokio::test] + async fn run_piped_sends_exit_status_before_late_stdout_but_still_delivers_output() { + let (client_stream, server_stream) = channel_pair(); + let (server_reader, server_writer) = tokio::io::split(server_stream); + let (client_reader, client_writer) = tokio::io::split(client_stream); + + let config = SessionConfig::default(); + let handle = tokio::spawn(async move { + run_piped( + SshChannel::new(server_reader, server_writer), + CommandMode::Exec { + shell: OsStr::new("/bin/sh"), + command: b"(sleep 0.05; printf late-output) & exit 23", + }, + &config, + None, + &[], + ) + .await + }); + + let mut channel = SshChannel::new(client_reader, client_writer); + let events = collect_events(&mut channel).await; + + let exit_index = events + .iter() + .position(|event| matches!(event, RecordedEvent::ExitStatus(code) if *code == 23)) + .unwrap(); + let data_index = events + .iter() + .position(|event| matches!(event, RecordedEvent::Data(text) if text == "late-output")) + .unwrap(); + + assert!( + exit_index < data_index, + "expected exit-status before late stdout drain: {events:#?}", + ); + assert!(matches!(events.last(), Some(RecordedEvent::Close))); + + drop(channel); + handle.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn run_pty_sends_exit_status_before_late_output_but_still_drains_pty() { + let (client_stream, server_stream) = channel_pair(); + let (server_reader, server_writer) = tokio::io::split(server_stream); + let (client_reader, client_writer) = tokio::io::split(client_stream); + + let pty = crate::session::pty::allocate_pty(&test_pty_request()).unwrap(); + let config = SessionConfig::default(); + let handle = tokio::spawn(async move { + run_pty( + SshChannel::new(server_reader, server_writer), + CommandMode::Exec { + shell: OsStr::new("/bin/sh"), + command: b"trap '' HUP; (sleep 0.05; printf late-pty) & exit 29", + }, + pty, + &config, + Some("xterm-256color"), + &[], + ) + .await + }); + + let mut channel = SshChannel::new(client_reader, client_writer); + let events = collect_events(&mut channel).await; + + let exit_index = events + .iter() + .position(|event| matches!(event, RecordedEvent::ExitStatus(code) if *code == 29)) + .unwrap(); + let data_index = events + .iter() + .position(|event| matches!(event, RecordedEvent::Data(text) if text.contains("late-pty"))) + .unwrap(); + + assert!( + exit_index < data_index, + "expected exit-status before late PTY data drain: {events:#?}", + ); + assert!(events.iter().any(|event| matches!(event, RecordedEvent::Eof))); + assert!(matches!(events.last(), Some(RecordedEvent::Close))); + + drop(channel); + handle.await.unwrap().unwrap(); + } } From 78c4c325cf6a48b72d9de627bf69e6d6eaff5abf Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 06:21:08 +0800 Subject: [PATCH 34/39] fix: align dssh session and forwarding semantics --- src/forward/client.rs | 271 +++++++++++++++++- src/forward/direct.rs | 46 +-- src/forward/mod.rs | 28 ++ src/forward/reverse.rs | 570 +++++++++++++++++++++++++++++++++----- src/forward/spec.rs | 16 +- src/session/dispatcher.rs | 187 ++++++++++++- src/session/process.rs | 14 +- 7 files changed, 1007 insertions(+), 125 deletions(-) diff --git a/src/forward/client.rs b/src/forward/client.rs index 82e87f1..f0164b8 100644 --- a/src/forward/client.rs +++ b/src/forward/client.rs @@ -24,6 +24,45 @@ use h3x::varint::VarInt; use super::spec::{Endpoint, LocalForward, RemoteForward}; +#[derive(Debug, Clone, PartialEq, Eq)] +struct DirectOriginator { + host: String, + port: u16, +} + +impl DirectOriginator { + fn from_socket_addr(addr: std::net::SocketAddr) -> Self { + Self { + host: addr.ip().to_string(), + port: addr.port(), + } + } + + fn placeholder() -> Self { + Self { + host: "127.0.0.1".to_owned(), + port: 65535, + } + } +} + +fn remote_forward_bind_key(bind: &Endpoint) -> Option<(String, u16)> { + match bind { + Endpoint::Tcp { host, port } => Some(( + crate::forward::canonicalize_remote_bind_host(host).into_owned(), + *port, + )), + Endpoint::Unix { .. } => None, + } +} + +fn forwarded_tcpip_bind_key(host: &str, port: u16) -> (String, u16) { + ( + crate::forward::canonicalize_remote_bind_host(host).into_owned(), + port, + ) +} + // ============================================================================ // Error types // ============================================================================ @@ -139,9 +178,10 @@ impl LocalForward { }; let conv = conversation.clone(); let connect = self.connect.clone(); + let originator = DirectOriginator::from_socket_addr(peer); let (r, w) = stream.into_split(); tasks.spawn( - open_channel_and_relay(conv, connect, Box::pin(r), Box::pin(w)) + open_channel_and_relay(conv, connect, originator, Box::pin(r), Box::pin(w)) .instrument(tracing::info_span!("conn", %peer)), ); } @@ -171,7 +211,14 @@ impl LocalForward { let connect = self.connect.clone(); let (r, w) = stream.into_split(); tasks.spawn( - open_channel_and_relay(conv, connect, Box::pin(r), Box::pin(w)).in_current_span(), + open_channel_and_relay( + conv, + connect, + DirectOriginator::placeholder(), + Box::pin(r), + Box::pin(w), + ) + .in_current_span(), ); } } @@ -181,6 +228,7 @@ impl LocalForward { async fn open_channel_and_relay( conversation: Arc>, connect: Endpoint, + originator: DirectOriginator, local_reader: Pin>, local_writer: Pin>, ) where @@ -195,8 +243,8 @@ async fn open_channel_and_relay( &DirectTcpip { dest_host: SshString::from(host.clone()), dest_port: VarInt::from(*port as u32), - originator_host: SshString::from_static(""), - originator_port: VarInt::from(0u32), + originator_host: SshString::from(originator.host.clone()), + originator_port: VarInt::from(originator.port as u32), }, DEFAULT_MAX_MESSAGE_SIZE, ) @@ -250,9 +298,11 @@ impl RemoteForward { match &self.bind { Endpoint::Tcp { host, port } => { + let canonical_host = + crate::forward::canonicalize_remote_bind_host(host).into_owned(); let request = TcpipForwardGlobalRequest { payload: TcpipForwardRequest { - bind_address: SshString::from(host.clone()), + bind_address: SshString::from(canonical_host.clone()), bind_port: VarInt::from(*port as u32), }, }; @@ -273,7 +323,7 @@ impl RemoteForward { Ok(RemoteForwardEstablished { bind: Endpoint::Tcp { - host: host.clone(), + host: canonical_host, port: allocated_port, }, connect: self.connect.clone(), @@ -383,14 +433,11 @@ pub async fn accept_forwarded_channels( let server_port = payload.connected_port.into_inner() as u16; let server_addr = payload.connected_address.to_string(); + let incoming_key = forwarded_tcpip_bind_key(&server_addr, server_port); let mapping = mappings.iter().find(|m| match &m.bind { - Endpoint::Tcp { host, port } => { - *port == server_port - && (host.is_empty() - || host == "0.0.0.0" - || host == "*" - || *host == server_addr) + Endpoint::Tcp { .. } => { + remote_forward_bind_key(&m.bind) == Some(incoming_key.clone()) } _ => false, }); @@ -521,3 +568,203 @@ async fn handle_forwarded_channel( let s2ch = tokio::spawn(relay(local_reader, ch_writer).in_current_span()); let _ = tokio::join!(ch2s, s2ch); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::codec::{SshBool, SshString}; + use crate::test_support::{MockWebTransportSession as TestSession, stream_pair as make_half}; + use h3x::{ + codec::{DecodeExt, EncodeExt}, + stream_id::StreamId, + varint::VarInt, + }; + use tokio::io::{AsyncWriteExt, empty, sink}; + + fn make_test_session() -> TestSession { + TestSession::new(StreamId(VarInt::from_u32(40))) + } + + fn make_conversation(session: TestSession) -> Arc> { + let stream_id = VarInt::from_u32(40); + let (local_reader, _remote_writer) = make_half(stream_id); + let (_remote_reader, local_writer) = make_half(stream_id); + Arc::new(Conversation::from_control_streams( + session, + "test", + local_reader, + local_writer, + )) + } + + #[tokio::test] + async fn direct_tcpip_uses_real_tcp_originator() { + let session = make_test_session(); + let conv = make_conversation(session.clone()); + + let stream_id = VarInt::from_u32(44); + let (mut remote_rd, local_wr) = make_half(stream_id); + let (local_rd, mut remote_wr) = make_half(stream_id); + session.provide_open_stream(local_rd, local_wr); + + let handle = tokio::spawn(open_channel_and_relay( + Arc::clone(&conv), + Endpoint::Tcp { + host: "example.com".into(), + port: 443, + }, + DirectOriginator::from_socket_addr("127.0.0.1:2222".parse().unwrap()), + Box::pin(empty()), + Box::pin(sink()), + )); + + let _stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); + let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); + let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _dest_host: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _dest_port: VarInt = remote_rd.decode_one().await.unwrap(); + let originator_host: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let originator_port: VarInt = remote_rd.decode_one().await.unwrap(); + + assert_eq!(&*originator_host, "127.0.0.1"); + assert_eq!(originator_port, VarInt::from_u32(2222)); + + remote_wr.encode_one(VarInt::from_u32(91)).await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(32768)).await.unwrap(); + drop(remote_wr); + handle.await.unwrap(); + } + + #[tokio::test] + async fn direct_tcpip_uses_placeholder_originator_for_unix_forwarders() { + let session = make_test_session(); + let conv = make_conversation(session.clone()); + + let stream_id = VarInt::from_u32(46); + let (mut remote_rd, local_wr) = make_half(stream_id); + let (local_rd, mut remote_wr) = make_half(stream_id); + session.provide_open_stream(local_rd, local_wr); + + let handle = tokio::spawn(open_channel_and_relay( + Arc::clone(&conv), + Endpoint::Tcp { + host: "example.com".into(), + port: 443, + }, + DirectOriginator::placeholder(), + Box::pin(empty()), + Box::pin(sink()), + )); + + let _stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); + let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); + let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _dest_host: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _dest_port: VarInt = remote_rd.decode_one().await.unwrap(); + let originator_host: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let originator_port: VarInt = remote_rd.decode_one().await.unwrap(); + + assert_eq!(&*originator_host, "127.0.0.1"); + assert_eq!(originator_port, VarInt::from_u32(65535)); + + remote_wr.encode_one(VarInt::from_u32(91)).await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(32768)).await.unwrap(); + drop(remote_wr); + handle.await.unwrap(); + } + + #[test] + fn remote_forward_bind_key_normalizes_wildcard_and_preserves_explicit_hosts() { + assert_eq!( + remote_forward_bind_key(&Endpoint::Tcp { + host: "*".into(), + port: 9000, + }), + Some(("".to_owned(), 9000)) + ); + assert_eq!( + remote_forward_bind_key(&Endpoint::Tcp { + host: "localhost".into(), + port: 9000, + }), + Some(("localhost".to_owned(), 9000)) + ); + assert_eq!( + remote_forward_bind_key(&Endpoint::Tcp { + host: "127.0.0.1".into(), + port: 9000, + }), + Some(("127.0.0.1".to_owned(), 9000)) + ); + } + + #[test] + fn forwarded_tcpip_bind_key_uses_canonical_semantics() { + assert_eq!(forwarded_tcpip_bind_key("*", 9000), ("".to_owned(), 9000)); + assert_eq!(forwarded_tcpip_bind_key("", 9000), ("".to_owned(), 9000)); + assert_eq!( + forwarded_tcpip_bind_key("localhost", 9000), + ("localhost".to_owned(), 9000) + ); + assert_eq!( + forwarded_tcpip_bind_key("::", 9000), + ("::".to_owned(), 9000) + ); + } + + #[tokio::test] + async fn remote_forward_request_sends_canonical_bind_host() { + let session = make_test_session(); + + let stream_id = VarInt::from_u32(52); + let (local_reader, mut remote_writer) = make_half(stream_id); + let (mut remote_reader, local_writer) = make_half(stream_id); + let conv = Conversation::from_control_streams(session, "test", local_reader, local_writer); + + let handle = tokio::spawn(async move { + let msg_type: VarInt = remote_reader.decode_one().await.unwrap(); + assert_eq!(msg_type, VarInt::from_u32(80)); + let request_type: SshString = remote_reader.decode_one().await.unwrap(); + assert_eq!(&*request_type, "tcpip-forward"); + let want_reply: SshBool = remote_reader.decode_one().await.unwrap(); + assert!(want_reply.0); + let bind_host: SshString = remote_reader.decode_one().await.unwrap(); + let bind_port: VarInt = remote_reader.decode_one().await.unwrap(); + assert_eq!(&*bind_host, ""); + assert_eq!(bind_port, VarInt::from_u32(9000)); + remote_writer + .encode_one(VarInt::from_u32(81)) + .await + .unwrap(); + remote_writer + .encode_one(VarInt::from_u32(9000)) + .await + .unwrap(); + remote_writer.flush().await.unwrap(); + }); + + let established = RemoteForward { + bind: Endpoint::Tcp { + host: "*".into(), + port: 9000, + }, + connect: Some(Endpoint::Tcp { + host: "127.0.0.1".into(), + port: 22, + }), + } + .request(&conv) + .await + .unwrap(); + + assert_eq!( + established.bind, + Endpoint::Tcp { + host: "".into(), + port: 9000, + } + ); + + handle.await.unwrap(); + } +} diff --git a/src/forward/direct.rs b/src/forward/direct.rs index 08171e0..7d2081f 100644 --- a/src/forward/direct.rs +++ b/src/forward/direct.rs @@ -66,13 +66,14 @@ pub enum DirectForwardError { RelayJoin { source: tokio::task::JoinError }, } -/// Send `ChannelOpenFailure` with `SSH_OPEN_CONNECT_FAILED`. +/// Send `ChannelOpenFailure` with the provided reason code. async fn send_open_failure( pending: PendingChannel, + reason: VarInt, description: &str, ) -> Result<(), DirectForwardError> { pending - .reject(reason_code::CONNECT_FAILED, description.to_owned().into()) + .reject(reason, description.to_owned().into()) .await .context(direct_forward_error::RejectSnafu) } @@ -139,20 +140,27 @@ where .await .context(direct_forward_error::DecodeVarintSnafu)?; + let pending = PendingChannel::from_raw_parts(reader, writer); let raw_port = dest_port.into_inner(); - snafu::ensure!( - raw_port <= u16::MAX as u64, - direct_forward_error::PortOverflowSnafu { raw_port } - ); - let port = raw_port as u16; + let port = match u16::try_from(raw_port) { + Ok(port) => port, + Err(_) => { + send_open_failure( + pending, + reason_code::ADMINISTRATIVELY_PROHIBITED, + "invalid destination port", + ) + .await?; + return Ok(()); + } + }; - let pending = PendingChannel::from_raw_parts(reader, writer); let addr = format!("{}:{}", &*dest_host, port); let tcp_stream = match TcpStream::connect(&addr).await { Ok(s) => s, Err(e) => { tracing::warn!(%addr, error = %snafu::Report::from_error(&e), "direct-tcpip connect failed"); - send_open_failure(pending, "connect failed").await?; + send_open_failure(pending, reason_code::CONNECT_FAILED, "connect failed").await?; return Ok(()); } }; @@ -194,7 +202,7 @@ where Ok(s) => s, Err(e) => { tracing::warn!(%path, error = %snafu::Report::from_error(&e), "direct-streamlocal connect failed"); - send_open_failure(pending, "connect failed").await?; + send_open_failure(pending, reason_code::CONNECT_FAILED, "connect failed").await?; return Ok(()); } }; @@ -292,21 +300,21 @@ mod tests { } #[tokio::test] - async fn direct_tcpip_port_overflow() { + async fn direct_tcpip_port_overflow_is_rejected() { let req = encode_tcpip_request("127.0.0.1", 70000, "127.0.0.1", 11111).await; let (mut client_wr, server_rd) = duplex(8192); - let (server_wr, _client_rd) = duplex(8192); + let (server_wr, mut client_rd) = duplex(8192); client_wr.write_all(&req).await.unwrap(); drop(client_wr); - // Port overflow causes PortOverflow error (not a failure message) - let result = handle_direct_tcpip(server_rd, server_wr).await; - assert!(result.is_err()); - assert!( - format!("{:?}", result.unwrap_err()).contains("PortOverflow"), - "expected PortOverflow error" - ); + handle_direct_tcpip(server_rd, server_wr).await.unwrap(); + + let result = read_channel_open_response(&mut client_rd).await; + assert!(matches!( + result, + Err(crate::conversation::channel::AwaitOpenError::Rejected { .. }) + )); } } diff --git a/src/forward/mod.rs b/src/forward/mod.rs index 90b7466..d485cbe 100644 --- a/src/forward/mod.rs +++ b/src/forward/mod.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + #[cfg(feature = "client")] pub mod client; #[cfg(feature = "server")] @@ -18,6 +20,17 @@ use h3x::{ use snafu::{ResultExt, Snafu}; use tokio::io::{self, AsyncRead, AsyncWrite, AsyncWriteExt}; +pub(crate) const CANONICAL_REMOTE_WILDCARD_HOST: &str = ""; +pub(crate) const CANONICAL_REMOTE_LOOPBACK_HOST: &str = "localhost"; + +pub(crate) fn canonicalize_remote_bind_host(host: &str) -> Cow<'_, str> { + match host { + "" | "*" => Cow::Borrowed(CANONICAL_REMOTE_WILDCARD_HOST), + "localhost" => Cow::Borrowed(CANONICAL_REMOTE_LOOPBACK_HOST), + other => Cow::Borrowed(other), + } +} + /// Copy all bytes from `reader` to `writer`, then shut down the writer. pub async fn relay(mut reader: R, mut writer: W) -> io::Result where @@ -219,6 +232,21 @@ impl DecodeFrom for TcpipForwardReply { } } +#[cfg(test)] +mod tests { + use super::canonicalize_remote_bind_host; + + #[test] + fn canonicalize_remote_bind_host_normalizes_wildcard_but_keeps_loopback_and_explicit_hosts() { + assert_eq!(canonicalize_remote_bind_host(""), ""); + assert_eq!(canonicalize_remote_bind_host("*"), ""); + assert_eq!(canonicalize_remote_bind_host("localhost"), "localhost"); + assert_eq!(canonicalize_remote_bind_host("127.0.0.1"), "127.0.0.1"); + assert_eq!(canonicalize_remote_bind_host("::"), "::"); + assert_eq!(canonicalize_remote_bind_host("example.com"), "example.com"); + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ForwardedTcpip { pub connected_address: SshString, diff --git a/src/forward/reverse.rs b/src/forward/reverse.rs index 2298105..f09a1ba 100644 --- a/src/forward/reverse.rs +++ b/src/forward/reverse.rs @@ -26,6 +26,7 @@ use snafu::{ResultExt, Snafu}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::{TcpListener, UnixListener}; use tokio::task::JoinSet; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; use tracing::Instrument; // --------------------------------------------------------------------------- @@ -38,6 +39,12 @@ pub enum AcceptTcpForwardError { #[snafu(display("bind port overflows u16"))] PortOverflow, + #[snafu(display("failed to resolve TCP bind address {host}"))] + ResolveBindAddress { + host: String, + source: std::io::Error, + }, + #[snafu(display("failed to bind TCP listener"))] TcpBind { source: std::io::Error }, @@ -62,6 +69,93 @@ pub enum AcceptUnixForwardError { }, } +fn bind_candidates(host: &str) -> Vec { + match host { + "localhost" => vec![ + std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)), + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)), + ], + "" => vec![ + std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0)), + std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0)), + ], + "0.0.0.0" => vec![std::net::SocketAddr::from(( + std::net::Ipv4Addr::UNSPECIFIED, + 0, + ))], + "::" => vec![std::net::SocketAddr::from(( + std::net::Ipv6Addr::UNSPECIFIED, + 0, + ))], + "127.0.0.1" => vec![std::net::SocketAddr::from(( + std::net::Ipv4Addr::LOCALHOST, + 0, + ))], + "::1" => vec![std::net::SocketAddr::from(( + std::net::Ipv6Addr::LOCALHOST, + 0, + ))], + _ => vec![], + } +} + +async fn resolve_bind_candidates( + host: &str, +) -> Result, AcceptTcpForwardError> { + let semantic = bind_candidates(host); + if !semantic.is_empty() { + return Ok(semantic); + } + + let mut addrs: Vec = tokio::net::lookup_host((host, 0)) + .await + .context(accept_tcp_forward_error::ResolveBindAddressSnafu { + host: host.to_owned(), + })? + .collect(); + addrs.sort_by_key(|addr| match addr { + std::net::SocketAddr::V6(_) => 0, + std::net::SocketAddr::V4(_) => 1, + }); + addrs.dedup(); + Ok(addrs) +} + +fn bind_tcp_socket(addr: std::net::SocketAddr) -> std::io::Result { + use std::os::fd::AsRawFd; + use tokio::net::TcpSocket; + + match addr { + std::net::SocketAddr::V4(addr) => { + let socket = TcpSocket::new_v4()?; + socket.set_reuseaddr(true)?; + socket.bind(std::net::SocketAddr::V4(addr))?; + socket.listen(1024) + } + std::net::SocketAddr::V6(addr) => { + let socket = TcpSocket::new_v6()?; + socket.set_reuseaddr(true)?; + let yes: libc::c_int = 1; + // SAFETY: setsockopt writes a small integer option to a valid socket + // file descriptor before bind. + let rc = unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::IPPROTO_IPV6, + libc::IPV6_V6ONLY, + &yes as *const _ as *const libc::c_void, + std::mem::size_of_val(&yes) as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + socket.bind(std::net::SocketAddr::V6(addr))?; + socket.listen(1024) + } + } +} + // --------------------------------------------------------------------------- // TCP forward listener // --------------------------------------------------------------------------- @@ -70,8 +164,9 @@ pub enum AcceptUnixForwardError { /// /// Obtained from [`DecodedGlobalRequest::accept_tcp_forward`]. pub struct TcpForwardListener { - listener: TcpListener, - bound_addr: SocketAddr, + advertised_host: String, + bound_port: u16, + listeners: Vec, } impl TcpForwardListener { @@ -80,14 +175,25 @@ impl TcpForwardListener { let listener = TcpListener::bind(addr).await?; let bound_addr = listener.local_addr()?; Ok(Self { - listener, - bound_addr, + advertised_host: bound_addr.ip().to_string(), + bound_port: bound_addr.port(), + listeners: vec![listener], }) } /// The address the listener is bound to. pub fn bound_addr(&self) -> SocketAddr { - self.bound_addr + self.listeners[0] + .local_addr() + .expect("single-listener bound address should be available") + } + + pub fn bound_port(&self) -> u16 { + self.bound_port + } + + pub fn advertised_host(&self) -> &str { + &self.advertised_host } /// Run the accept loop, opening a `forwarded-tcpip` channel for each @@ -95,46 +201,143 @@ impl TcpForwardListener { /// /// Runs until the listener encounters an accept error. Cancel the /// enclosing task to stop the listener. - pub async fn run(self, conversation: Arc>) - where + pub async fn run( + self, + conversation: Arc>, + relay_tasks: TaskTracker, + relay_cancel: CancellationToken, + ) where S: h3x::webtransport::Session + 'static, S::StreamReader: 'static, S::StreamWriter: 'static, { - let connected_port = self.bound_addr.port(); - let connected_addr = self.bound_addr.ip().to_string(); - let mut tasks = JoinSet::new(); - - loop { - let (tcp_stream, peer_addr) = match self.listener.accept().await { - Ok(pair) => pair, - Err(e) => { - tracing::warn!( - error = %snafu::Report::from_error(&e), - "reverse-tcp accept error, stopping listener" - ); - break; - } - }; - + let connected_port = self.bound_port; + let connected_addr = self.advertised_host.clone(); + let mut listeners = JoinSet::new(); + for listener in self.listeners { let conversation = Arc::clone(&conversation); let connected_addr = connected_addr.clone(); - - tasks.spawn( + let relay_tasks = relay_tasks.clone(); + let relay_cancel = relay_cancel.clone(); + listeners.spawn( async move { - let channel_open = ForwardedTcpip { - connected_address: connected_addr.into(), - connected_port: (connected_port as u32).into(), - originator_address: peer_addr.ip().to_string().into(), - originator_port: (peer_addr.port() as u32).into(), - }; - conversation - .open_channel_and_relay(channel_open, tcp_stream) - .await; + run_tcp_accept_loop( + listener, + conversation, + connected_addr, + connected_port, + relay_tasks, + relay_cancel, + ) + .await; } .in_current_span(), ); } + + while let Some(result) = listeners.join_next().await { + if let Err(error) = result { + tracing::warn!( + error = %snafu::Report::from_error(&error), + "reverse-tcp listener task panicked" + ); + } + } + } +} + +async fn bind_tcp_forward_group( + host: &str, + bind_port: u16, +) -> Result { + use accept_tcp_forward_error::*; + + let candidates = resolve_bind_candidates(host).await?; + let mut listeners = Vec::new(); + let mut logical_port = None; + + for candidate in candidates { + let requested = logical_port.unwrap_or(bind_port); + let mut addr = candidate; + addr.set_port(requested); + match bind_tcp_socket(addr) { + Ok(listener) => { + if logical_port.is_none() { + let local_addr = listener.local_addr().context(LocalAddrSnafu)?; + logical_port = Some(local_addr.port()); + } + listeners.push(listener); + } + Err(error) if logical_port.is_some() => { + tracing::debug!( + error = %snafu::Report::from_error(&error), + %host, + requested, + "reverse-tcp secondary bind failed" + ); + } + Err(source) => return Err(AcceptTcpForwardError::TcpBind { source }), + } + } + + let Some(bound_port) = logical_port else { + return Err(AcceptTcpForwardError::TcpBind { + source: std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "no listeners created", + ), + }); + }; + + Ok(TcpForwardListener { + advertised_host: host.to_owned(), + bound_port, + listeners, + }) +} + +async fn run_tcp_accept_loop( + listener: TcpListener, + conversation: Arc>, + connected_addr: String, + connected_port: u16, + relay_tasks: TaskTracker, + relay_cancel: CancellationToken, +) where + S: h3x::webtransport::Session + 'static, + S::StreamReader: 'static, + S::StreamWriter: 'static, +{ + loop { + let (tcp_stream, peer_addr) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + tracing::warn!( + error = %snafu::Report::from_error(&e), + "reverse-tcp accept error, stopping listener" + ); + break; + } + }; + + let conversation = Arc::clone(&conversation); + let connected_addr = connected_addr.clone(); + let relay_cancel = relay_cancel.clone(); + + relay_tasks.spawn( + async move { + let channel_open = ForwardedTcpip { + connected_address: connected_addr.into(), + connected_port: (connected_port as u32).into(), + originator_address: peer_addr.ip().to_string().into(), + originator_port: (peer_addr.port() as u32).into(), + }; + conversation + .open_channel_and_relay(channel_open, tcp_stream, relay_cancel) + .await; + } + .in_current_span(), + ); } } @@ -168,15 +371,18 @@ impl UnixForwardListener { /// Runs until the listener encounters an accept error. Cancel the /// enclosing task to stop the listener. The socket file is removed /// when this future is dropped (including on cancellation). - pub async fn run(self, conversation: Arc>) - where + pub async fn run( + self, + conversation: Arc>, + relay_tasks: TaskTracker, + relay_cancel: CancellationToken, + ) where S: h3x::webtransport::Session + 'static, S::StreamReader: 'static, S::StreamWriter: 'static, { let _guard = self.guard; let socket_path = &_guard.0; - let mut tasks = JoinSet::new(); loop { let (unix_stream, _) = match self.listener.accept().await { @@ -192,14 +398,15 @@ impl UnixForwardListener { let conversation = Arc::clone(&conversation); let path = socket_path.display().to_string(); + let relay_cancel = relay_cancel.clone(); - tasks.spawn( + relay_tasks.spawn( async move { let channel_open = ForwardedStreamlocal { socket_path: path.into(), }; conversation - .open_channel_and_relay(channel_open, unix_stream) + .open_channel_and_relay(channel_open, unix_stream, relay_cancel) .await; } .in_current_span(), @@ -233,38 +440,29 @@ where use accept_tcp_forward_error::*; let bind_address = self.payload().bind_address.to_string(); - // Empty or wildcard bind address means "all interfaces" (OpenSSH convention). - let bind_addr = match bind_address.as_str() { - "" | "*" => "0.0.0.0", - other => other, - }; - let bind_port = u16::try_from(self.payload().bind_port.into_inner()) - .map_err(|_| AcceptTcpForwardError::PortOverflow)?; - - let listener = match TcpListener::bind((bind_addr, bind_port)).await { - Ok(l) => l, - Err(source) => { + let bind_host = crate::forward::canonicalize_remote_bind_host(&bind_address).into_owned(); + let bind_port = match u16::try_from(self.payload().bind_port.into_inner()) { + Ok(port) => port, + Err(_) => { let _ = self.respond_failure().await; - return Err(AcceptTcpForwardError::TcpBind { source }); + return Err(AcceptTcpForwardError::PortOverflow); } }; - let bound_addr = match listener.local_addr() { - Ok(a) => a, - Err(source) => { + + let listener = match bind_tcp_forward_group(&bind_host, bind_port).await { + Ok(listener) => listener, + Err(error) => { let _ = self.respond_failure().await; - return Err(AcceptTcpForwardError::LocalAddr { source }); + return Err(error); } }; let reply = TcpipForwardReply { - allocated_port: VarInt::from(bound_addr.port() as u32), + allocated_port: VarInt::from(listener.bound_port() as u32), }; self.respond_success(reply).await.context(RespondSnafu)?; - Ok(TcpForwardListener { - listener, - bound_addr, - }) + Ok(listener) } } @@ -314,8 +512,12 @@ where /// through it bidirectionally. /// /// On failure to open the channel, logs a warning and returns silently. - pub(crate) async fn open_channel_and_relay(&self, channel_open: C, local_stream: T) - where + pub(crate) async fn open_channel_and_relay( + &self, + channel_open: C, + local_stream: T, + relay_cancel: CancellationToken, + ) where C: ChannelOpen, for<'w> C: EncodeInto< &'w mut h3x::codec::SinkWriter, @@ -336,13 +538,42 @@ where }; let (local_reader, local_writer) = tokio::io::split(local_stream); - let ch2s = tokio::spawn(relay(reader, local_writer).in_current_span()); - let s2ch = tokio::spawn(relay(local_reader, writer).in_current_span()); - let (r1, r2) = tokio::join!(ch2s, s2ch); - if let Err(e) = r1 { + let mut ch2s = tokio::spawn(relay(reader, local_writer).in_current_span()); + let mut s2ch = tokio::spawn(relay(local_reader, writer).in_current_span()); + let ch2s_abort = ch2s.abort_handle(); + let s2ch_abort = s2ch.abort_handle(); + enum RelayOutcome { + Joined((A, B)), + Cancelled, + } + let relay_outcome = tokio::select! { + _ = relay_cancel.cancelled() => { + ch2s_abort.abort(); + s2ch_abort.abort(); + RelayOutcome::Cancelled + } + result = async { + let r1 = (&mut ch2s).await; + let r2 = (&mut s2ch).await; + (r1, r2) + } => RelayOutcome::Joined(result), + }; + let (r1, r2) = match relay_outcome { + RelayOutcome::Joined(result) => result, + RelayOutcome::Cancelled => { + let r1 = ch2s.await; + let r2 = s2ch.await; + (r1, r2) + } + }; + if let Err(e) = r1 + && !e.is_cancelled() + { tracing::warn!(error = %snafu::Report::from_error(&e), "reverse relay task panicked"); } - if let Err(e) = r2 { + if let Err(e) = r2 + && !e.is_cancelled() + { tracing::warn!(error = %snafu::Report::from_error(&e), "reverse relay task panicked"); } } @@ -370,16 +601,89 @@ mod tests { )) } + #[test] + fn bind_candidates_expand_loopback_and_wildcard_semantics() { + assert_eq!( + bind_candidates("localhost"), + vec![ + std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)), + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)), + ] + ); + assert_eq!( + bind_candidates(""), + vec![ + std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0)), + std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0)), + ] + ); + } + + #[test] + fn explicit_single_family_bind_candidates_stay_single_family() { + assert_eq!( + bind_candidates("127.0.0.1"), + vec![std::net::SocketAddr::from(( + std::net::Ipv4Addr::LOCALHOST, + 0 + ))] + ); + assert_eq!( + bind_candidates("::"), + vec![std::net::SocketAddr::from(( + std::net::Ipv6Addr::UNSPECIFIED, + 0 + ))] + ); + } + + #[tokio::test] + async fn localhost_listener_group_uses_one_logical_port() { + let listener = bind_tcp_forward_group("localhost", 0).await.unwrap(); + assert_eq!(listener.advertised_host(), "localhost"); + assert_ne!(listener.bound_port(), 0); + assert!(!listener.listeners.is_empty()); + for concrete in &listener.listeners { + assert_eq!(concrete.local_addr().unwrap().port(), listener.bound_port()); + } + } + #[tokio::test] - async fn tcp_forward_bind_and_cancel() { + async fn tcp_forward_bind_and_cancel_stops_new_accepts() { + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); let conv = make_conversation(make_test_session()); let listener = TcpForwardListener::bind("127.0.0.1:0").await.unwrap(); - assert_ne!(listener.bound_addr().port(), 0, "should get a real port"); + let port = listener.bound_port(); + assert_ne!(port, 0, "should get a real port"); - let handle = tokio::spawn(listener.run(conv)); + let handle = tokio::spawn(listener.run(conv, relays, relay_cancel)); handle.abort(); let _ = handle.await; + + for _ in 0..20 { + match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::AddrNotAvailable + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + ) => + { + return; + } + Ok(stream) => { + drop(stream); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Err(error) => panic!("unexpected connect error after cancel: {error}"), + } + } + + panic!("listener still accepted new TCP connections after cancel"); } #[tokio::test] @@ -387,12 +691,14 @@ mod tests { use h3x::codec::DecodeExt; use tokio::io::AsyncWriteExt; + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); let session = make_test_session(); let conv = make_conversation(session.clone()); let listener = TcpForwardListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.bound_addr().port(); - let handle = tokio::spawn(listener.run(Arc::clone(&conv))); + let handle = tokio::spawn(listener.run(Arc::clone(&conv), relays, relay_cancel)); let stream_id = VarInt::from_u32(44); let (remote_rd, local_wr) = make_half(stream_id); @@ -411,8 +717,10 @@ mod tests { assert_eq!(stream_kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); - let _connected_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); - let _connected_port: VarInt = remote_rd.decode_one().await.unwrap(); + let connected_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let connected_port: VarInt = remote_rd.decode_one().await.unwrap(); + assert_eq!(&*connected_addr, "127.0.0.1"); + assert_eq!(connected_port, VarInt::from_u32(port as u32)); let _orig_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); let _orig_port: VarInt = remote_rd.decode_one().await.unwrap(); @@ -433,7 +741,9 @@ mod tests { } #[tokio::test] - async fn unix_forward_bind_and_cancel() { + async fn unix_forward_bind_and_cancel_stops_new_accepts_and_cleans_up_socket() { + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); let dir = tempfile::tempdir().unwrap(); let sock_path = dir.path().join("test.sock"); @@ -443,7 +753,7 @@ mod tests { assert!(sock_path.exists(), "socket file should exist after bind"); let sock_path_clone = sock_path.clone(); - let handle = tokio::spawn(listener.run(conv)); + let handle = tokio::spawn(listener.run(conv, relays, relay_cancel)); handle.abort(); let _ = handle.await; @@ -453,10 +763,118 @@ mod tests { !sock_path_clone.exists(), "socket file should be cleaned up on cancel" ); + + let error = tokio::net::UnixStream::connect(&sock_path_clone) + .await + .expect_err("unix listener should stop accepting after cancel"); + assert!( + matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionReset + ), + "unexpected unix connect error after cancel: {error}", + ); + } + + #[tokio::test] + async fn aborting_listener_task_keeps_established_tcp_forward_relay_alive() { + use h3x::codec::{DecodeExt, EncodeExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_util::{sync::CancellationToken, task::TaskTracker}; + + let session = make_test_session(); + let conv = make_conversation(session.clone()); + let listener = TcpForwardListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.bound_port(); + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); + + let stream_id = VarInt::from_u32(60); + let (mut remote_rd, local_wr) = make_half(stream_id); + let (local_rd, mut remote_wr) = make_half(stream_id); + session.provide_open_stream(local_rd, local_wr); + + let handle = + tokio::spawn(listener.run(Arc::clone(&conv), relays.clone(), relay_cancel.clone())); + + let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let _stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); + let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); + let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _connected_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _connected_port: VarInt = remote_rd.decode_one().await.unwrap(); + let _originator_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _originator_port: VarInt = remote_rd.decode_one().await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(91)).await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(32768)).await.unwrap(); + remote_wr.flush().await.unwrap(); + + handle.abort(); + let _ = handle.await; + + tcp.write_all(b"still-alive").await.unwrap(); + let mut buf = [0u8; 11]; + remote_rd.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"still-alive"); + + relay_cancel.cancel(); + relays.close(); + relays.wait().await; + } + + #[tokio::test] + async fn aborting_listener_task_keeps_established_unix_forward_relay_alive() { + use h3x::codec::{DecodeExt, EncodeExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_util::{sync::CancellationToken, task::TaskTracker}; + + let dir = tempfile::tempdir().unwrap(); + let sock_path = dir.path().join("relay.sock"); + let session = make_test_session(); + let conv = make_conversation(session.clone()); + let listener = UnixForwardListener::bind(&sock_path).unwrap(); + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); + + let stream_id = VarInt::from_u32(62); + let (mut remote_rd, local_wr) = make_half(stream_id); + let (local_rd, mut remote_wr) = make_half(stream_id); + session.provide_open_stream(local_rd, local_wr); + + let handle = + tokio::spawn(listener.run(Arc::clone(&conv), relays.clone(), relay_cancel.clone())); + + let mut unix = tokio::net::UnixStream::connect(&sock_path).await.unwrap(); + let _stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); + let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); + let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _socket_path: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + let _reserved: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(91)).await.unwrap(); + remote_wr.encode_one(VarInt::from_u32(32768)).await.unwrap(); + remote_wr.flush().await.unwrap(); + + handle.abort(); + let _ = handle.await; + + unix.write_all(b"still-alive").await.unwrap(); + let mut buf = [0u8; 11]; + remote_rd.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"still-alive"); + + relay_cancel.cancel(); + relays.close(); + relays.wait().await; } #[tokio::test] async fn drop_cleans_up_unix() { + let relays = TaskTracker::new(); + let relay_cancel = CancellationToken::new(); let dir = tempfile::tempdir().unwrap(); let sock_path = dir.path().join("drop-test.sock"); @@ -465,7 +883,7 @@ mod tests { let listener = UnixForwardListener::bind(&sock_path).unwrap(); assert!(sock_path.exists(), "socket file should exist after bind"); - let handle = tokio::spawn(listener.run(conv)); + let handle = tokio::spawn(listener.run(conv, relays, relay_cancel)); handle.abort(); let _ = handle.await; diff --git a/src/forward/spec.rs b/src/forward/spec.rs index 78ecce2..24fb655 100644 --- a/src/forward/spec.rs +++ b/src/forward/spec.rs @@ -174,7 +174,10 @@ peg::parser! { } / bp:port() ":" c:connect_endpoint() { RemoteForward { - bind: Endpoint::Tcp { host: String::new(), port: bp }, + bind: Endpoint::Tcp { + host: crate::forward::CANONICAL_REMOTE_LOOPBACK_HOST.to_owned(), + port: bp, + }, connect: Some(c), } } @@ -199,7 +202,10 @@ peg::parser! { } / bp:port() { RemoteForward { - bind: Endpoint::Tcp { host: String::new(), port: bp }, + bind: Endpoint::Tcp { + host: crate::forward::CANONICAL_REMOTE_LOOPBACK_HOST.to_owned(), + port: bp, + }, connect: None, } } @@ -416,7 +422,7 @@ mod tests { assert_eq!( f.bind, Endpoint::Tcp { - host: String::new(), + host: "localhost".into(), port: 8080 } ); @@ -454,7 +460,7 @@ mod tests { assert_eq!( f.bind, Endpoint::Tcp { - host: String::new(), + host: "localhost".into(), port: 8080 } ); @@ -498,7 +504,7 @@ mod tests { assert_eq!( f.bind, Endpoint::Tcp { - host: String::new(), + host: "localhost".into(), port: 8080 } ); diff --git a/src/session/dispatcher.rs b/src/session/dispatcher.rs index f1ca21e..eb956eb 100644 --- a/src/session/dispatcher.rs +++ b/src/session/dispatcher.rs @@ -31,6 +31,7 @@ use std::sync::Arc; use snafu::prelude::*; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::task::{AbortHandle, JoinSet}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; use crate::channel::reason_code; use crate::conversation::channel::{ChannelEvent, ReadChannelEventError, SshChannel}; @@ -288,6 +289,8 @@ where let mut channel_tasks: JoinSet> = JoinSet::new(); let mut forward_tasks: JoinSet<()> = JoinSet::new(); + let forward_relays = TaskTracker::new(); + let forward_cancel = CancellationToken::new(); let mut had_session = false; let mut outcome = RunSessionOutcome::ConversationClosed; @@ -417,7 +420,16 @@ where global_result = &mut accept_global => { match global_result { Ok(incoming) => { - dispatch_global(incoming, &conversation, &mut tcp_forwards, &mut unix_forwards, &mut forward_tasks).await; + dispatch_global( + incoming, + &conversation, + &mut tcp_forwards, + &mut unix_forwards, + &mut forward_tasks, + forward_relays.clone(), + forward_cancel.clone(), + ) + .await; // Reset the pinned future for the next global request. accept_global.set(conversation.accept_global_request()); } @@ -464,6 +476,26 @@ where } } + for (_, abort) in tcp_forwards.drain() { + abort.abort(); + } + for (_, abort) in unix_forwards.drain() { + abort.abort(); + } + while let Some(result) = forward_tasks.join_next().await { + if let Err(error) = result + && !error.is_cancelled() + { + tracing::warn!( + error = %snafu::Report::from_error(&error), + "forward task panicked during shutdown" + ); + } + } + forward_cancel.cancel(); + forward_relays.close(); + forward_relays.wait().await; + Ok(outcome) } @@ -499,6 +531,8 @@ async fn dispatch_global( tcp_forwards: &mut HashMap<(String, u16), AbortHandle>, unix_forwards: &mut HashMap, forward_tasks: &mut JoinSet<()>, + forward_relays: TaskTracker, + forward_cancel: CancellationToken, ) where S: h3x::webtransport::Session + 'static, S::StreamReader: 'static, @@ -516,13 +550,18 @@ async fn dispatch_global( .await { Ok(decoded) => { - let bind_addr = decoded.payload().bind_address.to_string(); + let bind_addr = crate::forward::canonicalize_remote_bind_host( + &decoded.payload().bind_address.to_string(), + ) + .into_owned(); match decoded.accept_tcp_forward().await { Ok(listener) => { - let port = listener.bound_addr().port(); + let port = listener.bound_port(); + let relay_tasks = forward_relays.clone(); + let relay_cancel = forward_cancel.clone(); let abort = forward_tasks.spawn( listener - .run(conversation.clone()) + .run(conversation.clone(), relay_tasks, relay_cancel) .instrument(tracing::info_span!("tcp-forward", port)), ); tcp_forwards.insert((bind_addr, port), abort); @@ -543,7 +582,10 @@ async fn dispatch_global( .await { Ok(decoded) => { - let bind_addr = decoded.payload().bind_address.to_string(); + let bind_addr = crate::forward::canonicalize_remote_bind_host( + &decoded.payload().bind_address.to_string(), + ) + .into_owned(); let bind_port = match u16::try_from(decoded.payload().bind_port.into_inner()) { Ok(p) => p, @@ -581,13 +623,15 @@ async fn dispatch_global( let socket_path = decoded.payload().socket_path.to_string(); match decoded.accept_unix_forward().await { Ok(listener) => { + let relay_tasks = forward_relays.clone(); + let relay_cancel = forward_cancel.clone(); let abort = forward_tasks.spawn( - listener.run(conversation.clone()).instrument( - tracing::info_span!( + listener + .run(conversation.clone(), relay_tasks, relay_cancel) + .instrument(tracing::info_span!( "unix-forward", path = &*socket_path - ), - ), + )), ); unix_forwards.insert(socket_path, abort); } @@ -637,3 +681,128 @@ async fn dispatch_global( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::codec::{SshBool, SshString}; + use crate::conversation::Conversation; + use crate::test_support::{MockWebTransportSession as TestSession, stream_pair as make_half}; + use h3x::{ + codec::{DecodeExt, EncodeExt}, + stream_id::StreamId, + varint::VarInt, + }; + use tokio::io::AsyncWriteExt; + use tokio_util::{sync::CancellationToken, task::TaskTracker}; + + async fn make_conversation() -> ( + Arc>, + crate::test_support::MockReader, + crate::test_support::MockWriter, + ) { + let stream_id = VarInt::from_u32(40); + let (local_reader, remote_writer) = make_half(stream_id); + let (remote_reader, local_writer) = make_half(stream_id); + let conv = Arc::new(Conversation::from_control_streams( + TestSession::new(StreamId(stream_id)), + "test-version", + local_reader, + local_writer, + )); + (conv, remote_reader, remote_writer) + } + + async fn remote_send_cancel_tcpip_forward( + writer: &mut crate::test_support::MockWriter, + host: &str, + raw_port: u64, + ) { + writer.encode_one(VarInt::from_u32(80)).await.unwrap(); + writer + .encode_one(SshString::from_static("cancel-tcpip-forward")) + .await + .unwrap(); + writer.encode_one(SshBool(true)).await.unwrap(); + writer + .encode_one(SshString::from(host.to_owned())) + .await + .unwrap(); + writer + .encode_one(VarInt::try_from(raw_port).unwrap()) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + + #[tokio::test] + async fn cancel_tcpip_forward_port_overflow_responds_failure() { + let (conv, mut remote_reader, mut remote_writer) = make_conversation().await; + remote_send_cancel_tcpip_forward(&mut remote_writer, "localhost", 70000).await; + + let incoming = conv.accept_global_request().await.unwrap(); + let mut tcp_forwards = std::collections::HashMap::new(); + let mut unix_forwards = std::collections::HashMap::new(); + let mut forward_tasks = tokio::task::JoinSet::new(); + let relay_cancel = CancellationToken::new(); + let relay_tasks = TaskTracker::new(); + + dispatch_global( + incoming, + &conv, + &mut tcp_forwards, + &mut unix_forwards, + &mut forward_tasks, + relay_tasks, + relay_cancel, + ) + .await; + + let msg_type: VarInt = remote_reader.decode_one().await.unwrap(); + assert_eq!(msg_type, VarInt::from_u32(82)); + } + + #[tokio::test] + async fn tcpip_forward_port_overflow_responds_failure() { + let (conv, mut remote_reader, mut remote_writer) = make_conversation().await; + remote_writer + .encode_one(VarInt::from_u32(80)) + .await + .unwrap(); + remote_writer + .encode_one(SshString::from_static("tcpip-forward")) + .await + .unwrap(); + remote_writer.encode_one(SshBool(true)).await.unwrap(); + remote_writer + .encode_one(SshString::from_static("localhost")) + .await + .unwrap(); + remote_writer + .encode_one(VarInt::from(70000u32)) + .await + .unwrap(); + remote_writer.flush().await.unwrap(); + + let incoming = conv.accept_global_request().await.unwrap(); + let mut tcp_forwards = std::collections::HashMap::new(); + let mut unix_forwards = std::collections::HashMap::new(); + let mut forward_tasks = tokio::task::JoinSet::new(); + let relay_cancel = CancellationToken::new(); + let relay_tasks = TaskTracker::new(); + + dispatch_global( + incoming, + &conv, + &mut tcp_forwards, + &mut unix_forwards, + &mut forward_tasks, + relay_tasks, + relay_cancel, + ) + .await; + + let msg_type: VarInt = remote_reader.decode_one().await.unwrap(); + assert_eq!(msg_type, VarInt::from_u32(82)); + } +} diff --git a/src/session/process.rs b/src/session/process.rs index ea6cd0d..3b9114e 100644 --- a/src/session/process.rs +++ b/src/session/process.rs @@ -761,8 +761,8 @@ mod tests { use super::*; use crate::codec::SshBytes; use crate::conversation::channel::ChannelEvent; - use crate::session::dispatcher::SessionConfig; use crate::session::PtyRequest; + use crate::session::dispatcher::SessionConfig; use std::pin::Pin; use std::task::{Context, Poll}; @@ -819,7 +819,7 @@ mod tests { .await .unwrap(); events.push(RecordedEvent::ExitStatus( - req.exit_status.into_inner() as u32, + req.exit_status.into_inner() as u32 )); } else if &**incoming.request_type() == "exit-signal" { let (req, _) = incoming @@ -1115,14 +1115,20 @@ mod tests { .unwrap(); let data_index = events .iter() - .position(|event| matches!(event, RecordedEvent::Data(text) if text.contains("late-pty"))) + .position( + |event| matches!(event, RecordedEvent::Data(text) if text.contains("late-pty")), + ) .unwrap(); assert!( exit_index < data_index, "expected exit-status before late PTY data drain: {events:#?}", ); - assert!(events.iter().any(|event| matches!(event, RecordedEvent::Eof))); + assert!( + events + .iter() + .any(|event| matches!(event, RecordedEvent::Eof)) + ); assert!(matches!(events.last(), Some(RecordedEvent::Close))); drop(channel); From 1bf39c76932145e3d3899f544d1806ecc29cbb87 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 11:23:21 +0800 Subject: [PATCH 35/39] release: prepare v0.3.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ae490cd..ae9a970 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dssh" description = "DSSH core session, channel, and wire-format foundation" -version = "0.2.0" +version = "0.3.0" edition = "2024" repository = "https://github.com/genmeta/dssh" From 0a746125ddf89f35ca8ee54e4ed98e9f6b62eda0 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 12:40:08 +0800 Subject: [PATCH 36/39] release: converge h3x tag and gate forward helpers --- Cargo.toml | 2 +- src/forward/mod.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ae9a970..65bb199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" repository = "https://github.com/genmeta/dssh" [dependencies] -h3x = { git = "https://github.com/genmeta/h3x.git", branch = "endpoint", version = "0.3.0", features = [ +h3x = { git = "https://github.com/genmeta/h3x.git", tag = "v0.3.0", version = "0.3.0", features = [ "rpc", "serde", "webtransport", diff --git a/src/forward/mod.rs b/src/forward/mod.rs index d485cbe..4b20183 100644 --- a/src/forward/mod.rs +++ b/src/forward/mod.rs @@ -1,3 +1,4 @@ +#[cfg(any(feature = "client", feature = "server", test))] use std::borrow::Cow; #[cfg(feature = "client")] @@ -20,9 +21,12 @@ use h3x::{ use snafu::{ResultExt, Snafu}; use tokio::io::{self, AsyncRead, AsyncWrite, AsyncWriteExt}; +#[cfg(any(feature = "client", feature = "server", test))] pub(crate) const CANONICAL_REMOTE_WILDCARD_HOST: &str = ""; +#[cfg(any(feature = "client", feature = "server", test))] pub(crate) const CANONICAL_REMOTE_LOOPBACK_HOST: &str = "localhost"; +#[cfg(any(feature = "client", feature = "server", test))] pub(crate) fn canonicalize_remote_bind_host(host: &str) -> Cow<'_, str> { match host { "" | "*" => Cow::Borrowed(CANONICAL_REMOTE_WILDCARD_HOST), From db17345b0cd21e60cf29a9148275223bb1aa6c4f Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 16:08:20 +0800 Subject: [PATCH 37/39] release: converge registry deps and gate crates publish --- .github/workflows/publish-crates.yml | 55 +++++++++++++++++++++++++++- Cargo.toml | 2 +- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 4aae40c..f5d7dba 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -57,8 +57,59 @@ jobs: mode=dry-run fi + package_name=dssh + package_version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json, sys; print(json.load(sys.stdin)["packages"][0]["version"])')" + if [[ "$mode" == "dry-run" ]]; then cargo publish --dry-run --locked - else - cargo publish --locked + exit 0 fi + + crate_state="$( + python3 - <<'PY' "$package_name" "$package_version" + import sys + import urllib.error + import urllib.request + + name, version = sys.argv[1], sys.argv[2] + headers = {"User-Agent": "genmeta dssh publish workflow"} + version_url = f"https://crates.io/api/v1/crates/{name}/{version}" + version_request = urllib.request.Request(version_url, headers=headers) + try: + with urllib.request.urlopen(version_request, timeout=20) as response: + if response.status == 200: + print("published_version") + else: + raise SystemExit(f"unexpected crates.io status for {name} {version}: {response.status}") + except urllib.error.HTTPError as error: + if error.code == 404: + crate_url = f"https://crates.io/api/v1/crates/{name}" + crate_request = urllib.request.Request(crate_url, headers=headers) + try: + with urllib.request.urlopen(crate_request, timeout=20) as response: + if response.status == 200: + print("missing_version") + else: + raise SystemExit(f"unexpected crates.io crate status for {name}: {response.status}") + except urllib.error.HTTPError as crate_error: + if crate_error.code == 404: + print("missing_crate") + else: + raise + else: + raise + PY + )" + + if [[ "$crate_state" == "published_version" ]]; then + echo "skip $package_name $package_version (already on crates.io)" + exit 0 + fi + + if [[ "$crate_state" == "missing_crate" ]]; then + echo "skip $package_name $package_version (crate not yet initialized on crates.io)" + exit 0 + fi + + echo "publish $package_name $package_version" + cargo publish --locked diff --git a/Cargo.toml b/Cargo.toml index 65bb199..6f65e6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" repository = "https://github.com/genmeta/dssh" [dependencies] -h3x = { git = "https://github.com/genmeta/h3x.git", tag = "v0.3.0", version = "0.3.0", features = [ +h3x = { version = "0.3.1", features = [ "rpc", "serde", "webtransport", From 27295b1246494c2833bbd96a1873b4d18d27a4b3 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 16:29:12 +0800 Subject: [PATCH 38/39] ci: gate dry-run by registry publish eligibility --- .github/workflows/publish-crates.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index f5d7dba..ac28acb 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -60,11 +60,6 @@ jobs: package_name=dssh package_version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json, sys; print(json.load(sys.stdin)["packages"][0]["version"])')" - if [[ "$mode" == "dry-run" ]]; then - cargo publish --dry-run --locked - exit 0 - fi - crate_state="$( python3 - <<'PY' "$package_name" "$package_version" import sys @@ -111,5 +106,11 @@ jobs: exit 0 fi + if [[ "$mode" == "dry-run" ]]; then + echo "dry-run $package_name $package_version" + cargo publish --dry-run --locked + exit 0 + fi + echo "publish $package_name $package_version" cargo publish --locked From 425347f6509e416f9b6afe21cccd30422add2430 Mon Sep 17 00:00:00 2001 From: eareimu Date: Mon, 15 Jun 2026 17:08:48 +0800 Subject: [PATCH 39/39] refactor: rename dssh crate to dshell --- .github/workflows/publish-crates.yml | 10 +- Cargo.toml | 4 +- src/client.rs | 4 +- src/codec.rs | 2 +- src/constants.rs | 8 +- src/conversation.rs | 34 ++++--- src/conversation/tests.rs | 12 +-- src/error.rs | 8 +- src/forward/reverse.rs | 4 +- src/lib.rs | 6 +- src/message.rs | 4 +- src/session/dispatcher.rs | 4 +- src/session/mod.rs | 2 +- src/session/process.rs | 4 +- src/session/pty.rs | 2 +- src/session/signal.rs | 2 +- src/test_support.rs | 4 +- src/version.rs | 47 ++++----- src/webtransport.rs | 136 +++++++++++++-------------- 19 files changed, 154 insertions(+), 143 deletions(-) diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index ac28acb..cd11247 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -44,7 +44,7 @@ jobs: uses: rust-lang/crates-io-auth-action@v1 id: auth - - name: Release dssh crate + - name: Release dshell crate shell: bash env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} @@ -57,7 +57,7 @@ jobs: mode=dry-run fi - package_name=dssh + package_name=dshell package_version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json, sys; print(json.load(sys.stdin)["packages"][0]["version"])')" crate_state="$( @@ -67,7 +67,7 @@ jobs: import urllib.request name, version = sys.argv[1], sys.argv[2] - headers = {"User-Agent": "genmeta dssh publish workflow"} + headers = {"User-Agent": "genmeta dshell publish workflow"} version_url = f"https://crates.io/api/v1/crates/{name}/{version}" version_request = urllib.request.Request(version_url, headers=headers) try: @@ -108,9 +108,9 @@ jobs: if [[ "$mode" == "dry-run" ]]; then echo "dry-run $package_name $package_version" - cargo publish --dry-run --locked + cargo publish --dry-run exit 0 fi echo "publish $package_name $package_version" - cargo publish --locked + cargo publish diff --git a/Cargo.toml b/Cargo.toml index 6f65e6e..dfb5437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "dssh" -description = "DSSH core session, channel, and wire-format foundation" +name = "dshell" +description = "DShell core session, channel, and wire-format foundation" version = "0.3.0" edition = "2024" repository = "https://github.com/genmeta/dssh" diff --git a/src/client.rs b/src/client.rs index fcc74a1..0236dbd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ -//! SSH3 client utilities. +//! DShell client utilities. //! -//! Provides helpers for SSH3/DSSH client implementations. +//! Provides helpers for DShell/DShell client implementations. use base64::engine::{Engine, general_purpose::STANDARD}; use http::HeaderValue; diff --git a/src/codec.rs b/src/codec.rs index 0087430..b624a2f 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -1,4 +1,4 @@ -//! SSH3 binary wire format codec. +//! DShell binary wire format codec. //! //! All types use QUIC varint length-prefix + raw bytes encoding, //! implementing h3x's `EncodeInto`/`DecodeFrom` traits on `AsyncWrite`/`AsyncRead`. diff --git a/src/constants.rs b/src/constants.rs index c2c8b74..173c3a4 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,12 +1,12 @@ use h3x::varint::VarInt; -pub const SSH_VERSION: &str = "dssh-00"; +pub const DSHELL_VERSION: &str = "dshell-00"; -pub const SUPPORTED_SSH_VERSIONS: &[&str] = &[SSH_VERSION]; +pub const SUPPORTED_DSHELL_VERSIONS: &[&str] = &[DSHELL_VERSION]; pub const CHANNEL_SIGNAL_VALUE: VarInt = VarInt::from_u32(0xaf3627e6); pub const DEFAULT_MAX_MESSAGE_SIZE: VarInt = VarInt::from_u32(1 << 20); -/// Well-known path for DSSH WebTransport Extended CONNECT requests. -pub const DSSH_CONNECT_PATH: &str = "/.well-known/dssh/connect"; +/// Well-known path for DShell WebTransport Extended CONNECT requests. +pub const DSHELL_CONNECT_PATH: &str = "/.well-known/dshell/connect"; diff --git a/src/conversation.rs b/src/conversation.rs index 4bfcb39..b125249 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -1,6 +1,6 @@ -//! SSH3 conversation (session) abstraction. +//! DShell conversation (session) abstraction. //! -//! A *conversation* is the SSH3 equivalent of an SSH2 session — it manages +//! A *conversation* is the DShell equivalent of an SSH2 session — it manages //! channels and global requests over a WebTransport session. //! //! # Design @@ -465,16 +465,16 @@ where &self.peer_version } - /// Open a DSSH conversation over a WebTransport session. + /// Open a DShell conversation over a WebTransport session. /// - /// This opens the DSSH control stream and writes the DSSH control stream + /// This opens the DShell control stream and writes the DShell control stream /// kind as the first field on that WebTransport bidirectional stream. pub async fn open( session: S, peer_version: impl Into, ) -> Result { let (reader, writer) = - Self::open_stream_kind(&session, crate::webtransport::DSSH_CONTROL_STREAM_KIND) + Self::open_stream_kind(&session, crate::webtransport::DSHELL_CONTROL_STREAM_KIND) .await .context(crate::webtransport::open_conversation_error::OpenControlSnafu)?; Ok(Self::from_control_streams( @@ -485,16 +485,16 @@ where )) } - /// Accept a DSSH conversation over a WebTransport session. + /// Accept a DShell conversation over a WebTransport session. /// - /// This accepts the DSSH control stream and validates that its first field - /// is the DSSH control stream kind. + /// This accepts the DShell control stream and validates that its first field + /// is the DShell control stream kind. pub async fn accept( session: S, peer_version: impl Into, ) -> Result { let (reader, writer) = - Self::accept_stream_kind(&session, crate::webtransport::DSSH_CONTROL_STREAM_KIND) + Self::accept_stream_kind(&session, crate::webtransport::DSHELL_CONTROL_STREAM_KIND) .await .context(crate::webtransport::accept_conversation_error::AcceptControlSnafu)?; Ok(Self::from_control_streams( @@ -511,7 +511,11 @@ where (StreamReader, SinkWriter), crate::webtransport::WebTransportStreamError, > { - Self::open_stream_kind(&self.session, crate::webtransport::DSSH_CHANNEL_STREAM_KIND).await + Self::open_stream_kind( + &self.session, + crate::webtransport::DSHELL_CHANNEL_STREAM_KIND, + ) + .await } async fn accept_channel_stream( @@ -520,7 +524,11 @@ where (StreamReader, SinkWriter), crate::webtransport::WebTransportStreamError, > { - Self::accept_stream_kind(&self.session, crate::webtransport::DSSH_CHANNEL_STREAM_KIND).await + Self::accept_stream_kind( + &self.session, + crate::webtransport::DSHELL_CHANNEL_STREAM_KIND, + ) + .await } async fn open_stream_kind( @@ -795,7 +803,7 @@ where /// Open a new channel. /// /// The WebTransport session carries the stream lifetime. This method only - /// writes the DSSH channel stream kind and SSH channel header fields: + /// writes the DShell channel stream kind and SSH channel header fields: /// `max_message_size`, `channel_type`, and the type-specific payload. /// /// Returns the (reader, writer) pair for subsequent channel communication. @@ -844,7 +852,7 @@ where /// Accept an incoming channel. /// /// The WebTransport session carries the stream lifetime. This method - /// accepts a WebTransport bidirectional stream, validates the DSSH channel + /// accepts a WebTransport bidirectional stream, validates the DShell channel /// stream kind, and reads the SSH channel header fields: `max_message_size` /// and `channel_type`. /// diff --git a/src/conversation/tests.rs b/src/conversation/tests.rs index c6d33c8..dc68e89 100644 --- a/src/conversation/tests.rs +++ b/src/conversation/tests.rs @@ -1201,7 +1201,7 @@ async fn open_channel_roundtrip() { let mut rr = ch_remote_reader; let kind: VarInt = rr.decode_one().await.unwrap(); - assert_eq!(kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); + assert_eq!(kind, crate::webtransport::DSHELL_CHANNEL_STREAM_KIND); let mms: VarInt = rr.decode_one().await.unwrap(); assert_eq!(mms, max_msg_size); @@ -1241,10 +1241,10 @@ async fn accept_channel_roundtrip() { let (_ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); // Remote encodes channel data starting at max_message_size - // (WebTransport session prefix is handled by h3x; DSSH stream kind is part of this test). + // (WebTransport session prefix is handled by h3x; DShell stream kind is part of this test). let mut rw = ch_remote_writer; let max_msg_size = VarInt::from_u32(1 << 20); - rw.encode_one(crate::webtransport::DSSH_CHANNEL_STREAM_KIND) + rw.encode_one(crate::webtransport::DSHELL_CHANNEL_STREAM_KIND) .await .unwrap(); rw.encode_one(max_msg_size).await.unwrap(); @@ -1285,9 +1285,9 @@ async fn accept_channel_session_no_payload() { let (_ch_remote_reader, ch_local_writer) = make_half(ch_stream_id); // Remote sends channel data starting at max_message_size - // (WebTransport session prefix is handled by h3x; DSSH stream kind is part of this test). + // (WebTransport session prefix is handled by h3x; DShell stream kind is part of this test). let mut rw = ch_remote_writer; - rw.encode_one(crate::webtransport::DSSH_CHANNEL_STREAM_KIND) + rw.encode_one(crate::webtransport::DSHELL_CHANNEL_STREAM_KIND) .await .unwrap(); rw.encode_one(VarInt::from_u32(1 << 20)).await.unwrap(); @@ -1320,7 +1320,7 @@ async fn open_channel_session_no_payload() { let mut rr = ch_remote_reader; let kind: VarInt = rr.decode_one().await.unwrap(); - assert_eq!(kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); + assert_eq!(kind, crate::webtransport::DSHELL_CHANNEL_STREAM_KIND); let mms: VarInt = rr.decode_one().await.unwrap(); assert_eq!(mms, VarInt::from_u32(1 << 20)); diff --git a/src/error.rs b/src/error.rs index 65db99e..096a9cd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,16 +5,16 @@ use snafu::Snafu; #[snafu(visibility(pub), module)] pub enum NegotiateVersionError { #[snafu(display("missing ssh-version header"))] - MissingSshVersionHeader, + MissingDshellVersionHeader, #[snafu(display("invalid ssh-version header value"))] - InvalidSshVersionHeaderValue { source: http::header::ToStrError }, + InvalidDshellVersionHeaderValue { source: http::header::ToStrError }, #[snafu(display("empty ssh-version header"))] - EmptySshVersionHeader, + EmptyDshellVersionHeader, #[snafu(display("no supported ssh-version found in client offer: {offered:?}"))] - UnsupportedSshVersion { offered: String }, + UnsupportedDshellVersion { offered: String }, } /// Error returned by [`crate::auth::parse_authorization_header`]. diff --git a/src/forward/reverse.rs b/src/forward/reverse.rs index f09a1ba..91ef3ca 100644 --- a/src/forward/reverse.rs +++ b/src/forward/reverse.rs @@ -1,4 +1,4 @@ -//! Reverse forwarding: bind listeners that open SSH3 channels back to the +//! Reverse forwarding: bind listeners that open DShell channels back to the //! client for each accepted connection. //! //! Use [`DecodedGlobalRequest::accept_tcp_forward`] and @@ -714,7 +714,7 @@ mod tests { let mut remote_wr = remote_wr; let stream_kind: VarInt = remote_rd.decode_one().await.unwrap(); - assert_eq!(stream_kind, crate::webtransport::DSSH_CHANNEL_STREAM_KIND); + assert_eq!(stream_kind, crate::webtransport::DSHELL_CHANNEL_STREAM_KIND); let _max_msg: VarInt = remote_rd.decode_one().await.unwrap(); let _channel_type: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); let connected_addr: crate::codec::SshString = remote_rd.decode_one().await.unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 3691217..ab7e92a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! SSH3 protocol types and codec +//! DShell protocol types and codec pub mod auth; pub mod byte_channel; @@ -23,13 +23,13 @@ mod test_support; #[cfg(test)] mod tests { #[test] - fn legacy_raw_ssh3_protocol_module_is_not_exported() { + fn legacy_raw_dshell_protocol_module_is_not_exported() { let lib = include_str!("lib.rs"); let legacy_protocol_export = concat!("pub mod ", "protocol;"); assert!( !lib.contains(legacy_protocol_export), - "dssh transport must stay WebTransport-only" + "dshell transport must stay WebTransport-only" ); } } diff --git a/src/message.rs b/src/message.rs index c7860c5..8f92dea 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,11 +1,11 @@ use h3x::varint::VarInt; -/// SSH global request/response message type constants (RFC 4254 / SSH3 draft). +/// SSH global request/response message type constants (RFC 4254 / DShell draft). pub const SSH_MSG_GLOBAL_REQUEST: VarInt = VarInt::from_u32(80); pub const SSH_MSG_REQUEST_SUCCESS: VarInt = VarInt::from_u32(81); pub const SSH_MSG_REQUEST_FAILURE: VarInt = VarInt::from_u32(82); -/// SSH channel message type constants (RFC 4254 / SSH3 draft). +/// SSH channel message type constants (RFC 4254 / DShell draft). /// /// These are VarInt wire values used by the trait-based encoding/decoding /// in [`conversation`](crate::conversation). No enum wrapper is needed. diff --git a/src/session/dispatcher.rs b/src/session/dispatcher.rs index eb956eb..2531236 100644 --- a/src/session/dispatcher.rs +++ b/src/session/dispatcher.rs @@ -1,6 +1,6 @@ //! Server-side session dispatcher. //! -//! Drives an SSH3 session by concurrently accepting channels and global +//! Drives an DShell session by concurrently accepting channels and global //! requests from a [`Conversation`], dispatching each to the appropriate //! handler. //! @@ -139,7 +139,7 @@ struct SessionSetup { /// Read session channel requests (pty-req, exec, shell) and respond. /// -/// The SSH3 protocol expects the client to send setup requests before any +/// The DShell protocol expects the client to send setup requests before any /// data. This function reads those requests using [`SshChannel::next_event`] /// (which has writer access for sending replies), then returns the determined /// command mode and optional PTY allocation. diff --git a/src/session/mod.rs b/src/session/mod.rs index 602a365..2565231 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,4 +1,4 @@ -//! SSH3 session types, PTY, signal handling, and process management. +//! DShell session types, PTY, signal handling, and process management. //! //! This module provides: //! - Session channel request/notice types (exec, shell, PTY, signal, window-change) diff --git a/src/session/process.rs b/src/session/process.rs index 3b9114e..ec2e1ae 100644 --- a/src/session/process.rs +++ b/src/session/process.rs @@ -1,4 +1,4 @@ -//! Process spawning and I/O relay for SSH3 session channels. +//! Process spawning and I/O relay for DShell session channels. //! //! Provides two execution modes: //! - **Piped**: stdin/stdout/stderr are separate pipes; stdout → channel data, @@ -724,7 +724,7 @@ where if let Some(signal_number) = status.signal() { let signal_name = signal::to_ssh_name(signal_number) .map(Cow::Borrowed) - .unwrap_or_else(|| Cow::Owned(format!("signal-{signal_number}@dssh"))); + .unwrap_or_else(|| Cow::Owned(format!("signal-{signal_number}@dshell"))); writer .notice(&ExitSignalChannelNotice { diff --git a/src/session/pty.rs b/src/session/pty.rs index 12d245e..ef274cd 100644 --- a/src/session/pty.rs +++ b/src/session/pty.rs @@ -1,4 +1,4 @@ -//! PTY allocation and terminal control for SSH3 sessions. +//! PTY allocation and terminal control for DShell sessions. //! //! Handles `pty-req` and `window-change` channel requests per RFC 4254 //! Sections 6.2 and 6.7. diff --git a/src/session/signal.rs b/src/session/signal.rs index 1750e94..de9492a 100644 --- a/src/session/signal.rs +++ b/src/session/signal.rs @@ -50,7 +50,7 @@ pub fn deliver(pid: nix::unistd::Pid, signal: Signal) -> Result<(), nix::Error> /// Map a Unix signal number to its SSH name (without "SIG" prefix). /// /// Returns `None` for unrecognized signal numbers. Uses a fallback format -/// `"signal-N@dssh"` for the caller to handle unknown signals. +/// `"signal-N@dshell"` for the caller to handle unknown signals. pub fn to_ssh_name(signal_number: i32) -> Option<&'static str> { use nix::libc; match signal_number { diff --git a/src/test_support.rs b/src/test_support.rs index 713fa13..26904e9 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -268,7 +268,7 @@ impl h3x::webtransport::Session for MockWebTransportSession { } async fn open_uni(&self) -> Result { - unreachable!("dssh tests use only bidirectional streams") + unreachable!("dshell tests use only bidirectional streams") } async fn accept_bi( @@ -286,6 +286,6 @@ impl h3x::webtransport::Session for MockWebTransportSession { } async fn accept_uni(&self) -> Result { - unreachable!("dssh tests use only bidirectional streams") + unreachable!("dshell tests use only bidirectional streams") } } diff --git a/src/version.rs b/src/version.rs index 467c6e6..7b686a9 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,49 +1,51 @@ -//! SSH3 version negotiation. +//! DShell version negotiation. //! //! The client sends a comma-separated list of supported versions in the //! `ssh-version` request header. The server picks the first match from -//! [`SUPPORTED_SSH_VERSIONS`] and echoes it back. +//! [`SUPPORTED_DSHELL_VERSIONS`] and echoes it back. -use crate::constants::SUPPORTED_SSH_VERSIONS; +use crate::constants::SUPPORTED_DSHELL_VERSIONS; use crate::error::{NegotiateVersionError, negotiate_version_error}; use snafu::ResultExt; -/// A negotiated SSH3 version. +/// A negotiated DShell version. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshVersion { +pub struct DshellVersion { pub version_string: String, } -/// Negotiate an SSH3 version from the client's `ssh-version` request header. +/// Negotiate a DShell version from the client's `ssh-version` request header. /// /// Returns the first client-offered version that the server also supports. -pub fn negotiate_version(headers: &http::HeaderMap) -> Result { +pub fn negotiate_version( + headers: &http::HeaderMap, +) -> Result { let header_value = headers .get("ssh-version") - .ok_or(NegotiateVersionError::MissingSshVersionHeader)? + .ok_or(NegotiateVersionError::MissingDshellVersionHeader)? .to_str() - .context(negotiate_version_error::InvalidSshVersionHeaderValueSnafu)?; + .context(negotiate_version_error::InvalidDshellVersionHeaderValueSnafu)?; if header_value.is_empty() { - return Err(NegotiateVersionError::EmptySshVersionHeader); + return Err(NegotiateVersionError::EmptyDshellVersionHeader); } for offered in header_value.split(',') { let trimmed = offered.trim(); - if SUPPORTED_SSH_VERSIONS.contains(&trimmed) { - return Ok(SshVersion { + if SUPPORTED_DSHELL_VERSIONS.contains(&trimmed) { + return Ok(DshellVersion { version_string: trimmed.to_owned(), }); } } - Err(NegotiateVersionError::UnsupportedSshVersion { + Err(NegotiateVersionError::UnsupportedDshellVersion { offered: header_value.to_owned(), }) } /// Build the `ssh-version` response header value. -pub fn version_response_header(version: &SshVersion) -> http::HeaderValue { +pub fn version_response_header(version: &DshellVersion) -> http::HeaderValue { http::HeaderValue::from_str(&version.version_string) .expect("negotiated version string must be a valid header value") } @@ -51,7 +53,7 @@ pub fn version_response_header(version: &SshVersion) -> http::HeaderValue { #[cfg(test)] mod tests { use super::*; - use crate::constants::SSH_VERSION; + use crate::constants::DSHELL_VERSION; fn headers_with(value: &str) -> http::HeaderMap { let mut map = http::HeaderMap::new(); @@ -61,14 +63,14 @@ mod tests { #[test] fn single_valid_version() { - let v = negotiate_version(&headers_with(SSH_VERSION)).unwrap(); - assert_eq!(v.version_string, SSH_VERSION); + let v = negotiate_version(&headers_with(DSHELL_VERSION)).unwrap(); + assert_eq!(v.version_string, DSHELL_VERSION); } #[test] fn multiple_picks_supported() { - let v = negotiate_version(&headers_with(&format!("unknown-v1,{SSH_VERSION}"))).unwrap(); - assert_eq!(v.version_string, SSH_VERSION); + let v = negotiate_version(&headers_with(&format!("unknown-v1,{DSHELL_VERSION}"))).unwrap(); + assert_eq!(v.version_string, DSHELL_VERSION); } #[test] @@ -79,14 +81,15 @@ mod tests { #[test] fn no_match() { - let err = negotiate_version(&headers_with("dssh-99")).unwrap_err(); + let err = negotiate_version(&headers_with("dshell-99")).unwrap_err(); assert!(err.to_string().contains("no supported")); } #[test] fn whitespace_handling() { - let v = negotiate_version(&headers_with(&format!(" {SSH_VERSION} , other-v1 "))).unwrap(); - assert_eq!(v.version_string, SSH_VERSION); + let v = + negotiate_version(&headers_with(&format!(" {DSHELL_VERSION} , other-v1 "))).unwrap(); + assert_eq!(v.version_string, DSHELL_VERSION); } #[test] diff --git a/src/webtransport.rs b/src/webtransport.rs index 8c68c36..69f6733 100644 --- a/src/webtransport.rs +++ b/src/webtransport.rs @@ -1,13 +1,13 @@ -//! DSSH over WebTransport stream adaptation. +//! DShell over WebTransport stream adaptation. //! -//! A WebTransport session provides bidirectional streams. DSSH reserves the -//! first field on each WebTransport bidirectional stream for a DSSH stream kind: +//! A WebTransport session provides bidirectional streams. DShell reserves the +//! first field on each WebTransport bidirectional stream for a DShell stream kind: //! -//! - [`DSSH_CONTROL_STREAM_KIND`] — the conversation control stream -//! - [`DSSH_CHANNEL_STREAM_KIND`] — SSH channel streams managed by +//! - [`DSHELL_CONTROL_STREAM_KIND`] — the conversation control stream +//! - [`DSHELL_CHANNEL_STREAM_KIND`] — SSH channel streams managed by //! [`Conversation`](crate::conversation::Conversation) //! -//! The WebTransport CONNECT stream is not used as a DSSH control stream. The +//! The WebTransport CONNECT stream is not used as a DShell control stream. The //! control stream is an ordinary WebTransport bidirectional stream marked with //! the control kind. @@ -19,21 +19,21 @@ use http::{HeaderValue, header::AUTHORIZATION, uri::Authority}; use http_body_util::{BodyExt, Empty}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; -use crate::constants::SSH_VERSION; +use crate::constants::DSHELL_VERSION; use crate::conversation::Conversation; use crate::error::NegotiateVersionError; -use crate::version::{SshVersion, negotiate_version, version_response_header}; +use crate::version::{DshellVersion, negotiate_version, version_response_header}; -/// DSSH-over-WebTransport stream kind for the conversation control stream. -pub const DSSH_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); +/// DShell-over-WebTransport stream kind for the conversation control stream. +pub const DSHELL_CONTROL_STREAM_KIND: VarInt = VarInt::from_u32(0); -/// DSSH-over-WebTransport stream kind for SSH channel streams. -pub const DSSH_CHANNEL_STREAM_KIND: VarInt = VarInt::from_u32(1); +/// DShell-over-WebTransport stream kind for SSH channel streams. +pub const DSHELL_CHANNEL_STREAM_KIND: VarInt = VarInt::from_u32(1); -/// DSSH conversation backed by a WebTransport session. +/// DShell conversation backed by a WebTransport session. pub type WebTransportConversation = Conversation; -/// DSSH conversation backed by a concrete h3x WebTransport session. +/// DShell conversation backed by a concrete h3x WebTransport session. pub type ClientWebTransportConversation = WebTransportConversation; @@ -45,70 +45,70 @@ pub struct AcceptedWebTransportSession { pub peer_version: String, } -/// Error returned when opening a DSSH conversation over WebTransport. +/// Error returned when opening a DShell conversation over WebTransport. #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum OpenConversationError { - #[snafu(display("failed to open dssh webtransport control stream"))] + #[snafu(display("failed to open dshell webtransport control stream"))] OpenControl { source: WebTransportStreamError }, } -/// Error returned when accepting a DSSH conversation over WebTransport. +/// Error returned when accepting a DShell conversation over WebTransport. #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum AcceptConversationError { - #[snafu(display("failed to accept dssh webtransport control stream"))] + #[snafu(display("failed to accept dshell webtransport control stream"))] AcceptControl { source: WebTransportStreamError }, } -/// Error returned when constructing a client-side DSSH WebTransport CONNECT +/// Error returned when constructing a client-side DShell WebTransport CONNECT /// request. #[derive(Debug, Snafu)] #[snafu(module)] pub enum BuildClientConnectRequestError { - #[snafu(display("failed to build dssh webtransport connect URI"))] + #[snafu(display("failed to build dshell webtransport connect URI"))] Uri { source: http::uri::InvalidUri }, - #[snafu(display("failed to build dssh webtransport connect request"))] + #[snafu(display("failed to build dshell webtransport connect request"))] Request { source: http::Error }, } -/// Error returned when opening a client-side DSSH conversation over +/// Error returned when opening a client-side DShell conversation over /// WebTransport. #[derive(Debug, Snafu)] #[snafu(module)] pub enum ClientConnectConversationError { - #[snafu(display("failed to build dssh webtransport connect request"))] + #[snafu(display("failed to build dshell webtransport connect request"))] BuildRequest { source: BuildClientConnectRequestError, }, - #[snafu(display("failed to execute dssh webtransport connect request"))] + #[snafu(display("failed to execute dshell webtransport connect request"))] Execute { source: h3x::hyper::RequestError, }, - #[snafu(display("failed to validate dssh peer version"))] + #[snafu(display("failed to validate dshell peer version"))] PeerVersion { source: NegotiateVersionError }, #[snafu(display("failed to establish extended connect"))] Establish { source: h3x::hyper::extended_connect::EstablishError, }, - #[snafu(display("successful dssh webtransport connect response was not validated"))] + #[snafu(display("successful dshell webtransport connect response was not validated"))] MissingValidatedPeerVersion, #[snafu(display("failed to register webtransport session"))] RegisterSession { source: h3x::webtransport::RegisterSessionError, }, - #[snafu(display("failed to open dssh webtransport conversation"))] + #[snafu(display("failed to open dshell webtransport conversation"))] OpenConversation { source: OpenConversationError }, } -/// Error returned when accepting a server-side DSSH WebTransport session from +/// Error returned when accepting a server-side DShell WebTransport session from /// an Extended CONNECT request. #[derive(Debug, Snafu)] #[snafu(module)] pub enum AcceptServerSessionError { - #[snafu(display("extended connect path {path} is not the dssh connect path"))] + #[snafu(display("extended connect path {path} is not the dshell connect path"))] UnexpectedPath { path: String }, - #[snafu(display("failed to validate dssh peer version"))] + #[snafu(display("failed to validate dshell peer version"))] PeerVersion { source: NegotiateVersionError }, #[snafu(display("failed to accept extended connect"))] Accept { @@ -120,15 +120,15 @@ pub enum AcceptServerSessionError { }, } -/// Build a DSSH WebTransport Extended CONNECT request. +/// Build a DShell WebTransport Extended CONNECT request. /// /// The returned request carries `:protocol = webtransport-h3` through h3x's -/// [`h3x::qpack::field::Protocol`] extension and includes the DSSH +/// [`h3x::qpack::field::Protocol`] extension and includes the DShell /// `ssh-version` header. Authentication, when present, is carried as a normal /// HTTP `Authorization` header. `path` is supplied by the caller so gateways /// can keep their routed SSH location, while clients that want a stable /// well-known endpoint can pass -/// [`DSSH_CONNECT_PATH`](crate::constants::DSSH_CONNECT_PATH). +/// [`DSHELL_CONNECT_PATH`](crate::constants::DSHELL_CONNECT_PATH). pub fn client_connect_request( authority: &Authority, path: &str, @@ -141,7 +141,7 @@ pub fn client_connect_request( let mut builder = http::Request::builder() .method(http::Method::CONNECT) .uri(uri) - .header("ssh-version", SSH_VERSION) + .header("ssh-version", DSHELL_VERSION) .extension(h3x::qpack::field::Protocol::new( h3x::webtransport::WEBTRANSPORT_H3, )); @@ -154,11 +154,11 @@ pub fn client_connect_request( .context(build_client_connect_request_error::RequestSnafu) } -fn peer_version(headers: &http::HeaderMap) -> Result { +fn peer_version(headers: &http::HeaderMap) -> Result { negotiate_version(headers) } -/// Send a DSSH WebTransport Extended CONNECT request and open the DSSH +/// Send a DShell WebTransport Extended CONNECT request and open the DShell /// conversation control stream on the resulting WebTransport session. pub async fn open_client_conversation( connection: &h3x::connection::Connection, @@ -195,12 +195,12 @@ where .context(client_connect_conversation_error::OpenConversationSnafu) } -/// Accept a DSSH WebTransport Extended CONNECT request after the caller has +/// Accept a DShell WebTransport Extended CONNECT request after the caller has /// already made its authentication and authorization decision. /// /// The accepted request path must match `path`. This keeps route ownership with /// the server or gateway layer instead of forcing every deployment to use the -/// well-known DSSH path. +/// well-known DShell path. /// /// The returned HTTP response must be sent back to the peer. Only after that /// response is on the wire should the server call [`Conversation::accept`] in a @@ -237,7 +237,7 @@ where }) } -/// Error returned by DSSH WebTransport stream-kind operations. +/// Error returned by DShell WebTransport stream-kind operations. #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum WebTransportStreamError { @@ -251,16 +251,16 @@ pub enum WebTransportStreamError { source: h3x::webtransport::AcceptStreamError, }, - #[snafu(display("failed to encode dssh webtransport stream kind"))] + #[snafu(display("failed to encode dshell webtransport stream kind"))] EncodeStreamKind { source: std::io::Error }, - #[snafu(display("failed to flush dssh webtransport stream kind"))] + #[snafu(display("failed to flush dshell webtransport stream kind"))] FlushStreamKind { source: std::io::Error }, - #[snafu(display("failed to decode dssh webtransport stream kind"))] + #[snafu(display("failed to decode dshell webtransport stream kind"))] DecodeStreamKind { source: std::io::Error }, - #[snafu(display("unexpected dssh webtransport stream kind {kind}"))] + #[snafu(display("unexpected dshell webtransport stream kind {kind}"))] UnexpectedStreamKind { kind: VarInt }, } @@ -270,7 +270,7 @@ mod tests { use http::HeaderMap; use super::*; - use crate::constants::{DSSH_CONNECT_PATH, SSH_VERSION}; + use crate::constants::{DSHELL_CONNECT_PATH, DSHELL_VERSION}; use crate::test_support::{MockWebTransportSession as TestSession, stream_pair as make_half}; fn test_session() -> TestSession { @@ -291,33 +291,33 @@ mod tests { let (mut remote_reader, local_writer) = make_half(stream_id); session.provide_open_stream(local_reader, local_writer); - let conversation = Conversation::open(session, SSH_VERSION) + let conversation = Conversation::open(session, DSHELL_VERSION) .await .expect("conversation opens"); let kind: VarInt = remote_reader.decode_one().await.expect("stream kind"); - assert_eq!(kind, DSSH_CONTROL_STREAM_KIND); + assert_eq!(kind, DSHELL_CONTROL_STREAM_KIND); assert_eq!(conversation.id(), StreamId(VarInt::from_u32(4))); - assert_eq!(conversation.peer_version(), SSH_VERSION); + assert_eq!(conversation.peer_version(), DSHELL_VERSION); } #[tokio::test] async fn accept_conversation_accepts_control_stream_and_preserves_metadata() { let session = session_with_accept_bytes(b"\x00control"); - let conversation = Conversation::accept(session, SSH_VERSION) + let conversation = Conversation::accept(session, DSHELL_VERSION) .await .expect("conversation accepts"); assert_eq!(conversation.id(), StreamId(VarInt::from_u32(4))); - assert_eq!(conversation.peer_version(), SSH_VERSION); + assert_eq!(conversation.peer_version(), DSHELL_VERSION); } #[tokio::test] async fn accept_conversation_rejects_channel_stream_as_control() { let session = session_with_accept_bytes(b"\x01channel"); - let error = match Conversation::accept(session, SSH_VERSION).await { + let error = match Conversation::accept(session, DSHELL_VERSION).await { Ok(_) => panic!("channel stream is not a control stream"), Err(error) => error, }; @@ -326,13 +326,13 @@ mod tests { error, AcceptConversationError::AcceptControl { source: WebTransportStreamError::UnexpectedStreamKind { kind } - } if kind == DSSH_CHANNEL_STREAM_KIND + } if kind == DSHELL_CHANNEL_STREAM_KIND )); } #[test] - fn dssh_connect_path_is_well_known_dssh_path() { - assert_eq!(DSSH_CONNECT_PATH, "/.well-known/dssh/connect"); + fn dshell_connect_path_is_well_known_dshell_path() { + assert_eq!(DSHELL_CONNECT_PATH, "/.well-known/dshell/connect"); } #[test] @@ -351,7 +351,7 @@ mod tests { ); assert_eq!( request.headers().get("ssh-version"), - Some(&HeaderValue::from_static(SSH_VERSION)) + Some(&HeaderValue::from_static(DSHELL_VERSION)) ); assert_eq!(request.headers().get(AUTHORIZATION), Some(&authorization)); assert_eq!( @@ -368,33 +368,33 @@ mod tests { let headers = HeaderMap::new(); assert!(matches!( peer_version(&headers), - Err(NegotiateVersionError::MissingSshVersionHeader) + Err(NegotiateVersionError::MissingDshellVersionHeader) )); let mut headers = HeaderMap::new(); headers.insert( "ssh-version", - HeaderValue::from_bytes(b"dssh-00\xff").expect("opaque header value"), + HeaderValue::from_bytes(b"dshell-00\xff").expect("opaque header value"), ); assert!(matches!( peer_version(&headers), - Err(NegotiateVersionError::InvalidSshVersionHeaderValue { .. }) + Err(NegotiateVersionError::InvalidDshellVersionHeaderValue { .. }) )); let mut headers = HeaderMap::new(); - headers.insert("ssh-version", HeaderValue::from_static("dssh-99")); + headers.insert("ssh-version", HeaderValue::from_static("dshell-99")); assert!(matches!( peer_version(&headers), - Err(NegotiateVersionError::UnsupportedSshVersion { offered }) if offered == "dssh-99" + Err(NegotiateVersionError::UnsupportedDshellVersion { offered }) if offered == "dshell-99" )); let mut headers = HeaderMap::new(); - headers.insert("ssh-version", HeaderValue::from_static(SSH_VERSION)); + headers.insert("ssh-version", HeaderValue::from_static(DSHELL_VERSION)); assert_eq!( peer_version(&headers) .expect("supported version") .version_string, - SSH_VERSION + DSHELL_VERSION ); } @@ -402,19 +402,19 @@ mod tests { async fn accept_server_session_rejects_wrong_path_before_registering_session() { let request = http::Request::builder() .method(http::Method::CONNECT) - .uri("https://example.test/not-dssh") - .header("ssh-version", SSH_VERSION) + .uri("https://example.test/not-dshell") + .header("ssh-version", DSHELL_VERSION) .body(Empty::::new()) .expect("request"); - let error = match accept_server_session(request, DSSH_CONNECT_PATH).await { + let error = match accept_server_session(request, DSHELL_CONNECT_PATH).await { Ok(_) => panic!("wrong path must not be accepted"), Err(error) => error, }; assert!(matches!( error, - AcceptServerSessionError::UnexpectedPath { path } if path == "/not-dssh" + AcceptServerSessionError::UnexpectedPath { path } if path == "/not-dshell" )); } @@ -422,11 +422,11 @@ mod tests { async fn accept_server_session_rejects_missing_version_before_registering_session() { let request = http::Request::builder() .method(http::Method::CONNECT) - .uri(format!("https://example.test{DSSH_CONNECT_PATH}")) + .uri(format!("https://example.test{DSHELL_CONNECT_PATH}")) .body(Empty::::new()) .expect("request"); - let error = match accept_server_session(request, DSSH_CONNECT_PATH).await { + let error = match accept_server_session(request, DSHELL_CONNECT_PATH).await { Ok(_) => panic!("missing version must not be accepted"), Err(error) => error, }; @@ -434,7 +434,7 @@ mod tests { assert!(matches!( error, AcceptServerSessionError::PeerVersion { - source: NegotiateVersionError::MissingSshVersionHeader + source: NegotiateVersionError::MissingDshellVersionHeader } )); }