Files
alknet/docs/research/phase2/interface-model.md
glm-5.1 a107aebeb7 Add Phase 2 research: credential provider, interface model, and TLS transport architecture
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.
2026-06-08 10:37:20 +00:00

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: StreamInterface produces a session from a stream. MessageInterface handles 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: StreamInterface receives a TransportStream from elsewhere. MessageInterface manages 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 OpenAPIServiceRegistry to 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:

  1. Rename Interface to StreamInterface — the current trait becomes the stream-specific variant.
  2. Add MessageInterface trait — for HTTP, DNS, WebSocket.
  3. Add HttpInterface as a MessageInterface implementation.
  4. Clarify DNS — DNS is a MessageInterface, not a (DNS transport, raw framing) pair. Remove TransportKind::Dns from the transport enum.
  5. Add valid message-based interface pairs table alongside the stream-based pairs table.
  6. Add InterfaceRequest / InterfaceResponse types that normalize calls across message interfaces.

auth.md

Needs revision:

  1. Add HTTP interface authAuthorization: Bearer <token> extraction.
  2. Add DNS interface auth — token embedded in DNS query labels.
  3. Add auth presentation table showing all interface/auth combos.
  4. 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 InterfaceStreamInterface 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's FromOpenAPI could 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:

  1. 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.
  2. Identity-bound credentials: Each alknet account has a corresponding rustfs/gitea credential, looked up via Identity.id. Per-user ACL on the external service.
  3. 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