- Add definitions.md: normative terminology disambiguation (Interface, Service, Transport, Token, Identity, Domain, Scope, CredentialProvider, etc.) - Add credentials.md: CredentialProvider trait and CredentialSet enum for outbound auth, mirroring IdentityProvider pattern for inbound auth - Rewrite interface.md: StreamInterface/MessageInterface split (ADR-035), InterfaceRequest/InterfaceResponse, HttpInterface/DnsInterface stubs, ListenerConfig with Stream/Http/Dns variants, credential presentation table - Update auth.md: API keys in DynamicConfig (ADR-037), credential presentation per (Transport, Interface) pair, ApiKeyEntry struct in AuthPolicy - Update configuration.md: API keys, ListenerConfig with Http/Dns variants, expanded TOML config examples - Update call-protocol.md: resolve OQ-IF-01 (InterfaceEvent carries EventEnvelope + Identity), add MessageInterface awareness to protocol adapter layer - Update overview.md: three-layer model now includes StreamInterface/ MessageInterface, CredentialProvider/CredentialSet exports, definitions.md reference, ADRs 035-037 - Update open-questions.md: resolve OQ-IF-01, OQ-IF-02, add OQ-P2-01 through OQ-P2-04, add OQ-CP-01 through OQ-CP-04, add OQ-DEF-01, OQ-DEF-03, OQ-DEF-08 - Update README.md: add definitions.md, credentials.md, ADRs 035-037, phase2 research docs, current state description Key architectural decisions: - ADR-035: StreamInterface/MessageInterface split (two Layer 2 traits) - ADR-036: CredentialProvider as core type (outbound auth, alknet_core::credentials) - ADR-037: API keys as DynamicConfig auth (hash-verified bearer tokens)
376 lines
15 KiB
Markdown
376 lines
15 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-06-09
|
|
---
|
|
|
|
# Interface (Layer 2)
|
|
|
|
## What
|
|
|
|
The Interface layer sits between Transport (Layer 1) and Protocol (Layer 3).
|
|
Interfaces consume byte streams from Transports or manage their own transports,
|
|
and produce call protocol sessions or handle discrete requests. SSH is an
|
|
interface, not a transport — it wraps a byte stream in session semantics. Raw
|
|
framing (4-byte length prefix + JSON `EventEnvelope`) is another interface.
|
|
HTTP and DNS are message-based interfaces that handle individual request/response
|
|
pairs without persistent sessions.
|
|
|
|
## Why
|
|
|
|
In the original architecture, SSH was deeply embedded in `ServerHandler`. This
|
|
tangling of transport, interface, and protocol made it impossible to:
|
|
|
|
- Run the call protocol over DNS queries without wrapping SSH inside DNS
|
|
- Use raw framing for local service mesh (no SSH overhead)
|
|
- Support WebTransport direct call protocol for browsers
|
|
- Separate auth mechanics from channel management
|
|
- Accept HTTP requests and map them to call protocol operations
|
|
|
|
The three-layer model (ADR-026) cleanly separates these concerns. Transport
|
|
produces bytes. Interface parses bytes into sessions or handles requests.
|
|
Protocol carries semantics. A connection is always a (Transport, Interface)
|
|
pair for stream-based interfaces, or a standalone message-based interface.
|
|
|
|
Phase 2 research identified that HTTP and DNS don't fit the persistent session
|
|
model — they're stateless per-request. This led to the StreamInterface /
|
|
MessageInterface split (ADR-035), which gives each interface category its own
|
|
trait with the right lifecycle and ownership model.
|
|
|
|
## Architecture
|
|
|
|
### Three-Layer Model
|
|
|
|
```
|
|
Layer 3: Protocol (Call protocol, Operations, OperationEnv)
|
|
Layer 2: Interface (StreamInterface: SSH, raw framing | MessageInterface: HTTP, DNS)
|
|
Layer 1: Transport (TCP, TLS, iroh, WebTransport)
|
|
```
|
|
|
|
- **Layer 1: Transport** — produces byte streams (`AsyncRead + AsyncWrite + Unpin
|
|
+ Send`). Unchanged per ADR-001. DNS is NOT a transport.
|
|
- **Layer 2: Interface** — two categories:
|
|
- **StreamInterface**: consumes a `TransportStream` and produces a long-lived
|
|
session that yields `InterfaceEvent` frames.
|
|
- **MessageInterface**: handles individual `InterfaceRequest` →
|
|
`InterfaceResponse` pairs. Manages its own transport.
|
|
- **Layer 3: Protocol** — carries semantics. Call protocol events, operation
|
|
registry, service calls. Agnostic to both Transport and Interface below it.
|
|
|
|
### StreamInterface Trait
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait StreamInterface: Send + Sync + 'static {
|
|
type Session: InterfaceSession;
|
|
|
|
async fn accept(
|
|
&self,
|
|
stream: Box<dyn TransportStream>,
|
|
config: &InterfaceConfig,
|
|
) -> Result<Self::Session>;
|
|
}
|
|
```
|
|
|
|
The session produced by a `StreamInterface` is consumed by the call protocol
|
|
handler. Different stream interfaces produce different session types, but the
|
|
call protocol handler receives `InterfaceEvent` frames from any stream
|
|
interface.
|
|
|
|
### MessageInterface Trait
|
|
|
|
```rust
|
|
#[async_trait]
|
|
pub trait MessageInterface: Send + Sync + 'static {
|
|
async fn handle_request(&self, request: InterfaceRequest) -> Result<InterfaceResponse>;
|
|
}
|
|
```
|
|
|
|
Message-based interfaces handle individual requests without persistent sessions.
|
|
They manage their own transport (HTTP server, DNS server) and normalize requests
|
|
into `InterfaceRequest` / `InterfaceResponse`.
|
|
|
|
### InterfaceRequest / InterfaceResponse
|
|
|
|
```rust
|
|
pub struct InterfaceRequest {
|
|
pub operation_path: String, // e.g., "/head/auth/verify"
|
|
pub input: Value, // JSON input payload
|
|
pub auth_token: Option<AuthToken>, // Extracted from wire format
|
|
pub metadata: HashMap<String, String>,
|
|
}
|
|
|
|
pub struct InterfaceResponse {
|
|
pub result: Result<Value, CallError>,
|
|
pub status: u16, // HTTP status, DNS result code, etc.
|
|
pub headers: HashMap<String, String>,
|
|
}
|
|
```
|
|
|
|
The call protocol handler processes `InterfaceRequest` the same way it processes
|
|
`InterfaceEvent` frames — both resolve to operation invocations through
|
|
`OperationEnv`. The difference is framing: stream interfaces produce `InterfaceEvent`
|
|
frames from a continuous byte stream, message interfaces construct `InterfaceRequest`
|
|
from their wire format.
|
|
|
|
### InterfaceSession
|
|
|
|
Every stream interface session implements `InterfaceSession`:
|
|
|
|
```rust
|
|
pub struct InterfaceEvent {
|
|
pub envelope: EventEnvelope,
|
|
pub identity: Option<Identity>,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait InterfaceSession: Send {
|
|
async fn recv(&mut self) -> Option<InterfaceEvent>;
|
|
async fn send(&mut self, envelope: EventEnvelope) -> Result<()>;
|
|
}
|
|
```
|
|
|
|
`InterfaceEvent` carries an `EventEnvelope` and the authenticated `Identity`.
|
|
The call protocol handler (Layer 3) receives `InterfaceEvent` frames and
|
|
processes them uniformly, regardless of whether they arrived over SSH or raw
|
|
framing.
|
|
|
|
### SshInterface (StreamInterface)
|
|
|
|
Wraps the existing `ServerHandler` logic. This is the most complex stream
|
|
interface because SSH provides channel multiplexing, auth negotiation, and
|
|
proxy management within a single session.
|
|
|
|
What stays in SshInterface (Layer 2):
|
|
- SSH handshake and session management
|
|
- Auth delegation to `IdentityProvider` (via `auth_publickey()` callback)
|
|
- Channel multiplexing (multiple channels per session)
|
|
- `alknet-control:0` channel routing to call protocol
|
|
|
|
What moves to Layer 3 (call protocol handler):
|
|
- Operation registry and dispatch
|
|
- Forwarding policy checks (per ADR-031)
|
|
- Operation context construction (Identity, scopes)
|
|
|
|
What moves to per-connection state:
|
|
- Port forwarding proxy logic
|
|
|
|
**Current implementation note**: `SshSession::recv()` and `SshSession::send()`
|
|
are stubs. The bridge from SSH channels to `InterfaceEvent` frames is
|
|
scheduled for Phase 2 implementation (see integration-plan.md Phase 2.1).
|
|
|
|
### RawFramingInterface (StreamInterface)
|
|
|
|
Reads 4-byte big-endian length prefix + JSON `EventEnvelope` frames directly
|
|
from the transport stream. No SSH wrapping. No channel multiplexing — the
|
|
entire stream is a single call protocol channel.
|
|
|
|
```rust
|
|
pub struct RawFramingInterface;
|
|
|
|
impl StreamInterface for RawFramingInterface {
|
|
type Session = RawFramingSession;
|
|
// Reads length-prefixed EventEnvelope frames from the stream
|
|
}
|
|
```
|
|
|
|
Used for:
|
|
- Local service mesh (TCP + raw framing, no SSH overhead)
|
|
- Secure mesh (TLS + raw framing)
|
|
- WebTransport direct call protocol (future: WebTransport + raw framing)
|
|
|
|
Auth for raw framing: `AuthToken` in frame header, resolved via
|
|
`IdentityProvider::resolve_from_token()`.
|
|
|
|
**Current implementation note**: `RawFramingInterface::accept()` returns an
|
|
error. Frame reading/writing is scheduled for Phase 2 implementation (see
|
|
integration-plan.md Phase 2.2).
|
|
|
|
### HttpInterface (MessageInterface)
|
|
|
|
Accepts standard HTTP requests and maps them to call protocol operations:
|
|
|
|
```
|
|
POST /v1/{namespace}/{op} → registry.invoke(namespace, op, input) (mutation)
|
|
GET /v1/{namespace}/{op} → registry.invoke(namespace, op, input) (query)
|
|
GET /v1/{namespace}/{op} SSE → registry.subscribe(namespace, op, input) (subscription)
|
|
GET /v1/schema → registry.list_operations()
|
|
```
|
|
|
|
Auth: `Authorization: Bearer <token>` header, resolved via
|
|
`IdentityProvider::resolve_from_token()`. Both AuthTokens and API keys are
|
|
accepted.
|
|
|
|
The HTTP interface runs inside the existing stealth mode byte-peek architecture:
|
|
after a TLS handshake, the server peeks at the first bytes. If they're
|
|
`SSH-2.0-`, the stream goes to `SshInterface`. Otherwise, the stream goes to
|
|
the axum HTTP router.
|
|
|
|
**Phase 2 scope**: Auth middleware, stealth handoff, and default 404 handler
|
|
only. Specific operation routes and path conventions are Phase 5+. The
|
|
`ListenerConfig::Http` variant spawns an axum router that reaches auth context;
|
|
routing inside axum is a later concern.
|
|
|
|
### DnsInterface (MessageInterface)
|
|
|
|
A DNS server that encodes/decodes `EventEnvelope` frames as DNS query/response
|
|
pairs. AuthToken is embedded in DNS query labels. Resolution via
|
|
`IdentityProvider::resolve_from_token()`.
|
|
|
|
This is a `MessageInterface` — it manages its own transport (UDP/TCP port 53)
|
|
and handles individual DNS queries as request/response pairs. DNS is NOT a
|
|
transport.
|
|
|
|
**Phase**: DNS interface implementation is Phase 5+. The `ListenerConfig::Dns`
|
|
variant and `DnsInterface` stub are defined now; implementation is deferred.
|
|
|
|
### Stream-Based Interface Pairs
|
|
|
|
| Transport | StreamInterface | Credential Presentation | Use case |
|
|
|-----------|---------------|------------------------|----------|
|
|
| TLS | SshInterface | SSH key handshake | Standard alknet tunnel |
|
|
| TCP | SshInterface | SSH key handshake | Plain SSH tunnel |
|
|
| iroh | SshInterface | SSH key handshake | P2P SSH tunnel |
|
|
| TCP | RawFramingInterface | AuthToken in frame header | Local service mesh |
|
|
| TLS | RawFramingInterface | AuthToken in frame header | Secure mesh |
|
|
| WebTransport | RawFramingInterface | AuthToken in CONNECT request | Browser call protocol (future) |
|
|
|
|
### Message-Based Interface Pairs
|
|
|
|
| MessageInterface | Credential Presentation | Owns transport? | Use case |
|
|
|-----------------|------------------------|----------------|----------|
|
|
| HttpInterface | `Authorization: Bearer` header | Yes (axum) | REST API, dashboard, integrations |
|
|
| DnsInterface | AuthToken in query labels | Yes (DNS server) | Censorship-resistant control channel |
|
|
| WebSocketInterface | AuthToken in handshake | Yes (WS server) | Browser persistent connection (future) |
|
|
|
|
Message-based interfaces manage their own transport. They don't need a
|
|
`Transport` from Layer 1 — they ARE the transport+interface combined.
|
|
|
|
### ListenerConfig
|
|
|
|
The server's accept loop configuration covers both stream and message interfaces:
|
|
|
|
```rust
|
|
pub enum ListenerConfig {
|
|
Stream {
|
|
transport: TransportKind,
|
|
interface: StreamInterfaceKind,
|
|
},
|
|
Http {
|
|
bind_addr: SocketAddr,
|
|
tls: bool,
|
|
stealth: bool, // byte-peek protocol detection on shared port
|
|
},
|
|
Dns {
|
|
bind_addr: SocketAddr,
|
|
tls: bool,
|
|
},
|
|
}
|
|
|
|
pub enum StreamInterfaceKind {
|
|
Ssh,
|
|
RawFraming,
|
|
}
|
|
|
|
pub enum TransportKind {
|
|
Tcp,
|
|
Tls { server_name: Option<String> },
|
|
Iroh { endpoint_id: String },
|
|
WebTransport, // Phase 5+: tag only, no acceptor yet
|
|
}
|
|
```
|
|
|
|
Note: `TransportKind::Dns` does NOT exist. DNS is a `MessageInterface`, not a
|
|
transport. The `ListenerConfig::Dns` variant handles DNS listener configuration
|
|
directly.
|
|
|
|
### Credential Presentation Across Interfaces
|
|
|
|
Every interface resolves to the same `Identity` through `IdentityProvider`:
|
|
|
|
```
|
|
SSH fingerprint → IdentityProvider::resolve_from_fingerprint → Identity
|
|
AuthToken (Bearer) → IdentityProvider::resolve_from_token → Identity
|
|
API key (Bearer) → IdentityProvider::resolve_from_token → Identity
|
|
DNS embedded token → IdentityProvider::resolve_from_token → Identity
|
|
```
|
|
|
|
The credential presentation differs per (Transport, Interface) pair, but the
|
|
resolution result is always an `Identity`. See [definitions.md](definitions.md)
|
|
for the full table and terminology rules.
|
|
|
|
### Server Accept Loop
|
|
|
|
With both stream and message interfaces, the accept loop becomes:
|
|
|
|
```rust
|
|
for listener in listeners {
|
|
match listener {
|
|
ListenerConfig::Stream { transport, interface } => {
|
|
// Spawn accept loop: transport.accept() → interface.accept(stream)
|
|
}
|
|
ListenerConfig::Http { bind_addr, tls, stealth } => {
|
|
// Spawn axum HTTP server on bind_addr
|
|
// If stealth: byte-peek after TLS, route SSH vs HTTP
|
|
}
|
|
ListenerConfig::Dns { bind_addr, tls } => {
|
|
// Spawn DNS server on bind_addr
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Constraints
|
|
|
|
- `StreamInterface` and `MessageInterface` are independent traits with different
|
|
signatures, lifecycles, and transport ownership. No common super-trait (ADR-035).
|
|
- `SshInterface` is the most invasive refactoring. The existing `SshHandler`
|
|
owns auth, channel management, and proxy logic — extracting these cleanly
|
|
requires careful design (integration-plan Phase 1.8, completed in Phase 1).
|
|
- DNS interface implementation is Phase 5 work. `DnsInterface` is defined as a
|
|
`MessageInterface` stub; implementation is deferred.
|
|
- HTTP interface Phase 2 scope is limited to auth middleware and stealth handoff.
|
|
Specific operation routes are Phase 5+.
|
|
- WebTransport is Phase 5 work. `TransportKind::WebTransport` and
|
|
`StreamInterfaceKind::WebTransport` are tags only for now.
|
|
- `TransportKind::Dns` does not exist. DNS is a `MessageInterface`, not a
|
|
transport. This was `TransportKind` enum pollution from an earlier design.
|
|
- The `Interface` trait (singular) in the current codebase needs to be renamed
|
|
to `StreamInterface`. This is a rename, not a semantic change.
|
|
|
|
## Open Questions
|
|
|
|
- **OQ-IF-02**: ~~Should `SshInterface` own the `ForwardingPolicy` check for
|
|
`channel_open_direct_tcpip`, or should that move to Layer 3?~~ **Resolved**:
|
|
ForwardingPolicy is Layer 3, but channel open/close lifecycle is Layer 2.
|
|
SshInterface reports channel requests to Layer 3; Layer 3 applies policy.
|
|
|
|
- **OQ-P2-01**: Should `MessageInterface` and `StreamInterface` share a common
|
|
trait? **Recommendation**: No. Independent traits with different signatures,
|
|
lifecycles, and transport ownership. A common super-trait adds complexity
|
|
without clear benefit. (See ADR-035.)
|
|
|
|
- **OQ-P2-02**: Should the HTTP interface share a port with the SSH listener?
|
|
**Recommendation**: Start with separate ports. ALPN multiplexing on port 443
|
|
is a future optimization that doesn't change the interface abstraction.
|
|
Stealth mode byte-peek already handles shared-port detection for the common
|
|
case.
|
|
|
|
## Design Decisions
|
|
|
|
| ADR | Decision | Summary |
|
|
|-----|----------|---------|
|
|
| [026](decisions/026-transport-interface-separation.md) | Three-layer model | SSH is Layer 2, not Layer 1 |
|
|
| [035](decisions/035-streaminterface-messageinterface-split.md) | StreamInterface / MessageInterface | Two trait categories at Layer 2 |
|
|
| [033](decisions/033-operationenv-irpc-call-protocol.md) | OperationEnv | Protocol is interface-agnostic |
|
|
| [029](decisions/029-identity-core-type.md) | Identity as core type | Auth resolution across interfaces |
|
|
| [031](decisions/031-forwarding-policy.md) | Forwarding policy | Layer 3 policy applied to Layer 2 channel requests |
|
|
|
|
## References
|
|
|
|
- [definitions.md](definitions.md) — Terminology disambiguation, credential presentation
|
|
- [research/phase2/interface-model.md](../research/phase2/interface-model.md) — Full StreamInterface/MessageInterface analysis
|
|
- [research/phase2/tls-transport.md](../research/phase2/tls-transport.md) — HTTP interface, stealth handoff, ListenerConfig
|
|
- [research/integration-plan.md](../research/integration-plan.md) — Phase 1.8, Phase 2.1-2.7
|
|
- [transport.md](transport.md) — Transport trait (unchanged at Layer 1)
|
|
- [auth.md](auth.md) — Credential presentation per (Transport, Interface) pair
|
|
- [identity.md](identity.md) — IdentityProvider, auth across interfaces |