refactor!: rebrand wraith to alknet

Rename all crates, CLI commands, constants, type names, doc comments,
and documentation from wraith to alknet. Includes wire-protocol changes:
ALPN wraith-ssh -> alknet-ssh, reserved destination prefix wraith- ->
alknet-, SSH auth username wraith -> alknet.
This commit is contained in:
2026-06-05 10:04:32 +00:00
parent af7f4d0006
commit 596c89ce24
101 changed files with 552 additions and 552 deletions

View File

@@ -0,0 +1,362 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use rustls::crypto::aws_lc_rs::default_provider;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme};
use tracing::{error, info};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor as TokioTlsAcceptor;
use super::{TransportAcceptor, TransportInfo, TransportKind};
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
#[derive(Debug, Clone)]
pub enum AcmeMode {
Domain { domain: String },
Ip,
}
pub struct AcmeCertProvider {
mode: AcmeMode,
cache_dir: Option<PathBuf>,
directory_url: String,
contact: Vec<String>,
}
impl std::fmt::Debug for AcmeCertProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcmeCertProvider")
.field("mode", &self.mode)
.field("cache_dir", &self.cache_dir)
.field("directory_url", &self.directory_url)
.field("contact", &self.contact)
.finish_non_exhaustive()
}
}
impl AcmeCertProvider {
pub fn new(mode: AcmeMode) -> Self {
Self {
mode,
cache_dir: None,
directory_url: rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY.to_string(),
contact: Vec::new(),
}
}
pub fn domain(domain: impl Into<String>) -> Self {
Self::new(AcmeMode::Domain {
domain: domain.into(),
})
}
pub fn ip() -> Self {
Self::new(AcmeMode::Ip)
}
pub fn with_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(dir.into());
self
}
pub fn with_directory(mut self, url: impl Into<String>) -> Self {
self.directory_url = url.into();
self
}
pub fn with_production_directory(mut self) -> Self {
self.directory_url = rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY.to_string();
self
}
pub fn with_contact(mut self, contact: impl Into<String>) -> Self {
self.contact.push(contact.into());
self
}
pub fn mode(&self) -> &AcmeMode {
&self.mode
}
fn build_acme_state(&self) -> (AcmeState<std::io::Error>, Arc<ResolvesServerCertAcme>) {
let domains: Vec<String> = match &self.mode {
AcmeMode::Domain { domain } => vec![domain.clone()],
AcmeMode::Ip => vec![],
};
let base_config = AcmeConfig::new(domains)
.directory(&self.directory_url)
.contact(self.contact.clone());
let state = match &self.cache_dir {
Some(cache_dir) => {
base_config.cache(DirCache::new(cache_dir.clone())).state()
}
None => {
base_config
.cache(rustls_acme::caches::NoCache::default())
.state()
}
};
let resolver = state.resolver();
(state, resolver)
}
pub fn build_server_config_with_resolver(
&self,
resolver: Arc<ResolvesServerCertAcme>,
) -> Result<Arc<ServerConfig>> {
let provider = default_provider().into();
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
Ok(Arc::new(config))
}
}
pub struct AcmeTlsAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
#[allow(dead_code)]
server_config: Arc<ServerConfig>,
tokio_acceptor: TokioTlsAcceptor,
}
impl AcmeTlsAcceptor {
pub async fn bind_acme(
addr: SocketAddr,
provider: Arc<AcmeCertProvider>,
) -> Result<Self> {
let (state, resolver) = provider.build_acme_state();
let server_config = provider.build_server_config_with_resolver(resolver.clone())?;
Self::spawn_state_worker(state, resolver);
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
fn spawn_state_worker(state: AcmeState<std::io::Error>, resolver: Arc<ResolvesServerCertAcme>) {
use futures::StreamExt;
let task = async move {
let mut state = state;
while let Some(event) = state.next().await {
match event {
Ok(ok) => {
if let rustls_acme::EventOk::DeployedNewCert = ok {
info!("ACME: new certificate deployed");
} else {
info!("ACME event: {:?}", ok);
}
}
Err(err) => error!("ACME event error: {:?}", err),
}
if Arc::strong_count(&resolver) == 1 {
info!("ACME resolver dropped, stopping background task");
break;
}
}
};
tokio::spawn(task);
}
}
#[async_trait::async_trait]
impl TransportAcceptor for AcmeTlsAcceptor {
type Stream = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream
.get_ref()
.1
.server_name()
.map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tls { server_name },
};
Ok((tls_stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acme_cert_provider_domain_mode() {
let provider = AcmeCertProvider::domain("example.com");
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
if let AcmeMode::Domain { domain } = provider.mode() {
assert_eq!(domain, "example.com");
}
}
#[test]
fn acme_cert_provider_ip_mode() {
let provider = AcmeCertProvider::ip();
assert!(matches!(provider.mode(), AcmeMode::Ip));
}
#[test]
fn acme_cert_provider_default_staging_directory() {
let provider = AcmeCertProvider::domain("example.com");
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY
);
}
#[test]
fn acme_cert_provider_production_directory() {
let provider = AcmeCertProvider::domain("example.com").with_production_directory();
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
);
}
#[test]
fn acme_cert_provider_custom_directory() {
let provider =
AcmeCertProvider::domain("example.com").with_directory("https://custom.acme.dir/");
assert_eq!(provider.directory_url, "https://custom.acme.dir/");
}
#[test]
fn acme_cert_provider_with_cache_dir() {
let provider = AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/acme_cache");
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/acme_cache")));
}
#[test]
fn acme_cert_provider_with_contact() {
let provider =
AcmeCertProvider::domain("example.com").with_contact("mailto:admin@example.com");
assert_eq!(
provider.contact,
vec!["mailto:admin@example.com".to_string()]
);
}
#[test]
fn acme_cert_provider_build_state_domain() {
let provider = AcmeCertProvider::domain("example.com");
let (_state, resolver) = provider.build_acme_state();
assert!(Arc::strong_count(&resolver) >= 2);
}
#[test]
fn acme_cert_provider_build_state_with_cache() {
let provider =
AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/test_cache");
let (_state, resolver) = provider.build_acme_state();
assert!(Arc::strong_count(&resolver) >= 2);
}
#[test]
fn acme_cert_provider_build_server_config() {
let _ = default_provider().install_default();
let provider = AcmeCertProvider::domain("example.com");
let (_, resolver) = provider.build_acme_state();
let config = provider.build_server_config_with_resolver(resolver).unwrap();
assert!(!config.alpn_protocols.is_empty());
assert!(config
.alpn_protocols
.iter()
.any(|p| p == ACME_TLS_ALPN_NAME));
}
#[test]
fn acme_mode_domain_debug() {
let mode = AcmeMode::Domain {
domain: "test.example.com".to_string(),
};
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("test.example.com"));
}
#[test]
fn acme_mode_ip_debug() {
let mode = AcmeMode::Ip;
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("Ip"));
}
#[test]
fn acme_cert_provider_builder_chain() {
let provider = AcmeCertProvider::domain("test.example.com")
.with_production_directory()
.with_cache_dir("/tmp/cache")
.with_contact("mailto:admin@test.example.com");
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
);
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/cache")));
assert_eq!(provider.contact.len(), 1);
}
#[tokio::test]
async fn acme_tls_acceptor_bind_acme() {
let _ = default_provider().install_default();
let provider = Arc::new(AcmeCertProvider::domain("example.com"));
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let acceptor = AcmeTlsAcceptor::bind_acme(addr, provider).await.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
#[tokio::test]
#[ignore]
async fn acme_staging_domain_cert_provisioning() {
let _ = default_provider().install_default();
let cache_dir = tempfile::tempdir().unwrap();
let provider = Arc::new(
AcmeCertProvider::domain("acme-test.example.com")
.with_cache_dir(cache_dir.path())
.with_contact("mailto:admin@example.com"),
);
let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
let result = AcmeTlsAcceptor::bind_acme(addr, provider).await;
assert!(
result.is_ok(),
"ACME TlsAcceptor should bind: {:?}",
result.err()
);
let acceptor = result.unwrap();
assert_eq!(acceptor.listen_addr().port(), 443);
}
}

