From f62f8dfaf126b42d3ebba4e95e4614a0eae6554b Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Sun, 7 Jun 2026 15:27:51 +0000 Subject: [PATCH] feat: define Interface trait and InterfaceConfig types (core/interface-trait-definition) Add Layer 2 interface abstraction per ADR-026: - Interface trait with accept() and associated Session type - InterfaceConfig enum with Ssh and RawFraming variants - SshInterfaceConfig with auth, forwarding, host_key fields - RawFramingConfig (minimal, no SSH-specific config) - InterfaceSession trait with recv()/send() producing InterfaceEvent frames - InterfaceEvent wraps EventEnvelope with optional Identity - Resolves OQ-IF-01: every session produces EventEnvelope frames via InterfaceSession, making Layer 3 interface-agnostic - Valid (Transport, Interface) pair enumeration with TransportKindBase and is_valid_pair validation function - Module re-exported from lib.rs --- crates/alknet-core/src/interface/config.rs | 89 ++++++++++++ crates/alknet-core/src/interface/mod.rs | 68 ++++++++++ crates/alknet-core/src/interface/pairs.rs | 142 ++++++++++++++++++++ crates/alknet-core/src/interface/session.rs | 62 +++++++++ crates/alknet-core/src/lib.rs | 6 + 5 files changed, 367 insertions(+) create mode 100644 crates/alknet-core/src/interface/config.rs create mode 100644 crates/alknet-core/src/interface/mod.rs create mode 100644 crates/alknet-core/src/interface/pairs.rs create mode 100644 crates/alknet-core/src/interface/session.rs diff --git a/crates/alknet-core/src/interface/config.rs b/crates/alknet-core/src/interface/config.rs new file mode 100644 index 0000000..7435ca4 --- /dev/null +++ b/crates/alknet-core/src/interface/config.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; +use russh::keys::PrivateKey; + +use crate::auth::IdentityProvider; +use crate::config::DynamicConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InterfaceKind { + Ssh, + RawFraming, +} + +impl std::fmt::Display for InterfaceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InterfaceKind::Ssh => write!(f, "ssh"), + InterfaceKind::RawFraming => write!(f, "raw-framing"), + } + } +} + +pub enum InterfaceConfig { + Ssh(SshInterfaceConfig), + RawFraming(RawFramingConfig), +} + +impl InterfaceConfig { + pub fn kind(&self) -> InterfaceKind { + match self { + InterfaceConfig::Ssh(_) => InterfaceKind::Ssh, + InterfaceConfig::RawFraming(_) => InterfaceKind::RawFraming, + } + } +} + +pub struct SshInterfaceConfig { + pub auth: Arc, + pub forwarding: Arc>, + pub host_key: Arc, +} + +pub struct RawFramingConfig {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interface_kind_display() { + assert_eq!(InterfaceKind::Ssh.to_string(), "ssh"); + assert_eq!(InterfaceKind::RawFraming.to_string(), "raw-framing"); + } + + #[test] + fn interface_kind_from_config() { + let auth = Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new( + ArcSwap::new(Arc::new(DynamicConfig::default())), + ))); + let ssh_config = InterfaceConfig::Ssh(SshInterfaceConfig { + auth, + forwarding: Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default()))), + host_key: Arc::new( + russh::keys::PrivateKey::random( + &mut rand_core::OsRng, + russh::keys::Algorithm::Ed25519, + ) + .unwrap(), + ), + }); + assert_eq!(ssh_config.kind(), InterfaceKind::Ssh); + + let raw_config = InterfaceConfig::RawFraming(RawFramingConfig {}); + assert_eq!(raw_config.kind(), InterfaceKind::RawFraming); + } + + #[test] + fn interface_kind_equality() { + assert_eq!(InterfaceKind::Ssh, InterfaceKind::Ssh); + assert_eq!(InterfaceKind::RawFraming, InterfaceKind::RawFraming); + assert_ne!(InterfaceKind::Ssh, InterfaceKind::RawFraming); + } + + #[test] + fn raw_framing_config_minimal() { + let _config = RawFramingConfig {}; + } +} diff --git a/crates/alknet-core/src/interface/mod.rs b/crates/alknet-core/src/interface/mod.rs new file mode 100644 index 0000000..3dc29e8 --- /dev/null +++ b/crates/alknet-core/src/interface/mod.rs @@ -0,0 +1,68 @@ +//! Interface layer (Layer 2) of the three-layer model (ADR-026). +//! +//! The Interface layer sits between Transport (Layer 1) and Protocol (Layer 3). +//! An Interface consumes a `TransportStream` and produces call protocol sessions +//! that yield `EventEnvelope` frames. This enables the call protocol handler to be +//! interface-agnostic — it receives `InterfaceEvent` frames from any interface. +//! +//! SSH is an interface, not a transport. It wraps a byte stream in session +//! semantics (handshake, auth, channel multiplexing). Raw framing (4-byte length +//! prefix + JSON `EventEnvelope`) is another interface, one without SSH overhead. +//! +//! # OQ-IF-01 Resolution +//! +//! Every Interface session implements the `InterfaceSession` trait, which provides +//! `recv()` and `send()` methods producing and consuming `InterfaceEvent` frames. +//! Each `InterfaceEvent` carries an `EventEnvelope` and an optional `Identity` +//! (authenticated by the interface layer, e.g., via SSH public key auth or +//! transport-level token auth). +//! +//! This means the call protocol handler (Layer 3) is completely interface-agnostic: +//! it receives `InterfaceEvent` frames and processes them uniformly, regardless +//! of whether they arrived over SSH or raw framing. + +pub mod config; +pub mod pairs; +pub mod session; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub use config::{InterfaceConfig, InterfaceKind, RawFramingConfig, SshInterfaceConfig}; +pub use pairs::{is_valid_pair, TransportKindBase, VALID_TRANSPORT_INTERFACE_PAIRS}; +pub use session::{InterfaceEvent, InterfaceSession}; + +pub trait TransportStream: AsyncRead + AsyncWrite + Unpin + Send + 'static {} + +impl TransportStream for T {} + +#[async_trait] +pub trait Interface: Send + Sync + 'static { + type Session: InterfaceSession; + + async fn accept( + &self, + stream: Box, + config: &InterfaceConfig, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::duplex; + + #[test] + fn transport_stream_trait_bounds() { + fn assert_transport_stream() {} + assert_transport_stream::(); + } + + #[tokio::test] + async fn transport_stream_from_duplex() { + let (client, server) = duplex(1024); + let _boxed: Box = Box::new(server); + let _: Box = Box::new(client); + } +} diff --git a/crates/alknet-core/src/interface/pairs.rs b/crates/alknet-core/src/interface/pairs.rs new file mode 100644 index 0000000..3047c8b --- /dev/null +++ b/crates/alknet-core/src/interface/pairs.rs @@ -0,0 +1,142 @@ +use crate::transport::TransportKind; + +use super::config::InterfaceKind; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportKindBase { + Tcp, + Tls, + Iroh, + Dns, + WebTransport, +} + +fn transport_base(kind: &TransportKind) -> TransportKindBase { + match kind { + TransportKind::Tcp => TransportKindBase::Tcp, + TransportKind::Tls { .. } => TransportKindBase::Tls, + TransportKind::Iroh { .. } => TransportKindBase::Iroh, + TransportKind::Dns { .. } => TransportKindBase::Dns, + TransportKind::WebTransport { .. } => TransportKindBase::WebTransport, + } +} + +pub fn is_valid_pair(transport: &TransportKind, interface: InterfaceKind) -> bool { + let base = transport_base(transport); + matches!( + (base, interface), + (TransportKindBase::Tcp, InterfaceKind::Ssh) + | (TransportKindBase::Tls, InterfaceKind::Ssh) + | (TransportKindBase::Iroh, InterfaceKind::Ssh) + | (TransportKindBase::Dns, InterfaceKind::RawFraming) + | (TransportKindBase::WebTransport, InterfaceKind::Ssh) + | (TransportKindBase::WebTransport, InterfaceKind::RawFraming) + | (TransportKindBase::Tcp, InterfaceKind::RawFraming) + ) +} + +pub const VALID_TRANSPORT_INTERFACE_PAIRS: &[(TransportKindBase, InterfaceKind)] = &[ + (TransportKindBase::Tcp, InterfaceKind::Ssh), + (TransportKindBase::Tls, InterfaceKind::Ssh), + (TransportKindBase::Iroh, InterfaceKind::Ssh), + (TransportKindBase::Dns, InterfaceKind::RawFraming), + (TransportKindBase::WebTransport, InterfaceKind::Ssh), + (TransportKindBase::WebTransport, InterfaceKind::RawFraming), + (TransportKindBase::Tcp, InterfaceKind::RawFraming), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_ssh_pairs() { + assert!(is_valid_pair(&TransportKind::Tcp, InterfaceKind::Ssh)); + assert!(is_valid_pair( + &TransportKind::Tls { server_name: None }, + InterfaceKind::Ssh + )); + assert!(is_valid_pair( + &TransportKind::Iroh { + endpoint_id: String::new() + }, + InterfaceKind::Ssh + )); + assert!(is_valid_pair( + &TransportKind::WebTransport { + host: String::new() + }, + InterfaceKind::Ssh + )); + } + + #[test] + fn valid_raw_framing_pairs() { + assert!(is_valid_pair( + &TransportKind::Tcp, + InterfaceKind::RawFraming + )); + assert!(is_valid_pair( + &TransportKind::Dns { + domain: String::new() + }, + InterfaceKind::RawFraming + )); + assert!(is_valid_pair( + &TransportKind::WebTransport { + host: String::new() + }, + InterfaceKind::RawFraming + )); + } + + #[test] + fn invalid_pairs() { + assert!(!is_valid_pair( + &TransportKind::Dns { + domain: String::new() + }, + InterfaceKind::Ssh + )); + assert!(!is_valid_pair( + &TransportKind::Iroh { + endpoint_id: String::new() + }, + InterfaceKind::RawFraming + )); + } + + #[test] + fn transport_kind_base_classification() { + assert_eq!(transport_base(&TransportKind::Tcp), TransportKindBase::Tcp); + assert_eq!( + transport_base(&TransportKind::Tls { + server_name: Some("example.com".to_string()) + }), + TransportKindBase::Tls + ); + assert_eq!( + transport_base(&TransportKind::Iroh { + endpoint_id: "abc".to_string() + }), + TransportKindBase::Iroh + ); + assert_eq!( + transport_base(&TransportKind::Dns { + domain: "example.com".to_string() + }), + TransportKindBase::Dns + ); + assert_eq!( + transport_base(&TransportKind::WebTransport { + host: "example.com".to_string() + }), + TransportKindBase::WebTransport + ); + } + + #[test] + fn valid_pairs_table_complete() { + assert_eq!(VALID_TRANSPORT_INTERFACE_PAIRS.len(), 7); + } +} diff --git a/crates/alknet-core/src/interface/session.rs b/crates/alknet-core/src/interface/session.rs new file mode 100644 index 0000000..0955e41 --- /dev/null +++ b/crates/alknet-core/src/interface/session.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use async_trait::async_trait; + +use crate::auth::Identity; +use crate::call::EventEnvelope; + +#[derive(Debug, Clone)] +pub struct InterfaceEvent { + pub envelope: EventEnvelope, + pub identity: Option, +} + +impl InterfaceEvent { + pub fn new(envelope: EventEnvelope) -> Self { + Self { + envelope, + identity: None, + } + } + + pub fn with_identity(envelope: EventEnvelope, identity: Identity) -> Self { + Self { + envelope, + identity: Some(identity), + } + } +} + +#[async_trait] +pub trait InterfaceSession: Send { + async fn recv(&mut self) -> Option; + + async fn send(&mut self, envelope: EventEnvelope) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn interface_event_new() { + let envelope = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"})); + let event = InterfaceEvent::new(envelope.clone()); + assert_eq!(event.envelope, envelope); + assert!(event.identity.is_none()); + } + + #[test] + fn interface_event_with_identity() { + let envelope = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"})); + let identity = Identity { + id: "SHA256:abc123".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: HashMap::new(), + }; + let event = InterfaceEvent::with_identity(envelope.clone(), identity.clone()); + assert_eq!(event.envelope, envelope); + assert!(event.identity.is_some()); + assert_eq!(event.identity.as_ref().unwrap().id, "SHA256:abc123"); + } +} diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 400a1a1..5fe70d4 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod call; pub mod client; pub mod config; pub mod error; +pub mod interface; pub mod server; pub mod socks5; pub mod transport; @@ -84,5 +85,10 @@ pub use config::{ ForwardingPolicy, ForwardingRule, RateLimitConfig, StaticConfig, TargetPattern, }; pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError}; +pub use interface::{ + is_valid_pair, Interface, InterfaceConfig, InterfaceEvent, InterfaceKind, InterfaceSession, + RawFramingConfig, SshInterfaceConfig, TransportKindBase, TransportStream, + VALID_TRANSPORT_INTERFACE_PAIRS, +}; pub use server::serve::{ListenerConfig, ServeError, ServeOptions, ServeTransportMode, Server}; pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};