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
This commit is contained in:
89
crates/alknet-core/src/interface/config.rs
Normal file
89
crates/alknet-core/src/interface/config.rs
Normal file
@@ -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<dyn IdentityProvider>,
|
||||||
|
pub forwarding: Arc<ArcSwap<DynamicConfig>>,
|
||||||
|
pub host_key: Arc<PrivateKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
68
crates/alknet-core/src/interface/mod.rs
Normal file
68
crates/alknet-core/src/interface/mod.rs
Normal file
@@ -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<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> TransportStream for T {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Interface: Send + Sync + 'static {
|
||||||
|
type Session: InterfaceSession;
|
||||||
|
|
||||||
|
async fn accept(
|
||||||
|
&self,
|
||||||
|
stream: Box<dyn TransportStream>,
|
||||||
|
config: &InterfaceConfig,
|
||||||
|
) -> Result<Self::Session>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::duplex;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_stream_trait_bounds() {
|
||||||
|
fn assert_transport_stream<S: TransportStream>() {}
|
||||||
|
assert_transport_stream::<tokio::io::DuplexStream>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transport_stream_from_duplex() {
|
||||||
|
let (client, server) = duplex(1024);
|
||||||
|
let _boxed: Box<dyn TransportStream> = Box::new(server);
|
||||||
|
let _: Box<dyn TransportStream> = Box::new(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
crates/alknet-core/src/interface/pairs.rs
Normal file
142
crates/alknet-core/src/interface/pairs.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/alknet-core/src/interface/session.rs
Normal file
62
crates/alknet-core/src/interface/session.rs
Normal file
@@ -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<Identity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InterfaceEvent>;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ pub mod call;
|
|||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod interface;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod socks5;
|
pub mod socks5;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
@@ -84,5 +85,10 @@ pub use config::{
|
|||||||
ForwardingPolicy, ForwardingRule, RateLimitConfig, StaticConfig, TargetPattern,
|
ForwardingPolicy, ForwardingRule, RateLimitConfig, StaticConfig, TargetPattern,
|
||||||
};
|
};
|
||||||
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
|
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 server::serve::{ListenerConfig, ServeError, ServeOptions, ServeTransportMode, Server};
|
||||||
pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
||||||
|
|||||||
Reference in New Issue
Block a user