View File

@@ -0,0 +1,321 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use iroh::{
endpoint::RecvStream,
node_info::NodeIdExt,
Endpoint, NodeId, RelayMap, RelayMode, RelayUrl,
};
use tokio::io;
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
pub const ALPN: &[u8] = b"alknet-ssh";
const DEFAULT_RELAY_URL: &str = "https://relay.iroh.network/";
/// A client-side iroh QUIC P2P transport that connects to a remote iroh endpoint.
///
/// Connects via `Endpoint::connect(node_id, alpn)`, opens a bidirectional
/// QUIC stream with `conn.open_bi()`, and joins the halves with
/// `tokio::io::join(recv, send)` to produce a duplex stream for russh.
/// Per ADR-003, `tokio::io::join` is used instead of a custom wrapper.
///
/// Use [`IrohTransport::new`] to create a standalone endpoint, or
/// [`IrohTransport::from_endpoint`] to share an existing iroh `Endpoint`
/// with other protocol handlers (blobs, gossip, docs).
pub struct IrohTransport {
node_id: NodeId,
endpoint: Endpoint,
owned: bool,
}
impl IrohTransport {
/// Create a new iroh transport with its own dedicated endpoint.
///
/// The endpoint is created with the `alknet-ssh` ALPN and the provided
/// relay URL. Use this when alknet is the only iroh service on this node.
pub async fn new(
node_id: NodeId,
relay_url: Option<RelayUrl>,
proxy_url: Option<url::Url>,
) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL.parse().expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
.relay_mode(RelayMode::Custom(relay_map))
.alpns(vec![ALPN.to_vec()]);
if let Some(ref proxy) = proxy_url {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { node_id, endpoint, owned: true })
}
/// Create an iroh transport using an existing shared endpoint.
///
/// The endpoint must already have the `alknet-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). This enables
/// running alknet alongside iroh-blobs, iroh-gossip, iroh-docs, and
/// other protocol handlers on the same QUIC endpoint — one connection
/// per peer, multiplexed by ALPN.
pub fn from_endpoint(node_id: NodeId, endpoint: Endpoint) -> Self {
Self { node_id, endpoint, owned: false }
}
pub fn endpoint_id(&self) -> String {
self.endpoint.node_id().to_z32()
}
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
impl Transport for IrohTransport {
type Stream = io::Join<RecvStream, iroh::endpoint::SendStream>;
async fn connect(&self) -> Result<Self::Stream> {
let conn = self.endpoint.connect(self.node_id, ALPN).await?;
let (send, recv) = conn.open_bi().await?;
Ok(io::join(recv, send))
}
fn describe(&self) -> String {
format!("iroh://{}", self.node_id.to_z32())
}
}
/// A server-side iroh QUIC P2P transport acceptor that listens for incoming connections.
///
/// Binds an iroh `Endpoint` with the configured relay URL and optional proxy
/// (ADR-010). Accepts incoming connections, accepts bidirectional QUIC streams,
/// and joins the halves with `tokio::io::join(recv, send)`. Exposes
/// `endpoint_id()` for CLI display of the server's z-base-32 node ID.
///
/// Use [`IrohAcceptor::bind`] to create a standalone endpoint, or
/// [`IrohAcceptor::from_endpoint`] to share an existing iroh `Endpoint`
/// with other protocol handlers (blobs, gossip, docs).
///
/// When using `from_endpoint`, the alknet-ssh ALPN must be registered
/// via an iroh `Router` that calls `Handler::accept()` on incoming
/// connections with the `alknet-ssh` ALPN, then passes the accepted
/// bidirectional stream to `russh::server::run_stream()`.
pub struct IrohAcceptor {
endpoint: Endpoint,
owned: bool,
}
impl IrohAcceptor {
/// Bind a new iroh endpoint with a dedicated `alknet-ssh` ALPN.
///
/// Use this when alknet is the only iroh service on this node.
pub async fn bind(
relay_url: Option<RelayUrl>,
proxy_url: Option<url::Url>,
) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL.parse().expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
.relay_mode(RelayMode::Custom(relay_map))
.alpns(vec![ALPN.to_vec()]);
if let Some(ref proxy) = proxy_url {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { endpoint, owned: true })
}
/// Create an iroh acceptor using an existing shared endpoint.
///
/// The endpoint must already have the `alknet-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). When using a
/// shared endpoint, incoming connections with the `alknet-ssh` ALPN
/// are routed by the Router to a `ProtocolHandler` that this acceptor
/// does not manage — the caller is responsible for bridging the
/// Router's `accept()` callback to this acceptor's stream handling.
///
/// For the standalone case where alknet owns the endpoint, use
/// [`IrohAcceptor::bind`] instead, which handles the accept loop
/// internally.
pub fn from_endpoint(endpoint: Endpoint) -> Self {
Self { endpoint, owned: false }
}
pub fn endpoint_id(&self) -> String {
self.endpoint.node_id().to_z32()
}
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
impl TransportAcceptor for IrohAcceptor {
type Stream = io::Join<RecvStream, iroh::endpoint::SendStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let incoming = self
.endpoint
.accept()
.await
.ok_or_else(|| anyhow!("endpoint closed"))?;
let conn = incoming.await?;
let node_id = conn.remote_node_id()?;
let (send, recv) = conn.accept_bi().await?;
let stream = io::join(recv, send);
let info = TransportInfo {
remote_addr: None,
transport_kind: TransportKind::Iroh {
endpoint_id: node_id.to_z32(),
},
};
Ok((stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn iroh_acceptor_bind_creates_endpoint() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint_id = acceptor.endpoint_id();
assert!(!endpoint_id.is_empty());
let parsed = NodeId::from_z32(&endpoint_id);
assert!(parsed.is_ok());
assert!(acceptor.owned());
}
#[tokio::test]
async fn iroh_acceptor_bind_with_custom_relay() {
let relay: RelayUrl = "https://relay.iroh.network/".parse().unwrap();
let acceptor = IrohAcceptor::bind(Some(relay), None).await.unwrap();
assert!(!acceptor.endpoint_id().is_empty());
assert!(acceptor.owned());
}
#[tokio::test]
async fn iroh_acceptor_from_endpoint() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint = acceptor.endpoint.clone();
let shared = IrohAcceptor::from_endpoint(endpoint);
assert_eq!(shared.endpoint_id(), acceptor.endpoint_id());
assert!(!shared.owned());
}
#[test]
fn iroh_transport_describe_format() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let desc = format!("iroh://{}", node_id.to_z32());
assert!(desc.starts_with("iroh://"));
}
#[tokio::test]
async fn iroh_transport_connect_builds_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let transport = IrohTransport::new(node_id, None, None).await.unwrap();
assert!(transport.describe().starts_with("iroh://"));
assert!(!transport.endpoint_id().is_empty());
assert!(transport.owned());
}
#[tokio::test]
async fn iroh_transport_from_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint = acceptor.endpoint.clone();
let transport = IrohTransport::from_endpoint(node_id, endpoint);
assert!(transport.describe().starts_with("iroh://"));
assert_eq!(transport.endpoint_id(), acceptor.endpoint_id());
assert!(!transport.owned());
}
#[tokio::test]
#[ignore]
async fn iroh_client_connects_to_iroh_server() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let server_node_id = acceptor.endpoint().node_id();
let transport = IrohTransport::new(server_node_id, None, None)
.await
.unwrap();
let mut addrs_watcher = acceptor.endpoint().direct_addresses();
addrs_watcher.initialized().await.unwrap();
let addr_set = addrs_watcher.get().unwrap().unwrap_or_default();
for addr in addr_set {
transport
.endpoint
.add_node_addr(iroh::NodeAddr::from_parts(
server_node_id,
None,
vec![addr.addr],
))
.unwrap();
}
let accept_handle = tokio::spawn(async move {
let (stream, info) = acceptor.accept().await.unwrap();
assert!(matches!(info.transport_kind, TransportKind::Iroh { .. }));
stream
});
let _client_stream: io::Join<RecvStream, iroh::endpoint::SendStream> =
transport.connect().await.unwrap();
let _server_stream = accept_handle.await.unwrap();
}
#[tokio::test]
#[ignore]
async fn iroh_shared_endpoint_client_connects_to_server() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let server_node_id = acceptor.endpoint().node_id();
let shared_endpoint = acceptor.endpoint().clone();
let transport = IrohTransport::from_endpoint(server_node_id, shared_endpoint);
let mut addrs_watcher = acceptor.endpoint().direct_addresses();
addrs_watcher.initialized().await.unwrap();
let addr_set = addrs_watcher.get().unwrap().unwrap_or_default();
for addr in addr_set {
transport
.endpoint
.add_node_addr(iroh::NodeAddr::from_parts(
server_node_id,
None,
vec![addr.addr],
))
.unwrap();
}
let accept_handle = tokio::spawn(async move {
let (stream, info) = acceptor.accept().await.unwrap();
assert!(matches!(info.transport_kind, TransportKind::Iroh { .. }));
stream
});
let _client_stream: io::Join<RecvStream, iroh::endpoint::SendStream> =
transport.connect().await.unwrap();
let _server_stream = accept_handle.await.unwrap();
}
}

