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:
2026-06-07 15:27:51 +00:00
parent de6e0795fd
commit f62f8dfaf1
5 changed files with 367 additions and 0 deletions

View 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 {};
}
}

View 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);
}
}

View 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);
}
}

View 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");
}
}

View File

@@ -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};