Three research documents for Phase 2 planning: - credential-provider.md: Outbound auth (CredentialProvider trait, CredentialSet enum), account model as storage-layer concern (Identity.id as account UUID), SecretStoreCredentialProvider, ManagedCredentialProvider, self-hosted service auth analysis (rustfs S3/OIDC, gitea OAuth2), implementation phases A-D. - interface-model.md: StreamInterface vs MessageInterface trait design, HTTP interface as axum handler, DNS as MessageInterface, unified auth across all interfaces (AuthToken + API keys via resolve_from_token), removal of TransportKind::Dns. - tls-transport.md: Unified multi-interface architecture on port 443. Byte-peek protocol detection (existing stealth mode) routes SSH vs axum. Axum multiplexes REST, WebSocket, SSE, gRPC. QUIC/UDP with ALPN routing for WebTransport and iroh P2P. Single AuthToken mechanism for all non-SSH interfaces. Four primitive operations (call/batch/schema/subscribe) map to HTTP, MCP, and DNS.
20 KiB
Interface Model: Stream and Message Interfaces
Status: Research / Draft Last updated: 2026-06-08 Part of: Phase 2 planning
Overview
The current three-layer model (ADR-026, interface.md) defines Transport (Layer 1), Interface (Layer 2), and Protocol (Layer 3). The Interface trait assumes a persistent byte stream from a Transport, which works for SSH and raw framing. However, two important interface types — HTTP and DNS — don't fit this model: they handle individual requests, not persistent sessions. This document proposes splitting the interface model into StreamInterface and MessageInterface, adding HTTP as a first-class interface, and reclassifying DNS from a transport to a message-based interface.
Problem Statement
DNS is not a transport
The current TransportKind enum includes Dns { domain: String } alongside Tcp, Tls, and Iroh. But DNS doesn't produce a AsyncRead + AsyncWrite + Unpin + Send byte stream. It's a request/response protocol. Listing it as a transport conflates different abstractions. DNS encodes/decodes EventEnvelope frames as DNS query/response pairs — that's an interface behavior, not a transport behavior.
HTTP is missing as an interface
The current valid (Transport, Interface) pairs are all stream-based:
| Transport | Interface |
|---|---|
| TLS | SSH |
| TCP | SSH |
| iroh | SSH |
| DNS | raw framing |
| WebTransport | SSH |
| WebTransport | raw framing |
| TCP | raw framing |
But there's no HTTP interface — the (TCP/TLS, HTTP) pair that accepts standard HTTP requests and maps them to call protocol operations. This is the server-side equivalent of OpenAPIServiceRegistry (which does client-side: consuming OpenAPI specs to make outbound HTTP calls). Without it, external clients (browsers, curl, monitoring) can only reach alknet through SSH.
Auth across all interfaces
Different interfaces authenticate differently, but all resolve to the same Identity through IdentityProvider:
| (Transport, Interface) | Auth mechanism | Resolves via |
|---|---|---|
| (TLS, SSH) | SSH public key handshake | IdentityProvider::resolve_from_fingerprint() |
| (TCP, SSH) | SSH public key handshake | IdentityProvider::resolve_from_fingerprint() |
| (iroh, SSH) | SSH public key handshake | IdentityProvider::resolve_from_fingerprint() |
| (TLS, raw framing) | Token in frame header | IdentityProvider::resolve_from_token() |
| (TCP, raw framing) | Token in frame header | IdentityProvider::resolve_from_token() |
| (WebTransport, raw framing) | Token in CONNECT request | IdentityProvider::resolve_from_token() |
| (TLS, HTTP) | HTTP Authorization header | IdentityProvider::resolve_from_token() |
| (—, DNS) | Token embedded in DNS query | IdentityProvider::resolve_from_token() |
All token-based paths use the same AuthToken format (Ed25519-signed timestamp, defined in auth.md). The IdentityProvider trait doesn't change — resolve_from_token() already covers all of these. The difference is just how the token gets extracted from the wire format.
Design
StreamInterface and MessageInterface
The current Interface trait has this signature:
#[async_trait]
pub trait Interface: Send + Sync + 'static {
type Session;
async fn accept(stream: TransportStream, config: &InterfaceConfig) -> Result<Self::Session>;
}
This works for SSH and raw framing — both run over a duplex stream. But HTTP and DNS are message-based: they receive isolated requests, not persistent sessions. The interface model needs to accommodate both patterns.
Rename Interface to StreamInterface for stream-based connections:
#[async_trait]
pub trait StreamInterface: Send + Sync + 'static {
type Session;
async fn accept(stream: TransportStream, config: &InterfaceConfig) -> Result<Self::Session>;
}
Add MessageInterface for message-based request/response interfaces:
#[async_trait]
pub trait MessageInterface: Send + Sync + 'static {
async fn handle_request(&self, request: InterfaceRequest) -> Result<InterfaceResponse>;
}
Why separate traits instead of one:
- Different signatures:
StreamInterfaceproduces a session from a stream.MessageInterfacehandles an individual request. - Different lifecycles: Stream sessions are long-lived (SSH channels persist). Message handlers are stateless per-request (each HTTP request is independent).
- Different transport ownership:
StreamInterfacereceives aTransportStreamfrom elsewhere.MessageInterfacemanages its own transport (HTTP server, DNS server).
InterfaceRequest / InterfaceResponse
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>,
}
This is a normalized interface-agnostic request/response. The MessageInterface implementation extracts the operation path, input, and auth token from its wire format (HTTP, DNS, etc.) and constructs an InterfaceRequest. The call protocol handler processes it and returns an InterfaceResponse that the implementation serializes back to its wire format.
HTTP Interface
The HTTP interface 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, params as input)
GET /v1/{namespace}/{op} SSE → registry.subscribe(namespace, op, input) (subscription)
This is how external clients invoke alknet operations without SSH. Use cases:
- Dashboard UI calling operations via fetch()
- Third-party service integration via REST API
- Health checks and monitoring endpoints
- Other alknet nodes using
OpenAPIServiceRegistryto register against this API
pub struct HttpInterface {
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
env: OperationEnv,
}
Auth: Extract Authorization: Bearer <token> header, pass to IdentityProvider::resolve_from_token(). The token is the same AuthToken format used by WebTransport and raw framing.
The HTTP interface manages its own transport layer (hyper/axum/actix). It doesn't need a Transport from Layer 1 — HTTP IS the transport. This is the same pattern as the DNS interface.
DNS Interface
DNS is not a transport. It's a message-based interface that encodes EventEnvelope frames as DNS query/response pairs:
DNS query: "_alknet.request.{base64url(payload)}.alk.dev TXT?"
→ decoded as EventEnvelope (call.requested)
→ call protocol handler processes it
→ encoded as EventEnvelope (call.responded)
→ returned as DNS TXT record response
pub struct DnsInterface {
domain: String,
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
env: OperationEnv,
}
Auth: Token embedded in the DNS query. Same AuthToken format.
The DNS interface runs its own DNS server. It doesn't need a separate Transport — DNS is both the transport and the interface combined.
Remove TransportKind::Dns
Since DNS is a MessageInterface (not a transport), TransportKind::Dns should be removed from the enum. The ListenerConfig enum should be updated to cover both stream and message listeners:
pub enum ListenerConfig {
Stream {
transport: TransportKind,
interface: StreamInterfaceKind,
},
Message {
interface: MessageInterfaceKind,
bind_addr: SocketAddr,
},
}
This cleanly separates "listen for byte streams" from "listen for messages."
Revised Interface Pairs
Stream-based connections (persistent session, StreamInterface):
| Transport | StreamInterface | Auth | Use case |
|---|---|---|---|
| TLS | SshInterface | SSH pubkey handshake | Standard alknet tunnel |
| TCP | SshInterface | SSH pubkey handshake | Plain SSH tunnel |
| iroh | SshInterface | SSH pubkey handshake | P2P SSH tunnel |
| TCP | RawFramingInterface | Token in frame header | Local service mesh |
| TLS | RawFramingInterface | Token in frame header | Secure mesh |
| WebTransport | SshInterface | SSH pubkey handshake | Browser SSH tunnel (future) |
| WebTransport | RawFramingInterface | Token in CONNECT request | Browser call protocol (future) |
Message-based interfaces (stateless per-request, MessageInterface):
| MessageInterface | Auth | Owns transport? | Use case |
|---|---|---|---|
| HttpInterface | Authorization header (Bearer token) | Yes (hyper/axum) | REST API, dashboard, integrations |
| DnsInterface | Token embedded in query labels | Yes (DNS server) | Censorship-resistant control channel |
| WebSocketInterface | Token in handshake or first message | Yes (WS server) | Browser persistent connection (future) |
The MessageInterface implementations manage their own transport. They don't need the Transport trait because they're not wrapping a generic byte stream — they ARE the transport+interface combined.
Unified auth across all interfaces
Every interface resolves to the same Identity through IdentityProvider:
SSH fingerprint → IdentityProvider::resolve_from_fingerprint → Identity
Bearer token → IdentityProvider::resolve_from_token → Identity
HTTP Authorization → IdentityProvider::resolve_from_token → Identity
DNS embedded token → IdentityProvider::resolve_from_token → Identity
WebSocket token → IdentityProvider::resolve_from_token → Identity
The token format is the same AuthToken = base64url(key_id || timestamp || signature) defined in auth.md. The interface just extracts the credential from its wire format. IdentityProvider resolves it to an Identity. The call protocol handler receives OperationContext with that identity.
In database-backed deployments (StorageIdentityProvider), Identity.id is the account UUID — so the same person connecting via SSH, HTTP, or DNS resolves to the same identity. No separate account_id field needed.
ConfigIdentityProvider: Token auth without a database
The config-based (minimal) deployment gains API key / bearer token support through DynamicConfig.auth:
[auth.ssh]
authorized_keys = [...]
[auth.token]
enabled = true
max_token_age = "5m"
# key_source = "shared" (default: same keys as SSH)
[[auth.api_keys]]
prefix = "alk_"
hash = "sha256:xyz..."
scopes = ["relay:connect"]
description = "dashboard service account"
ConfigIdentityProvider::resolve_from_token() already exists in the current spec. It verifies the AuthToken format (Ed25519 signed timestamp) against the same authorized_keys set used for SSH. The api_keys section adds an alternative: simple bearer tokens (hash-verified, with optional TTL) that don't require Ed25519 key pairs. This is useful for service accounts and automation.
Both token types produce the same Identity. Config-based Identity.id is the key fingerprint (for AuthToken) or the key prefix (for simple bearer tokens). In database-backed deployments, both resolve to the account UUID.
Service Decomposition
AuthService (existing — ADR-028)
Resolves inbound credentials to an Identity. Already defined. Works across all interfaces — SSH interface calls resolve_from_fingerprint(), HTTP/DNS interfaces call resolve_from_token(). No changes needed.
CredentialService (new — see credential-provider.md)
Resolves outbound credentials for external service access. Defined in credential-provider.md.
AccountService (new — storage layer)
Manages accounts and credential associations. This is a storage-layer irpc service, not a core concern:
AccountProtocol::CreateAccount { display_name, default_scopes }AccountProtocol::GetAccount { account_id }AccountProtocol::AddCredential { account_id, credential }(SSH key, API key)AccountProtocol::RemoveCredential { account_id, credential_id }AccountProtocol::ListCredentials { account_id }
This is the CRUD layer. StorageIdentityProvider uses it internally. External management (admin UI) goes through AccountService. Analogous to how ConfigService provides ConfigReloadHandle — core has the read trait, storage has the management service.
Core doesn't need AccountService for operation. IdentityProvider is the read-only contract. Account management is additive.
Impact on Existing Specs
interface.md
Needs revision:
- Rename
InterfacetoStreamInterface— the current trait becomes the stream-specific variant. - Add
MessageInterfacetrait — for HTTP, DNS, WebSocket. - Add
HttpInterfaceas aMessageInterfaceimplementation. - Clarify DNS — DNS is a
MessageInterface, not a (DNS transport, raw framing) pair. RemoveTransportKind::Dnsfrom the transport enum. - Add valid message-based interface pairs table alongside the stream-based pairs table.
- Add
InterfaceRequest/InterfaceResponsetypes that normalize calls across message interfaces.
auth.md
Needs revision:
- Add HTTP interface auth —
Authorization: Bearer <token>extraction. - Add DNS interface auth — token embedded in DNS query labels.
- Add auth presentation table showing all interface/auth combos.
- Add simple API keys — bearer tokens (hash-verified, with optional TTL) for service accounts. Not all token auth needs Ed25519 key pairs.
transport.md
Minor: Remove TransportKind::Dns from the enum. Add note that DNS is handled as a MessageInterface.
call-protocol.md
Minor update: the call protocol handler should accept EventEnvelope frames from both StreamInterface::Session and MessageInterface::handle_request(). The dispatch logic is the same — only the framing differs.
ADR-026
Needs update: the three-layer model is correct, but the (Transport, Interface) pair enumeration in ADR-026 lists DNS as a transport. This should be revised to show StreamInterface and MessageInterface as two interface categories at Layer 2.
Phasing Considerations
| Work | Suggested Phase | Notes |
|---|---|---|
Rename Interface → StreamInterface |
Phase 1 (now) | Rename only, no behavior change. Existing code already implements the stream pattern. |
Define MessageInterface trait |
Phase 1 (now) | Cheap, forward-compatible. Define the trait and InterfaceRequest/InterfaceResponse types. |
Define HttpInterface stub |
Phase 1 (now) | Define the struct and impl signature. Full HTTP server wiring can wait. |
TransportKind::Dns removal |
Phase 1 (now) | Clean up the enum before code depends on TransportKind::Dns. |
ListenerConfig with Stream/Message variants |
Phase 1 (now) | Update the server accept loop to support both interface types. |
HttpInterface implementation |
Phase 2 | Full HTTP server with router, auth middleware, SSE. Depends on core being stable. |
DnsInterface implementation |
Phase 3+ | DNS protocol is non-trivial. Deferring is fine. |
AccountService irpc protocol |
Phase 2 | CRUD for accounts. Lives in alknet-storage. |
ApiKeys in DynamicConfig.auth |
Phase 1 (now) | Enable bearer token auth in config-based deployments. |
The key observation: defining the traits (MessageInterface, InterfaceRequest, HttpInterface stub) now is cheap and prevents refactoring later. The actual HTTP server implementation can wait for Phase 2. But the trait surface needs to exist in Phase 1 so downstream code can target it.
Open Questions
OQ-IF-03: Should MessageInterface and StreamInterface share a common trait?
Recommendation: Independent traits. Different signatures (handle_request vs accept + next_event/send_event), different lifecycles (stateless vs session-stateful), different transport ownership (self-managed vs provided). A common super-trait adds complexity without clear benefit.
OQ-IF-04: Should TransportKind::Dns be removed from the enum?
Recommendation: Yes. DNS doesn't produce byte streams. Remove it and add ListenerConfig::Message variant. This is a cleanup, not a breaking change — TransportKind::Dns is currently a tag with no acceptor implementation.
OQ-IF-05: Should the HTTP interface share a port with the SSH listener?
In production, alknet might run SSH on port 22 and HTTP on port 443. Or both on 443 (TLS with ALPN). The HttpInterface could share a TLS listener with SshInterface if ALPN negotiation selects SSH vs. HTTP.
Recommendation: Start simple — separate ports. HTTP on its own port (default 8080 or configured via [[listeners]]). ALPN multiplexing is a future optimization that doesn't change the interface abstraction.
OQ-IF-06: Should the HTTP interface auto-generate OpenAPI specs from the OperationRegistry?
If alknet exposes operations as POST /v1/{namespace}/{op}, the HTTP interface could auto-generate an OpenAPI spec from the registered OperationSpecs. This would provide:
- Interactive API documentation
- Automatic client SDK generation
- Compatibility with
OpenAPIServiceRegistry(another alknet node'sFromOpenAPIcould register against this spec)
This is the reverse of OpenAPIServiceRegistry — instead of consuming an OpenAPI spec to register operations, it produces an OpenAPI spec from registered operations. The OperationSpec already has input_schema, output_schema, description, and tags.
Recommendation: Yes, but Phase 4+. The HTTP interface needs to exist first.
OQ-IF-07: How do self-hosted services (rustfs, gitea) authenticate requests from alknet users?
When alknet sits in front of rustfs or gitea (e.g., as a reverse proxy or HTTP interface gateway), how does it map alknet identities to external service identities?
Options:
- Shared secret / API key: Alknet holds a service-level credential. All proxied requests use it. Simple but loses per-user identity on the external service.
- Identity-bound credentials: Each alknet account has a corresponding rustfs/gitea credential, looked up via
Identity.id. Per-user ACL on the external service. - Alknet as OIDC provider: Rustfs/gitea trust alknet as their identity provider. No stored credentials — users authenticate directly via OIDC.
Recommendation: Start with Option 1. Add Option 2 when multi-tenant access is needed. Option 3 is the long-term goal (Phase D in credential-provider.md).
References
- interface.md — Current Interface layer spec (needs update for
StreamInterface/MessageInterface) - auth.md — Unified auth, IdentityProvider, AuthToken format
- identity.md — Identity struct, IdentityProvider trait
- call-protocol.md — Call protocol, OperationEnv
- services.md — irpc service definitions
- credential-provider.md — CredentialProvider, CredentialSet (Phase 2)
- ADR-026 — Three-layer model (needs update for
MessageInterface) - ADR-023 — Unified auth with shared key material
- ADR-029 — Identity as core type