View File

@@ -0,0 +1,188 @@
//! Pluggable transport layer for Alknet.
//!
//! The transport layer produces a duplex byte stream (`AsyncRead + AsyncWrite + Unpin + Send`)
//! that SSH consumes. This is the core architectural abstraction — SSH never opens its own
//! network connections; it runs entirely over whatever stream the transport provides.
//!
//! Available transports (feature-gated):
//! - `TcpTransport` / `TcpAcceptor` — always available, direct TCP
//! - `TlsTransport` / `TlsAcceptor` — behind the `tls` feature, TCP + rustls
//! - `IrohTransport` / `IrohAcceptor` — behind the `iroh` feature, QUIC P2P via iroh
//! - `AcmeTlsAcceptor` — behind the `acme` feature, auto-provision TLS certs via Let's Encrypt
//!
//! See [ADR-001](docs/architecture/decisions/001-pluggable-transport.md) and
//! [ADR-004](docs/architecture/decisions/004-ssh-over-transport.md) for design rationale.
mod tcp;
#[cfg(feature = "iroh")]
mod iroh_transport;
pub use tcp::{TcpAcceptor, TcpTransport};
#[cfg(feature = "iroh")]
pub use iroh_transport::{IrohAcceptor, IrohTransport, ALPN as IROH_ALPN};
#[cfg(feature = "tls")]
mod tls;
#[cfg(feature = "tls")]
pub use tls::{AcmeConfig, TlsAcceptor, TlsTransport};
#[cfg(feature = "acme")]
mod acme;
#[cfg(feature = "acme")]
pub use acme::{AcmeCertProvider, AcmeMode, AcmeTlsAcceptor};
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite};
/// Client-side transport trait. Produces a single duplex stream per connection.
///
/// Implementations connect to a remote endpoint and return a stream that SSH
/// runs over via `russh::client::connect_stream()`. Each call to `connect()` creates
/// a new stream — multiple sessions need multiple calls or multiple transports.
#[async_trait]
pub trait Transport: Send + Sync + 'static {
/// The duplex stream type produced by this transport.
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
/// Connect to the remote endpoint and return a duplex stream.
async fn connect(&self) -> Result<Self::Stream>;
/// Return a human-readable description of this transport for logging.
fn describe(&self) -> String;
}
/// Server-side transport acceptor. Accepts incoming connections and returns streams.
///
/// Implementations bind to a local endpoint and produce streams that SSH
/// runs over via `russh::server::run_stream()`.
#[async_trait]
pub trait TransportAcceptor: Send + Sync + 'static {
/// The duplex stream type produced by this acceptor.
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
/// Accept an incoming connection and return a duplex stream with metadata.
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)>;
}
/// Metadata about an incoming transport connection.
///
/// Carries the remote address (if available) and the kind of transport
/// used. The server handler uses this for logging and auth decisions.
/// See ADR-001 for the pluggable transport rationale and ADR-004
/// for why SSH runs entirely over the transport stream.
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub remote_addr: Option<SocketAddr>,
pub transport_kind: TransportKind,
}
/// The kind of transport that produced a connection.
///
/// Each variant identifies the transport mechanism. Used by the
/// server handler for logging and authorization decisions.
/// See ADR-001 and ADR-004.
#[derive(Debug, Clone)]
pub enum TransportKind {
Tcp,
Tls {
server_name: Option<String>,
},
Iroh {
endpoint_id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, DuplexStream};
struct MockTransport;
#[async_trait]
impl Transport for MockTransport {
type Stream = DuplexStream;
async fn connect(&self) -> Result<Self::Stream> {
let (stream, _) = duplex(1024);
Ok(stream)
}
fn describe(&self) -> String {
"mock".to_string()
}
}
struct MockAcceptor;
#[async_trait]
impl TransportAcceptor for MockAcceptor {
type Stream = DuplexStream;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (stream, _) = duplex(1024);
let info = TransportInfo {
remote_addr: None,
transport_kind: TransportKind::Tcp,
};
Ok((stream, info))
}
}
#[tokio::test]
async fn transport_trait_object() {
let _boxed: Box<dyn Transport<Stream = DuplexStream>> = Box::new(MockTransport);
}
#[tokio::test]
async fn transport_acceptor_trait_object() {
let _boxed: Box<dyn TransportAcceptor<Stream = DuplexStream>> = Box::new(MockAcceptor);
}
#[tokio::test]
async fn transport_connect_returns_stream() {
let t = MockTransport;
let _stream = t.connect().await.unwrap();
}
#[tokio::test]
async fn transport_describe_returns_string() {
let t = MockTransport;
assert_eq!(t.describe(), "mock");
}
#[tokio::test]
async fn acceptor_accept_returns_stream_and_info() {
let a = MockAcceptor;
let (_, info) = a.accept().await.unwrap();
assert!(info.remote_addr.is_none());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
}
#[test]
fn transport_kind_variants() {
let tcp = TransportKind::Tcp;
let tls = TransportKind::Tls {
server_name: Some("example.com".to_string()),
};
let iroh = TransportKind::Iroh {
endpoint_id: "abc123".to_string(),
};
if let TransportKind::Tcp = tcp {}
if let TransportKind::Tls {
server_name: Some(name),
} = tls
{
assert_eq!(name, "example.com");
}
if let TransportKind::Iroh { endpoint_id } = iroh {
assert_eq!(endpoint_id, "abc123");
}
}
}

View File

@@ -0,0 +1,162 @@
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use tokio::net::{TcpListener, TcpStream};
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
/// A TCP-based client transport that connects to a remote address.
///
/// Connects via `TcpStream::connect(addr)`. Uses tokio's default
/// connect timeout behavior: the OS controls connection timeout
/// (typically ~2 minutes on Linux via `net.ipv4.tcp_syn_retries`).
/// For custom timeouts, wrap `TcpTransport` with
/// `tokio::time::timeout(duration, transport.connect())`.
pub struct TcpTransport {
addr: SocketAddr,
}
impl TcpTransport {
pub fn new(addr: SocketAddr) -> Self {
Self { addr }
}
}
#[async_trait]
impl Transport for TcpTransport {
type Stream = TcpStream;
async fn connect(&self) -> Result<Self::Stream> {
let stream = TcpStream::connect(self.addr).await?;
Ok(stream)
}
fn describe(&self) -> String {
format!("tcp://{}", self.addr)
}
}
/// A TCP-based server transport acceptor that listens for incoming connections.
///
/// Binds via `TcpListener::bind(addr)`. Accepts connections and returns
/// the stream together with `TransportInfo` containing the remote address
/// and `TransportKind::Tcp`.
pub struct TcpAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
}
impl TcpAcceptor {
/// Bind a TCP listener on the given address.
///
/// Returns the acceptor ready to receive connections.
/// The actual bound address may differ from the requested one
/// (e.g., when binding to port 0 the OS assigns an ephemeral port).
pub async fn bind(addr: SocketAddr) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
Ok(Self {
listener,
listen_addr,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
}
#[async_trait]
impl TransportAcceptor for TcpAcceptor {
type Stream = TcpStream;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (stream, remote_addr) = self.listener.accept().await?;
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tcp,
};
Ok((stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn tcp_transport_connect_creates_stream() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TcpTransport::new(addr);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let stream = transport.connect().await.unwrap();
assert_eq!(stream.local_addr().unwrap().ip(), addr.ip());
let (_server_stream, info) = accept_handle.await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
}
#[tokio::test]
async fn tcp_acceptor_accept_receives_connection() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
tokio::spawn(async move {
TcpStream::connect(addr).await.unwrap();
});
let (stream, info) = acceptor.accept().await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
assert_eq!(
info.remote_addr.unwrap().ip(),
stream.peer_addr().unwrap().ip()
);
}
#[test]
fn tcp_transport_describe_format() {
let addr: SocketAddr = "1.2.3.4:22".parse().unwrap();
let transport = TcpTransport::new(addr);
assert_eq!(transport.describe(), "tcp://1.2.3.4:22");
}
#[tokio::test]
async fn tcp_stream_is_duplex() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
let mut client = TcpStream::connect(addr).await.unwrap();
let (mut server, _) = acceptor.accept().await.unwrap();
client.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
server.write_all(b"world").await.unwrap();
let mut buf = [0u8; 5];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"world");
}
#[tokio::test]
async fn tcp_acceptor_bind_port_zero_assigns_ephemeral() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
}

View File

@@ -0,0 +1,432 @@
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, ServerConfig};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector};
#[cfg(feature = "acme")]
use rustls::crypto::aws_lc_rs::default_provider;
#[cfg(feature = "acme")]
use rustls_acme::ResolvesServerCertAcme;
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
#[cfg(feature = "acme")]
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
/// A TLS-based client transport that connects to a remote address over TLS.
///
/// Wraps a TCP connection with a TLS client session via `tokio_rustls::TlsConnector`.
/// Supports insecure mode (accepts any certificate, for development) and
/// custom root CA certificates for verification. The `tls_server_name` field
/// overrides the SNI hostname sent during the TLS handshake (ADR-010).
pub struct TlsTransport {
addr: SocketAddr,
tls_server_name: Option<String>,
insecure: bool,
root_cert: Option<CertificateDer<'static>>,
}
impl TlsTransport {
pub fn new(addr: SocketAddr) -> Self {
Self {
addr,
tls_server_name: None,
insecure: false,
root_cert: None,
}
}
pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
self.tls_server_name = Some(name.into());
self
}
pub fn with_insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn with_root_cert(mut self, cert: CertificateDer<'static>) -> Self {
self.root_cert = Some(cert);
self
}
fn build_client_config(&self) -> Result<ClientConfig> {
if self.insecure {
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth();
return Ok(config);
}
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(ref cert) = self.root_cert {
root_store.add(cert.clone())?;
}
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(config)
}
fn resolve_server_name(&self) -> Result<ServerName<'static>> {
let name = match &self.tls_server_name {
Some(n) => n.clone(),
None => self.addr.ip().to_string(),
};
ServerName::try_from(name.clone())
.map_err(move |e| anyhow!("invalid server name '{}': {}", name, e))
}
}
#[async_trait]
impl Transport for TlsTransport {
type Stream = ClientTlsStream<TcpStream>;
async fn connect(&self) -> Result<Self::Stream> {
let tcp_stream = TcpStream::connect(self.addr).await?;
let config = self.build_client_config()?;
let connector = TlsConnector::from(Arc::new(config));
let server_name = self.resolve_server_name()?;
let tls_stream = connector.connect(server_name, tcp_stream).await?;
Ok(tls_stream)
}
fn describe(&self) -> String {
format!("tls://{}", self.addr)
}
}
/// Stub configuration for ACME certificate provisioning (ADR-008).
/// Feature-gated behind the `acme` feature. When implemented, this will
/// hold the ACME domain and challenge responder configuration.
#[derive(Debug)]
pub struct AcmeConfig {
pub domain: String,
}
/// A TLS-based server transport acceptor that accepts TCP connections
/// and wraps them with TLS server sessions via `tokio_rustls::TlsAcceptor`.
///
/// Supports three certificate modes (ADR-008):
/// - Manual certs via `bind()` with explicit cert/key
/// - ACME certs via `bind_acme()` with an `AcmeCertProvider`
/// - The stub `AcmeConfig` parameter in `bind()` is kept for backward compat
pub struct TlsAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
#[allow(dead_code)]
server_config: Arc<ServerConfig>,
tokio_acceptor: TokioTlsAcceptor,
}
impl TlsAcceptor {
pub async fn bind(
addr: SocketAddr,
tls_certs: Vec<CertificateDer<'static>>,
tls_key: PrivateKeyDer<'static>,
_acme_config: Option<AcmeConfig>,
) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(tls_certs, tls_key)?;
let server_config = Arc::new(server_config);
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
#[cfg(feature = "acme")]
pub async fn bind_acme(
addr: SocketAddr,
acme_resolver: Arc<ResolvesServerCertAcme>,
) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let provider = default_provider().into();
let mut server_config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
.with_no_client_auth()
.with_cert_resolver(acme_resolver);
server_config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
let server_config = Arc::new(server_config);
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
}
#[async_trait]
impl TransportAcceptor for TlsAcceptor {
type Stream = tokio_rustls::server::TlsStream<TcpStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream
.get_ref()
.1
.server_name()
.map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tls { server_name },
};
Ok((tls_stream, info))
}
}
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_doc: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_doc: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use rcgen::{CertificateParams, KeyPair};
use rustls::crypto::aws_lc_rs::default_provider;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn ensure_crypto_provider() {
let _ = default_provider().install_default();
}
fn generate_self_signed_cert() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
let params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
let key_pair = KeyPair::generate().unwrap();
let cert = params.self_signed(&key_pair).unwrap();
let cert_der: CertificateDer<'static> = cert.into();
let key_der = PrivateKeyDer::Pkcs8(key_pair.serialize_der().into());
(cert_der, key_der)
}
#[test]
fn tls_transport_describe_format() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr).with_server_name("example.com");
assert_eq!(transport.describe(), "tls://1.2.3.4:443");
}
#[test]
fn tls_transport_describe_with_ip() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr);
assert_eq!(transport.describe(), "tls://1.2.3.4:443");
}
#[test]
fn tls_transport_builder_methods() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr)
.with_server_name("alknet.test")
.with_insecure(true);
assert_eq!(transport.tls_server_name, Some("alknet.test".to_string()));
assert!(transport.insecure);
}
#[tokio::test]
async fn tls_connect_insecure_self_signed() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let mut client = transport.connect().await.unwrap();
let (mut server, info) = accept_handle.await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(
info.transport_kind,
TransportKind::Tls { .. }
));
client.write_all(b"hello tls").await.unwrap();
let mut buf = [0u8; 9];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello tls");
server.write_all(b"reply").await.unwrap();
let mut buf = [0u8; 5];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"reply");
}
#[tokio::test]
async fn tls_acceptor_returns_server_name() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let _client = transport.connect().await.unwrap();
let (_, info) = accept_handle.await.unwrap();
if let TransportKind::Tls { server_name } = info.transport_kind {
assert_eq!(server_name, Some("localhost".to_string()));
} else {
panic!("expected TransportKind::Tls");
}
}
#[tokio::test]
async fn tls_full_client_to_server_connection() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let mut client = transport.connect().await.unwrap();
let (mut server, _info) = accept_handle.await.unwrap();
let msg = b"alknet integration test";
client.write_all(msg).await.unwrap();
let mut buf = vec![0u8; msg.len()];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf[..], msg);
let reply = b"ok";
server.write_all(reply).await.unwrap();
let mut buf = [0u8; 2];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, reply);
}
#[tokio::test]
async fn tls_acceptor_bind_port_zero_assigns_ephemeral() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
#[test]
fn no_verifier_accepts_any_cert() {
let verifier = NoVerifier;
assert!(verifier.supported_verify_schemes().len() > 0);
}
}