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.
This commit is contained in:
466
docs/research/phase2/credential-provider.md
Normal file
466
docs/research/phase2/credential-provider.md
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
# Credential Provider: Outbound Service Authentication
|
||||||
|
|
||||||
|
> Status: Research / Draft
|
||||||
|
> Last updated: 2026-06-08
|
||||||
|
> Part of: Phase 2 planning
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Alknet's `IdentityProvider` resolves **inbound** authentication: who is making a request _to_ alknet. The `CredentialProvider` resolves **outbound** authentication: how alknet authenticates _to_ external and self-hosted services. This is a distinct and currently unaddressed concern that affects nearly every application service — from cloud API integrations (vast.ai, runpod, ubicloud) to self-hosted infrastructure (rustfs, gitea, postgres).
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### External API credentials
|
||||||
|
|
||||||
|
Cloud providers use simple auth patterns — API keys, bearer tokens, basic auth. The existing `SecretProtocol` (encrypt/decrypt via derived AES-256-GCM keys, defined in [secret-service.md](../../architecture/secret-service.md)) can store and retrieve these at rest. But the wiring between "decrypt a credential from storage" and "use it in an HTTP request" doesn't exist yet. Each service wrapper currently would have to independently solve credential retrieval, caching, and lifecycle.
|
||||||
|
|
||||||
|
### Self-hosted service auth
|
||||||
|
|
||||||
|
Self-hosted services use more complex auth mechanisms that go beyond static tokens:
|
||||||
|
|
||||||
|
- **rustfs** uses S3-style access key + secret key pairs with AWS Signature V4 request signing. They also support OIDC (OpenID Connect with PKCE). The access key/secret key aren't a bearer header — they're inputs to a per-request HMAC-SHA256 signature computation.
|
||||||
|
- **gitea** supports OAuth2, OIDC, and reverse proxy authentication (SSO via headers). Its internal user/token system is separate from alknet's identity model.
|
||||||
|
- Other self-hosted services (postgres, redis) may use their own auth schemes.
|
||||||
|
|
||||||
|
These services are **inside the operational domain** — their credential lifecycle (provisioning, rotation, revocation, token refresh) is part of running the stack, not a one-time configuration step.
|
||||||
|
|
||||||
|
### The gap
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
|
||||||
|
```
|
||||||
|
User → alknet → IdentityProvider (resolves who the user is) ✅ exists
|
||||||
|
alknet → external service → ??? (resolves how alknet authenticates) ❌ missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `CredentialProvider`, each service wrapper would:
|
||||||
|
1. Independently retrieve and decrypt credentials from the secret service
|
||||||
|
2. Independently implement auth mechanism specifics (bearer, S3 signing, OIDC refresh)
|
||||||
|
3. Have no shared infrastructure for credential lifecycle management
|
||||||
|
|
||||||
|
This leads to duplicated effort and inconsistent security practices across service wrappers.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### CredentialProvider Trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait CredentialProvider: Send + Sync + 'static {
|
||||||
|
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
|
||||||
|
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is intentionally narrow. It returns credentials for a named service. It does not try to abstract the auth mechanism itself — that stays with the service wrapper that knows the protocol.
|
||||||
|
|
||||||
|
### CredentialSet
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum CredentialSet {
|
||||||
|
ApiKey {
|
||||||
|
header_name: String,
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
Basic {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
Bearer {
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
S3AccessKey {
|
||||||
|
access_key: String,
|
||||||
|
secret_key: String,
|
||||||
|
session_token: Option<String>,
|
||||||
|
},
|
||||||
|
OidcToken {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
expires_at: Option<u64>,
|
||||||
|
},
|
||||||
|
Custom {
|
||||||
|
scheme: String,
|
||||||
|
params: HashMap<String, String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each variant carries the data needed for a specific auth mechanism. The service wrapper that requested the credentials knows what variant it expects and how to use it — the `OpenAPIServiceRegistry` knows it needs a `Bearer` or `ApiKey`, the rustfs S3 wrapper knows it needs `S3AccessKey` for request signing.
|
||||||
|
|
||||||
|
### CredentialProvider vs IdentityProvider
|
||||||
|
|
||||||
|
These are opposite-direction abstractions:
|
||||||
|
|
||||||
|
| | IdentityProvider | CredentialProvider |
|
||||||
|
|---|---|---|
|
||||||
|
| Direction | Inbound (who is calling alknet) | Outbound (how alknet calls others) |
|
||||||
|
| Resolves | Fingerprint/token → Identity | Service name → CredentialSet |
|
||||||
|
| Storage | `peer_credentials`, `api_keys` tables (alknet-storage) | Encrypted nodes in metagraph (via SecretProtocol) |
|
||||||
|
| Lifecycle | Stateless lookup | May need refresh (OIDC tokens, S3 sessions) |
|
||||||
|
| Location | `alknet_core::auth` | `alknet_core::credentials` |
|
||||||
|
|
||||||
|
Both live at the same architectural layer. A service handler receives an `OperationContext` with `identity` (who called us) and access to credentials through `context.env`. The handler doesn't interact with `CredentialProvider` directly in the common case — the service initialization code does, when setting up the HTTP client or SDK wrapper.
|
||||||
|
|
||||||
|
### Accounts: Storage-Layer Concern, Not Core
|
||||||
|
|
||||||
|
The `Identity` struct in core (`{ id, scopes, resources }`) does not need an explicit `account_id` field. In config-based auth (`ConfigIdentityProvider`), `id` is the SSH key fingerprint. In database-backed auth (`StorageIdentityProvider`), `id` is the account UUID. The account concept is an implementation detail of `StorageIdentityProvider` — it resolves `peer_credentials.fingerprint → account_id → Identity { id: account_uuid, ... }`. The same person authenticating via SSH key or bearer token gets the same `Identity { id: account_uuid, ... }` because both credential presentations map to the same account UUID in storage.
|
||||||
|
|
||||||
|
This means identity-bound credential lookups (e.g., "Alice's rustfs access key") use `Identity.id` (which is the account UUID in database-backed deployments) as the key — not a separate field. The call pattern is:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Service-level credential (no identity needed):
|
||||||
|
credential_provider.get_credentials("rustfs") // shared admin key
|
||||||
|
|
||||||
|
// Identity-bound credential (uses id as account identifier):
|
||||||
|
credential_provider.get_credentials_for("rustfs", &identity.id) // per-user key
|
||||||
|
```
|
||||||
|
|
||||||
|
The `CredentialProvider` trait at core only needs the service-level method. Identity-bound lookups are an extension in alknet-storage that uses the same `Identity.id`.
|
||||||
|
|
||||||
|
### Interaction with SecretProtocol
|
||||||
|
|
||||||
|
Credentials are stored encrypted in the metagraph via the existing `SecretProtocol`:
|
||||||
|
|
||||||
|
1. At setup time, an operator configures credentials for a service (e.g., `alknet credential add vast-ai --type bearer --token-file ./key.txt`)
|
||||||
|
2. The CLI encrypts the credential via `SecretProtocol::Encrypt` (using the derived encryption key at `m/74'/2'/0'/0'`)
|
||||||
|
3. The encrypted credential is stored as an `EncryptedData` node in the metagraph, tagged with the service name
|
||||||
|
4. At startup, `SecretStoreCredentialProvider` (the default `CredentialProvider` impl) calls `SecretProtocol::Decrypt` for each configured service
|
||||||
|
5. The decrypted credentials are held in RAM with the same lifecycle as the secret service (purged on `Lock`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SecretStoreCredentialProvider {
|
||||||
|
credentials: ArcSwap<HashMap<String, CredentialSet>>,
|
||||||
|
secret_client: Client<SecretProtocol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialProvider for SecretStoreCredentialProvider {
|
||||||
|
fn get_credentials(&self, service: &str) -> Option<CredentialSet> {
|
||||||
|
let cache = self.credentials.load();
|
||||||
|
cache.get(service).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet> {
|
||||||
|
// Re-decrypt from storage — used after Lock/Unlock cycle
|
||||||
|
// Calls secret_client.decrypt() and updates cache
|
||||||
|
None // simplified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interaction with OpenAPIServiceRegistry
|
||||||
|
|
||||||
|
The TypeScript `@alkdev/operations` `from_openapi.ts` defines `HTTPServiceConfig.auth`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
auth?: {
|
||||||
|
type: "bearer" | "apiKey" | "basic";
|
||||||
|
token?: string;
|
||||||
|
headerName?: string;
|
||||||
|
prefix?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rust port would populate this from `CredentialProvider`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let creds = credential_provider.get_credentials("vast-ai");
|
||||||
|
let auth = match creds {
|
||||||
|
Some(CredentialSet::Bearer { token }) => AuthConfig::Bearer { token },
|
||||||
|
Some(CredentialSet::ApiKey { header_name, token }) => AuthConfig::ApiKey { header_name, token },
|
||||||
|
Some(CredentialSet::Basic { username, password }) => AuthConfig::Basic { username, password },
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let config = HttpServiceConfig {
|
||||||
|
namespace: "vast-ai",
|
||||||
|
base_url: "https://cloud.vast.ai/api/v1",
|
||||||
|
auth,
|
||||||
|
..
|
||||||
|
};
|
||||||
|
let ops = FromOpenAPI(spec, config);
|
||||||
|
registry.register_all(ops);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self-Hosted Services: ManagedCredentialProvider
|
||||||
|
|
||||||
|
For self-hosted services, credentials may need active lifecycle management:
|
||||||
|
|
||||||
|
**rustfs (S3)**:
|
||||||
|
- Access key + secret key are created inside rustfs IAM
|
||||||
|
- The alknet rustfs service wrapper holds the `S3AccessKey` credential set
|
||||||
|
- Each S3 request is signed using AWS Signature V4 (computed from access_key + secret_key + request details)
|
||||||
|
- Session tokens from STS-style calls have a TTL and need rotation
|
||||||
|
- Provisioning: alknet could create the rustfs access key via the rustfs admin API at first setup, then store the resulting credentials
|
||||||
|
|
||||||
|
**rustfs (OIDC)**:
|
||||||
|
- rustfs supports OIDC providers — alknet's identity system _could_ act as an OIDC provider
|
||||||
|
- This would allow alknet identities to authenticate directly to rustfs without stored credentials
|
||||||
|
- Requires: alknet running an OIDC authorization server endpoint (potentially exposed via the call protocol)
|
||||||
|
|
||||||
|
**gitea (OAuth2/OIDC)**:
|
||||||
|
- Similar to rustfs OIDC — alknet could act as the OAuth2/OIDC provider
|
||||||
|
- Gitea supports reverse proxy auth (SSO via headers) — if alknet sits in front as a reverse proxy, it can inject auth headers
|
||||||
|
- Gitea also has its own API token system — simpler case, just store the token
|
||||||
|
|
||||||
|
**ManagedCredentialProvider** wraps these cases:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ManagedCredentialProvider {
|
||||||
|
base: SecretStoreCredentialProvider,
|
||||||
|
managers: HashMap<String, Arc<dyn CredentialManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CredentialManager: Send + Sync + 'static {
|
||||||
|
fn refresh(&self, current: &CredentialSet) -> Option<CredentialSet>;
|
||||||
|
fn is_expired(&self, current: &CredentialSet) -> bool;
|
||||||
|
fn provision(&self, identity: &Identity) -> Option<CredentialSet>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `refresh`: For OIDC token refresh, S3 session token rotation
|
||||||
|
- `is_expired`: Check TTL on tokens before use
|
||||||
|
- `provision`: Create credentials on a self-hosted service for a given alknet identity (e.g., create a rustfs access key for a new user)
|
||||||
|
|
||||||
|
### Identity-Bound Credentials
|
||||||
|
|
||||||
|
For self-hosted services where alknet manages the user accounts, there's a higher-order pattern:
|
||||||
|
|
||||||
|
1. An alknet `Identity` (resolved by `IdentityProvider`) needs access to a self-hosted service
|
||||||
|
2. `ManagedCredentialProvider::provision(identity)` creates the corresponding account on the external service
|
||||||
|
3. The resulting credentials are stored and associated with the alknet identity in the metagraph
|
||||||
|
4. When the identity makes a call through the operation registry, the handler can resolve their service-specific credentials using `Identity.id` as the account key
|
||||||
|
|
||||||
|
This bridges `IdentityProvider` and `CredentialProvider`:
|
||||||
|
|
||||||
|
```
|
||||||
|
IdentityProvider: who is this user? → Identity
|
||||||
|
CredentialProvider: how do we talk to service X? → CredentialSet
|
||||||
|
Identity-bound: how does THIS user talk to service X? → CredentialSet (scoped to Identity.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
The identity-bound case is important for multi-tenant self-hosted setups where different alknet users should have different access levels on rustfs or gitea. It can be deferred initially — Phase A only needs service-level credentials.
|
||||||
|
|
||||||
|
## Architectural Position
|
||||||
|
|
||||||
|
### Where CredentialProvider lives
|
||||||
|
|
||||||
|
`CredentialProvider` and `CredentialSet` are core types, analogous to `IdentityProvider` and `Identity`. They live in `alknet_core::credentials`.
|
||||||
|
|
||||||
|
Like `IdentityProvider`:
|
||||||
|
- The trait is in alknet-core
|
||||||
|
- The default impl (`SecretStoreCredentialProvider`) uses the secret service + metagraph
|
||||||
|
- Production impls (`ManagedCredentialProvider`) may live in alknet-storage or application crates
|
||||||
|
- The CLI/NAPI assembly wires the concrete impl
|
||||||
|
- Core does not depend on any storage system
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
alknet-core (CredentialProvider trait, CredentialSet enum)
|
||||||
|
↑
|
||||||
|
alknet-secret (SecretStoreCredentialProvider reads from SecretProtocol::Decrypt)
|
||||||
|
↑
|
||||||
|
Application crates (rustfs wrapper, gitea wrapper, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
`CredentialProvider` does not depend on `IdentityProvider`, but `ManagedCredentialProvider` may use `Identity.id` to resolve identity-bound credentials.
|
||||||
|
|
||||||
|
### Relationship to existing specs
|
||||||
|
|
||||||
|
| Existing concept | Relationship |
|
||||||
|
|---|---|
|
||||||
|
| `IdentityProvider` | Opposite direction. Identity is inbound auth. Credential is outbound auth. |
|
||||||
|
| `SecretProtocol` | Stores and retrieves encrypted credentials. `SecretStoreCredentialProvider` is a consumer of `SecretProtocol::Decrypt`. |
|
||||||
|
| `OperationEnv` | Service init code uses `CredentialProvider` to configure `HTTPServiceConfig.auth`. Handlers call operations through `env`. |
|
||||||
|
| `OpenAPIServiceRegistry` | Consumer of `CredentialProvider` — populates `auth` config from credential lookup. |
|
||||||
|
| `EncryptedData` | Wire format for stored credentials. Compatible with existing `EncryptedDataSchema` from `@alkdev/storage`. |
|
||||||
|
| `Identity.id` | In database-backed deployments, serves as the account UUID for identity-bound credential lookups. No separate `account_id` field needed — `id` IS the account identifier. |
|
||||||
|
|
||||||
|
### Account management is storage-layer, not core
|
||||||
|
|
||||||
|
The `AccountService` irpc protocol (CRUD for accounts and credential associations) lives in alknet-storage, not core. This follows the same pattern as `ConfigService`:
|
||||||
|
- Core has the read trait (`IdentityProvider`, `CredentialProvider`)
|
||||||
|
- Storage has the management service (`AccountProtocol`, `CredentialProtocol`)
|
||||||
|
- The CLI/NAPI assembly wires them together
|
||||||
|
|
||||||
|
The storage model for accounts:
|
||||||
|
|
||||||
|
```
|
||||||
|
accounts
|
||||||
|
├── id (UUID, primary key)
|
||||||
|
├── display_name
|
||||||
|
├── status (active, disabled)
|
||||||
|
└── default_scopes (JSON)
|
||||||
|
|
||||||
|
peer_credentials (inbound — SSH keys)
|
||||||
|
├── account_id → accounts.id
|
||||||
|
├── fingerprint (SHA-256 of public key)
|
||||||
|
├── public_key_data
|
||||||
|
└── scopes_override (JSON, null = use account default)
|
||||||
|
|
||||||
|
api_keys (inbound — bearer tokens)
|
||||||
|
├── account_id → accounts.id
|
||||||
|
├── key_prefix (first 8 chars, for lookup)
|
||||||
|
├── key_hash (SHA-256 of full key)
|
||||||
|
├── scopes (JSON)
|
||||||
|
└── expires_at
|
||||||
|
|
||||||
|
service_credentials (outbound — for external services)
|
||||||
|
├── id (UUID)
|
||||||
|
├── account_id → accounts.id (NULL = shared/service-level)
|
||||||
|
├── service_name
|
||||||
|
├── credential_type
|
||||||
|
├── encrypted_data → EncryptedData
|
||||||
|
├── metadata (JSON)
|
||||||
|
└── expires_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`StorageIdentityProvider` queries `peer_credentials` → `accounts` and `api_keys` → `accounts` to resolve any inbound credential to the same `Identity { id: account_uuid, ... }`. `StorageCredentialProvider` queries `service_credentials` and decrypts via `SecretProtocol` to resolve outbound credentials.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase A: Core types and simple credential storage
|
||||||
|
|
||||||
|
Define the trait and enum in alknet-core. Implement `SecretStoreCredentialProvider` that decrypts stored credentials at startup. Wire into the service assembly (CLI). This enables static API key / bearer token patterns — sufficient for cloud API integrations.
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- `CredentialProvider` trait + `CredentialSet` enum in `alknet_core::credentials`
|
||||||
|
- `SecretStoreCredentialProvider` impl (reads from `SecretProtocol::Decrypt`)
|
||||||
|
- CLI command: `alknet credential add <service> --type bearer --token-file <path>`
|
||||||
|
- Credential storage in metagraph as encrypted nodes tagged by service name
|
||||||
|
|
||||||
|
Depends on: Phase 1 (OperationEnv, SecretProtocol) + alknet-secret crate existing
|
||||||
|
|
||||||
|
### Phase B: OpenAPI/JSON Schema auto-registration
|
||||||
|
|
||||||
|
Port `FromOpenAPI` and `OpenAPIServiceRegistry` from the TypeScript `@alkdev/operations` to Rust. Integrate with `CredentialProvider` for auth config. This enables any OpenAPI-described service to be auto-registered as a set of operations.
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- `alknet-openapi` or `alknet-operations-adapter` crate with `from_openapi` module
|
||||||
|
- `FromOpenAPI(spec, config) -> Vec<(OperationSpec, Handler)>`
|
||||||
|
- `HttpServiceConfig` with auth populated from `CredentialProvider`
|
||||||
|
- `OpenAPIServiceRegistry::register_all(registry)` port
|
||||||
|
|
||||||
|
Depends on: Phase A + existing `OperationRegistry`
|
||||||
|
|
||||||
|
### Phase C: Managed credentials and self-hosted auth
|
||||||
|
|
||||||
|
Add `ManagedCredentialProvider` with `CredentialManager` trait. Implement S3 signing for rustfs. Implement OIDC token refresh. Enable identity-bound credential provisioning.
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- `CredentialManager` trait
|
||||||
|
- `ManagedCredentialProvider` impl
|
||||||
|
- S3CredentialManager (request signing, session token rotation)
|
||||||
|
- OidcCredentialManager (token refresh, PKCE flow)
|
||||||
|
- Identity-bound credential resolution (uses `Identity.id` as account key)
|
||||||
|
|
||||||
|
Depends on: Phase A + alknet-storage + application-specific knowledge
|
||||||
|
|
||||||
|
### Phase D: Alknet as OIDC/OAuth2 provider
|
||||||
|
|
||||||
|
Alknet's identity system could expose an OIDC authorization server endpoint. Self-hosted services (rustfs, gitea) would be configured to use alknet as their OIDC provider. This eliminates stored credential management entirely for the OIDC path — users authenticate directly through alknet's existing identity.
|
||||||
|
|
||||||
|
This is the most complex but also the most elegant path for self-hosted services. It makes alknet the identity backbone of the entire self-hosted stack.
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- OIDC authorization server operations (authorize, token, userinfo, jwks)
|
||||||
|
- Exposed via call protocol and/or HTTP adapter
|
||||||
|
- Configuration for rustfs/gitea to use alknet as OIDC provider
|
||||||
|
- Identity mapping: alknet Identity scopes → rustfs/gitea policies
|
||||||
|
|
||||||
|
Depends on: Phase C + call protocol HTTP or web adapter + significant R&D
|
||||||
|
|
||||||
|
## Analysis of Self-Hosted Auth Mechanisms
|
||||||
|
|
||||||
|
### rustfs
|
||||||
|
|
||||||
|
**S3 access key/secret key**:
|
||||||
|
- rustfs IAM manages users, groups, policies, and service accounts
|
||||||
|
- Credentials are `access_key` + `secret_key` pairs (S3 standard)
|
||||||
|
- Auth uses AWS Signature V4: HMAC-SHA256 of request details using the secret key
|
||||||
|
- Session tokens (from STS AssumeRole-style flows) are JWTs with claims including policy
|
||||||
|
- Access keys are created via the rustfs admin API or UI
|
||||||
|
|
||||||
|
**OIDC**:
|
||||||
|
- Full OpenID Connect support with PKCE
|
||||||
|
- Uses the `openidconnect` Rust crate for standards compliance
|
||||||
|
- Supports discovery, token exchange, ID token verification
|
||||||
|
- OIDC users are mapped to rustfs policies via claims
|
||||||
|
|
||||||
|
**Integration path**:
|
||||||
|
- Minimal: Store access key + secret key as `CredentialSet::S3AccessKey`, use for request signing
|
||||||
|
- Better: alknet as OIDC provider → no stored credentials, direct identity mapping
|
||||||
|
- Best: Phase D path where rustfs trusts alknet as its identity provider
|
||||||
|
|
||||||
|
### gitea
|
||||||
|
|
||||||
|
**Auth options**:
|
||||||
|
- OAuth2 provider (gitea can act as OAuth2 provider for other apps)
|
||||||
|
- OIDC client (gitea can delegate auth to an external OIDC provider — alknet in Phase D)
|
||||||
|
- Reverse proxy auth (SSO via HTTP headers — alknet injects `X-WebAuth-User` as a reverse proxy)
|
||||||
|
- API tokens (personal access tokens, scoped, with TTL)
|
||||||
|
- SSH keys (for git operations, separate from API auth)
|
||||||
|
|
||||||
|
**Integration path**:
|
||||||
|
- Minimal: Store gitea API token as `CredentialSet::Bearer`
|
||||||
|
- Intermediate: If alknet runs as a reverse proxy in front of gitea, inject auth headers
|
||||||
|
- Best: alknet as OIDC provider for gitea
|
||||||
|
|
||||||
|
### General pattern
|
||||||
|
|
||||||
|
For both rustfs and gitea, the auth integration follows the same progression:
|
||||||
|
|
||||||
|
1. **Static credentials** (Phase A): Store API keys/tokens, decrypt at startup. Simple, works for single-user or admin-only access.
|
||||||
|
2. **Dynamic credentials** (Phase C): Managed credential lifecycle — token refresh, session rotation. Needed for production.
|
||||||
|
3. **Identity federation** (Phase D): Alknet acts as the identity provider. No stored service credentials. Users authenticate through alknet and their identity (scopes, resources) maps to the external service's policy model. Most secure, most complex.
|
||||||
|
|
||||||
|
Phase D is not required to start building service wrappers. Phases A and C are sufficient for functional integrations. Phase D is a quality-of-life and security improvement that becomes important in multi-user self-hosted deployments.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### OQ-CP-01: Should CredentialProvider support per-identity credentials?
|
||||||
|
|
||||||
|
That is, should the trait be `get_credentials(service, identity)` instead of `get_credentials(service)`?
|
||||||
|
|
||||||
|
Pro: Enables multi-tenant self-hosted services where different alknet users have different access.
|
||||||
|
Con: More complex, and the identity resolution can be done by the service wrapper itself by looking up identity-bound credentials from the metagraph.
|
||||||
|
|
||||||
|
Recommendation: Start with service-level credentials. Add identity-level resolution as a second method (`get_credentials_for(service, account_id)`) when the need is concrete. Since `Identity.id` already serves as the account UUID in database-backed mode, there's no need for a separate `account_id` field.
|
||||||
|
|
||||||
|
### OQ-CP-02: Where should the OIDC provider operations live?
|
||||||
|
|
||||||
|
If alknet becomes an OIDC provider (Phase D), the authorization server endpoints need to live somewhere. Options:
|
||||||
|
|
||||||
|
1. In alknet-core behind a feature flag (like auth service)
|
||||||
|
2. In a new `alknet-oidc` crate
|
||||||
|
3. As an application service registered in the operation registry
|
||||||
|
|
||||||
|
Recommendation: Application service (option 3). OIDC is an application concern, not a core concern. The call protocol and `OperationRegistry` provide the transport; OIDC is just another set of operations.
|
||||||
|
|
||||||
|
### OQ-CP-03: How do credential rotations propagate across a cluster?
|
||||||
|
|
||||||
|
If a credential is rotated (e.g., S3 session token refreshed on the head node), how do worker nodes get the updated credential? Options:
|
||||||
|
|
||||||
|
1. Workers request fresh credentials on each use (always current, more secret service calls)
|
||||||
|
2. Push notification via honker stream (efficient, but adds cross-service event coupling)
|
||||||
|
3. Workers cache with TTL (simple, may briefly use stale credentials)
|
||||||
|
|
||||||
|
Recommendation: TTL-based caching with a refresh threshold. Workers call `CredentialProvider::get_credentials()` which checks `is_expired()` and calls `refresh_credentials()` if needed. The TTL is per-credential-type (e.g., 1 hour for S3 session tokens, no TTL for static API keys).
|
||||||
|
|
||||||
|
### OQ-CP-04: Should CredentialSet include request-signing capability?
|
||||||
|
|
||||||
|
For S3 auth, the credential set contains `access_key + secret_key`, but the actual HTTP request signing (AWS Signature V4) is a separate computation. Should `CredentialSet::S3AccessKey` include a signing method?
|
||||||
|
|
||||||
|
Recommendation: Keep `CredentialSet` as pure data. Add a separate `s3_sign(credential: &S3AccessKey, request: &HttpRequest) -> SignedRequest` utility function in the service wrapper or a shared `alknet-s3` utility crate. The `OpenAPIServiceRegistry` pattern already separates credentials from HTTP client behavior; signing is client behavior.
|
||||||
|
|
||||||
|
### OQ-CP-05: How does this relate to the HTTP service / AI SDK port?
|
||||||
|
|
||||||
|
The AI SDK port provides HTTP infrastructure (streaming, retries, SSE parsing, error handling). The `CredentialProvider` provides the auth config that the HTTP client consumes. They're separate concerns that compose: the HTTP service uses `CredentialProvider` to populate `auth` headers/tokens on outgoing requests, just as `OpenAPIServiceRegistry` does. The AI SDK's provider codegen (which would be replaced with the operation pattern) currently hardcodes auth per provider; `CredentialProvider` makes it dynamic and centrally managed instead.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [identity.md](../../architecture/identity.md) — IdentityProvider trait, Identity struct
|
||||||
|
- [secret-service.md](../../architecture/secret-service.md) — SecretProtocol, EncryptedData
|
||||||
|
- [services.md](../../architecture/services.md) — OperationEnv, OperationRegistry, service composition
|
||||||
|
- [call-protocol.md](../../architecture/call-protocol.md) — OperationEnv three dispatch paths
|
||||||
|
- [integration-plan.md](../integration-plan.md) — Phase structure, OperationEnv wiring
|
||||||
|
- [@alkdev/operations/src/from_openapi.ts](../../../@alkdev/operations/src/from_openapi.ts) — OpenAPIServiceRegistry, HTTPServiceConfig.auth
|
||||||
367
docs/research/phase2/interface-model.md
Normal file
367
docs/research/phase2/interface-model.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# 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](../../architecture/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](../../architecture/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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[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
|
||||||
|
|
||||||
|
```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>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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](../../architecture/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`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[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](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 auth** — `Authorization: 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 `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 `OperationSpec`s. 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](credential-provider.md)).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [interface.md](../../architecture/interface.md) — Current Interface layer spec (needs update for `StreamInterface`/`MessageInterface`)
|
||||||
|
- [auth.md](../../architecture/auth.md) — Unified auth, IdentityProvider, AuthToken format
|
||||||
|
- [identity.md](../../architecture/identity.md) — Identity struct, IdentityProvider trait
|
||||||
|
- [call-protocol.md](../../architecture/call-protocol.md) — Call protocol, OperationEnv
|
||||||
|
- [services.md](../../architecture/services.md) — irpc service definitions
|
||||||
|
- [credential-provider.md](credential-provider.md) — CredentialProvider, CredentialSet (Phase 2)
|
||||||
|
- [ADR-026](../../architecture/decisions/026-transport-interface-separation.md) — Three-layer model (needs update for `MessageInterface`)
|
||||||
|
- [ADR-023](../../architecture/decisions/023-unified-auth-shared-key-material.md) — Unified auth with shared key material
|
||||||
|
- [ADR-029](../../architecture/decisions/029-identity-core-type.md) — Identity as core type
|
||||||
401
docs/research/phase2/tls-transport.md
Normal file
401
docs/research/phase2/tls-transport.md
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
# TLS Transport: Unified Multi-Interface Architecture
|
||||||
|
|
||||||
|
> Status: Research / Draft
|
||||||
|
> Last updated: 2026-06-08
|
||||||
|
> Part of: Phase 2 planning
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Alknet's existing stealth mode already does protocol detection: after a TLS handshake, the server peeks at the first bytes and routes SSH connections one way and HTTP connections another. This document extends that pattern into a unified architecture where a single TLS port supports SSH, REST, WebSocket, SSE, and gRPC — all routed by the first bytes after the TLS handshake. Alongside this, QUIC (UDP) supports WebTransport and iroh P2P, and DNS runs on its own port. Every interface resolves to the same call protocol operations through the `OperationRegistry`.
|
||||||
|
|
||||||
|
This replaces the earlier `(Transport, Interface)` pair model for TCP/TLS connections with a clearer distinction: persistent stream interfaces go through the peek-based router, message-based interfaces manage their own transports, and axum serves as the multiplexer for everything HTTP.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The stealth mode implementation in `crates/alknet-core/src/server/stealth.rs` does byte-peeking after TLS handshake:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ProtocolDetection {
|
||||||
|
Ssh,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detect_protocol<S>(stream: S) -> (ProtocolDetection, BufReader<S>) {
|
||||||
|
// Peek first bytes: "SSH-2.0-" → Ssh, anything else → Http
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_fake_nginx_404<S>(reader: &mut BufReader<S>) {
|
||||||
|
// Currently: non-SSH gets a fake 404 and connection closed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is almost exactly what we need. The `Http` detection currently sends a fake nginx 404. Instead, it should route to a real HTTP server.
|
||||||
|
|
||||||
|
## New Architecture
|
||||||
|
|
||||||
|
### TCP TLS Port 443: Peek-Based Routing
|
||||||
|
|
||||||
|
```
|
||||||
|
Client connects to port 443
|
||||||
|
│
|
||||||
|
TLS handshake completes
|
||||||
|
│
|
||||||
|
Peek first bytes
|
||||||
|
│
|
||||||
|
├─ "SSH-2.0-" → SshInterface (russh, existing path)
|
||||||
|
│
|
||||||
|
└─ (anything else) → axum HTTP router
|
||||||
|
│
|
||||||
|
├─ POST /v1/{namespace}/{op} → registry.invoke()
|
||||||
|
├─ GET /v1/{namespace}/{op} → registry.invoke()
|
||||||
|
├─ GET /v1/{namespace}/{op} (SSE) → registry.subscribe()
|
||||||
|
├─ POST /v1/batch → batch invoke
|
||||||
|
├─ GET /v1/schema → registry.list_operations()
|
||||||
|
├─ WebSocket upgrade /ws → WebSocketInterface
|
||||||
|
├─ gRPC via tonic routes → tonic services
|
||||||
|
├─ GET /.well-known/alknet/schema → OpenAPI spec generation
|
||||||
|
└─ (anything else) → 404
|
||||||
|
```
|
||||||
|
|
||||||
|
The peek happens after TLS, so the client sees a valid HTTPS server. The `send_fake_nginx_404` function becomes `hand_to_axum(stream)`. axum handles everything that isn't SSH.
|
||||||
|
|
||||||
|
### UDP Port 443: QUIC with ALPN Routing
|
||||||
|
|
||||||
|
```
|
||||||
|
Client sends QUIC Initial to port 443 UDP
|
||||||
|
│
|
||||||
|
TLS 1.3 handshake with ALPN negotiation
|
||||||
|
│
|
||||||
|
├─ ALPN "h3" (WebTransport) → wtransport → RawFramingInterface
|
||||||
|
│ │
|
||||||
|
│ └─ SessionRequest → validate AuthToken
|
||||||
|
│ from URL path or headers
|
||||||
|
│ → OperationContext → call protocol
|
||||||
|
│
|
||||||
|
└─ ALPN "alknet" (iroh P2P) → iroh endpoint → RawFramingInterface
|
||||||
|
│
|
||||||
|
└─ existing iroh accept loop
|
||||||
|
→ SshInterface or RawFramingInterface
|
||||||
|
```
|
||||||
|
|
||||||
|
wtransport and iroh both listen on UDP 443. Quinn supports multiple ALPN protocols — the QUIC handshake negotiates which handler gets the connection.
|
||||||
|
|
||||||
|
### DNS Port 53: MessageInterface
|
||||||
|
|
||||||
|
```
|
||||||
|
DNS query arrives on port 53 (UDP or TCP)
|
||||||
|
│
|
||||||
|
├─ UDP query → DnsInterface (MessageInterface)
|
||||||
|
└─ TCP query → DnsInterface over DoT (TLS on port 853)
|
||||||
|
│
|
||||||
|
└─ Encode EventEnvelope as DNS TXT query
|
||||||
|
Decode response from DNS TXT record
|
||||||
|
AuthToken embedded in query labels
|
||||||
|
→ IdentityProvider::resolve_from_token()
|
||||||
|
→ OperationContext → call protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS is a `MessageInterface` — it manages its own transport and handles individual request/response pairs. It doesn't sit on top of the TLS peek router.
|
||||||
|
|
||||||
|
### Revised Routing Table
|
||||||
|
|
||||||
|
| Protocol | Transport | Detection | Interface | Auth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SSH | TCP/TLS | Byte peek: `SSH-2.0-` prefix | SshInterface | SSH key fingerprint |
|
||||||
|
| HTTP REST | TCP/TLS | Byte peek: not SSH → axum | axum handler → registry | `Authorization: Bearer <AuthToken>` |
|
||||||
|
| WebSocket | TCP/TLS | Axum upgrade: `Upgrade: websocket` | axum upgrade handler | AuthToken in handshake |
|
||||||
|
| SSE | TCP/TLS | Axum route: `Accept: text/event-stream` | axum handler → registry.subscribe() | AuthToken in header |
|
||||||
|
| gRPC | TCP/TLS | Axum route: `content-type: application/grpc` | tonic via axum router | AuthToken in header/metadata |
|
||||||
|
| WebTransport | QUIC (UDP) | ALPN `h3` | wtransport → RawFramingInterface | AuthToken in CONNECT URL |
|
||||||
|
| iroh P2P | QUIC (UDP) | ALPN `alknet` | iroh → RawFramingInterface | iroh's existing auth |
|
||||||
|
| DNS | UDP/TCP | Own listener | DnsInterface (MessageInterface) | AuthToken in query labels |
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Extending ProtocolDetection
|
||||||
|
|
||||||
|
The current `ProtocolDetection` enum gains variants for known HTTP sub-protocols:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProtocolDetection {
|
||||||
|
Ssh,
|
||||||
|
Http, // Any HTTP — axum handles sub-routing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This stays simple. SSH vs. not-SSH is the only peek-level decision. Everything else is HTTP-content routing inside axum. We don't need to detect WebSocket, SSE, or gRPC at the byte level — axum routes those by HTTP headers and paths.
|
||||||
|
|
||||||
|
The accept loop becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// After TLS handshake and peek:
|
||||||
|
match detect_protocol(tls_stream).await {
|
||||||
|
(ProtocolDetection::Ssh, reader) => {
|
||||||
|
// Existing SSH path: hand to SshInterface
|
||||||
|
handle_ssh(reader, config).await;
|
||||||
|
}
|
||||||
|
(ProtocolDetection::Http, reader) => {
|
||||||
|
// Hand to axum HTTP server
|
||||||
|
handle_http(reader, config).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Axum Integration
|
||||||
|
|
||||||
|
The axum server is an HTTP `Service` that receives the TLS stream after the peek. Since the TLS handshake is already complete, axum receives a plaintext stream:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn handle_http(stream: BufReader<TlsStream>, config: ServerConfig) {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/v1/{namespace}/{op}", post(invoke_operation))
|
||||||
|
.route("/v1/{namespace}/{op}", get(invoke_operation))
|
||||||
|
.route("/v1/batch", post(invoke_batch))
|
||||||
|
.route("/v1/schema", get(list_operations))
|
||||||
|
.route("/ws", get(websocket_upgrade))
|
||||||
|
// gRPC via tonic::Routes merged into axum router
|
||||||
|
.layer(ExtractorLayer::new(config.identity_provider, config.registry))
|
||||||
|
.layer(middleware::from_fn(auth_middleware));
|
||||||
|
|
||||||
|
// Serve the axum app on the TLS stream
|
||||||
|
hyper::server::conn::http1::Builder::new()
|
||||||
|
.serve_connection(TokioIo::new(stream), app.into_make_service())
|
||||||
|
.with_upgrades() // Enables WebSocket upgrades
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The auth middleware extracts the `Authorization: Bearer <token>` header and calls `IdentityProvider::resolve_from_token()`. The operation handler constructs an `OperationContext` and calls `registry.invoke(namespace, op, input)`.
|
||||||
|
|
||||||
|
### WebTransport (QUIC/UDP)
|
||||||
|
|
||||||
|
WebTransport runs on UDP alongside iroh. The routing is by ALPN during the QUIC handshake:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Quinn server config with two ALPN protocols:
|
||||||
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(tls_config));
|
||||||
|
server_config.alpn_protocols = vec![
|
||||||
|
WEBTRANSPORT_ALPN.to_vec(), // b"h3"
|
||||||
|
IROH_ALPN.to_vec(), // existing iroh ALPN
|
||||||
|
];
|
||||||
|
|
||||||
|
// Accept loop:
|
||||||
|
loop {
|
||||||
|
let incoming = quic_endpoint.accept().await;
|
||||||
|
match incoming.alpn() {
|
||||||
|
b"h3" => {
|
||||||
|
// Hand to wtransport
|
||||||
|
let session_request = IncomingSession::with_quic_incoming(incoming).await;
|
||||||
|
// Validate AuthToken from URL path/headers
|
||||||
|
// Create OperationContext
|
||||||
|
// Route to call protocol via RawFramingInterface or HTTP-like handler
|
||||||
|
}
|
||||||
|
b"alknet" | IROH_ALPN => {
|
||||||
|
// Hand to existing iroh accept loop
|
||||||
|
handle_iroh(incoming).await;
|
||||||
|
}
|
||||||
|
_ => { /* reject unknown ALPN */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
wtransport's `with_quic_incoming()` escape hatch allows integrating with an externally managed Quinn endpoint, so alknet owns the Quinn `Endpoint` and routes WebTransport sessions to wtransport.
|
||||||
|
|
||||||
|
### Auth: Single Token Mechanism
|
||||||
|
|
||||||
|
Every interface except SSH uses the same `AuthToken` format defined in auth.md:
|
||||||
|
|
||||||
|
```
|
||||||
|
AuthToken = base64url(key_id || timestamp || signature)
|
||||||
|
key_id = SHA-256 fingerprint of the Ed25519 public key (32 bytes)
|
||||||
|
timestamp = Unix seconds, big-endian u64 (8 bytes)
|
||||||
|
signature = Ed25519 sign(key_id || timestamp_bytes, private_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Interface | Auth mechanism | Token location |
|
||||||
|
|---|---|---|
|
||||||
|
| SSH | SSH key handshake | In SSH protocol (not a token) |
|
||||||
|
| HTTP REST | `Authorization: Bearer <AuthToken>` | HTTP header |
|
||||||
|
| WebSocket | AuthToken in first message or query param | After upgrade |
|
||||||
|
| SSE | `Authorization: Bearer <AuthToken>` | HTTP header |
|
||||||
|
| gRPC | `Authorization: Bearer <AuthToken>` | HTTP/2 metadata |
|
||||||
|
| WebTransport | AuthToken in CONNECT URL or header | WebTransport session request |
|
||||||
|
| DNS | AuthToken embedded in DNS query labels | Encoded in domain name |
|
||||||
|
|
||||||
|
All token-based paths call `IdentityProvider::resolve_from_token()`. The `resolve_from_token()` implementation handles Ed25519 signature verification (for AuthTokens) and will also handle hash-verified API keys (shorter tokens for simpler integrations).
|
||||||
|
|
||||||
|
For services and automation where Ed25519 key pairs are inconvenient, short API keys work:
|
||||||
|
|
||||||
|
```
|
||||||
|
API key: "alk_dGhlX3NlY3JldA" (~20 chars)
|
||||||
|
Storage: SHA-256 hash of the full key
|
||||||
|
Lookup: prefix match → hash verification → Identity
|
||||||
|
```
|
||||||
|
|
||||||
|
API keys are specified in `DynamicConfig.auth` or stored in `api_keys` tables (database-backed). Both AuthTokens and API keys go through the same `resolve_from_token()` method — the implementation discriminates by prefix or format.
|
||||||
|
|
||||||
|
### Contract Pattern: call / batch / schema / subscribe
|
||||||
|
|
||||||
|
Every interface exposes the same four primitive operations through `OperationRegistry`:
|
||||||
|
|
||||||
|
| Primitive | HTTP | MCP | DNS | Call protocol |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `call(namespace, op, input)` | `POST /v1/{ns}/{op}` | `tools/call` | `{op}.{ns}.alk.dev TXT?` | `call.requested` |
|
||||||
|
| `batch([{ns, op, input}, ...])` | `POST /v1/batch` | (multiple `tools/call`) | (multiple queries) | (multiple `call.requested`) |
|
||||||
|
| `schema(namespace?)` | `GET /v1/schema` | `tools/list` | (not typically) | `call.requested` with special op |
|
||||||
|
| `subscribe(namespace, op, input)` | `GET /v1/{ns}/{op} SSE` | (future) | (not applicable) | `call.requested` with stream flag |
|
||||||
|
|
||||||
|
MCP's four core operations map directly:
|
||||||
|
- `tools/list` → `schema()`
|
||||||
|
- `tools/call` → `call()`
|
||||||
|
- `prompts/list` → `schema("prompts")`
|
||||||
|
- `prompts/get` → `call("prompts", "get", input)`
|
||||||
|
|
||||||
|
The `memory` tool pattern (one namespace gate dispatching to many operations behind it) is exactly `OperationRegistry` with `OperationSpec.access_control`:
|
||||||
|
|
||||||
|
```
|
||||||
|
memory({tool:"help"}) → registry.invoke("memory", "help", {})
|
||||||
|
memory({tool:"search"}) → registry.invoke("memory", "search", {query: "..."})
|
||||||
|
memory({tool:"store"}) → registry.invoke("memory", "store", {key: "...", value: "..."})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse: OpenAPI Spec Generation
|
||||||
|
|
||||||
|
The HTTP interface's `GET /v1/schema` endpoint (or `GET /.well-known/alknet/schema`) auto-generates an OpenAPI spec from the registered `OperationSpec`s. This creates a symmetry with `FromOpenAPI`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound: HTTP request → axum handler → registry.invoke(namespace, op, input) → ResponseEnvelope → HTTP response
|
||||||
|
Outbound: OpenAPI spec → FromOpenAPI(spec, config) → registry.register_all(operations) → HTTP client → external service
|
||||||
|
```
|
||||||
|
|
||||||
|
Node A's HTTP interface produces an OpenAPI spec. Node B's `FromOpenAPI` consumes it. Alknet nodes can discover each other's capabilities via the schema endpoint.
|
||||||
|
|
||||||
|
## Relationship to StreamInterface / MessageInterface
|
||||||
|
|
||||||
|
The earlier `interface-model.md` research defined `StreamInterface` and `MessageInterface` traits. This doc refines the architecture:
|
||||||
|
|
||||||
|
**StreamInterface** — persistent byte stream, used for SSH and raw framing:
|
||||||
|
- `SshInterface`: (TLS, SSH) — existing path, unchanged
|
||||||
|
- `RawFramingInterface`: (TCP/TLS, raw framing) — for local mesh
|
||||||
|
- `RawFramingInterface`: (iroh/QUIC, raw framing) — for P2P mesh
|
||||||
|
|
||||||
|
**MessageInterface** — manages its own transport, handles individual requests:
|
||||||
|
- `DnsInterface`: Runs its own DNS server on port 53
|
||||||
|
|
||||||
|
**The HTTP case** is special. The axum router is not a `MessageInterface` in the same sense as DNS. It receives a stream (the TLS connection after peek), but it handles individual requests within that stream. It's better modeled as:
|
||||||
|
|
||||||
|
- A `StreamInterface` that internally routes to axum
|
||||||
|
- Axum is the implementation detail, not a trait boundary
|
||||||
|
- The call protocol handler receives `InterfaceRequest` and returns `InterfaceResponse` regardless of whether the request came from HTTP, DNS, SSH, or raw framing
|
||||||
|
|
||||||
|
The `InterfaceRequest` / `InterfaceResponse` types from `interface-model.md` still make sense as the normalized interface-agnostic request/response that all interfaces produce:
|
||||||
|
|
||||||
|
```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>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
But the HTTP implementation doesn't need to construct `InterfaceRequest` explicitly — it constructs `OperationContext` directly from the axum request and calls `registry.invoke()`. The `InterfaceRequest` abstraction is more useful for DNS where there's no framework doing routing for you.
|
||||||
|
|
||||||
|
## ListenerConfig Update
|
||||||
|
|
||||||
|
The `ListenerConfig` enum from the integration plan gains a `Http` variant alongside existing `Stream`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ListenerConfig {
|
||||||
|
Stream {
|
||||||
|
transport: TransportKind,
|
||||||
|
interface: StreamInterfaceKind,
|
||||||
|
},
|
||||||
|
Http {
|
||||||
|
bind_addr: SocketAddr,
|
||||||
|
tls: bool, // true = TLS, false = plain TCP
|
||||||
|
stealth: bool, // true = byte-peek protocol detection
|
||||||
|
},
|
||||||
|
Dns {
|
||||||
|
bind_addr: SocketAddr,
|
||||||
|
tls: bool, // true = DoT, false = plain DNS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum StreamInterfaceKind {
|
||||||
|
Ssh,
|
||||||
|
RawFraming,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum TransportKind {
|
||||||
|
Tcp,
|
||||||
|
Tls { server_name: Option<String> },
|
||||||
|
Iroh { endpoint_id: String },
|
||||||
|
// NO Dns variant — DNS is a MessageInterface, not a Transport
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For the common production deployment on port 443:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[listeners]]
|
||||||
|
type = "stream"
|
||||||
|
transport = { tls = {} }
|
||||||
|
interface = "ssh"
|
||||||
|
bind = "0.0.0.0:443"
|
||||||
|
|
||||||
|
[[listeners]]
|
||||||
|
type = "http"
|
||||||
|
bind = "0.0.0.0:443"
|
||||||
|
tls = true
|
||||||
|
stealth = true
|
||||||
|
|
||||||
|
# If separate ports are preferred:
|
||||||
|
[[listeners]]
|
||||||
|
type = "http"
|
||||||
|
bind = "0.0.0.0:8080"
|
||||||
|
tls = false
|
||||||
|
stealth = false
|
||||||
|
```
|
||||||
|
|
||||||
|
When `stealth = true` on an HTTP listener sharing a port with an SSH listener, the accept loop uses the byte-peek pattern to route connections to the correct handler.
|
||||||
|
|
||||||
|
When the HTTP listener is on its own port, no peeking is needed — everything is HTTP.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
| Work | Phase | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Extend `ProtocolDetection` to route `Http` to axum | Phase 1 (now) | Replace `send_fake_nginx_404` with axum handoff |
|
||||||
|
| Axum HTTP server with `/v1/{ns}/{op}` routes | Phase 1 (now) | Core REST API for call protocol operations |
|
||||||
|
| Auth middleware (`Authorization: Bearer`) | Phase 1 (now) | Uses existing `IdentityProvider::resolve_from_token()` |
|
||||||
|
| `ListenerConfig::Http` variant | Phase 1 (now) | Define alongside existing `Stream` variant |
|
||||||
|
| Remove `TransportKind::Dns` | Phase 1 (now) | Cleanup before code depends on it |
|
||||||
|
| WebSocket upgrade handler | Phase 2 | axum `.with_upgrades()` is already available |
|
||||||
|
| SSE streaming handler | Phase 2 | axum + `axum-streams` or `tokio-stream` |
|
||||||
|
| gRPC via tonic integration | Phase 3 | `tonic::Routes` merges into axum router |
|
||||||
|
| WebTransport (QUIC/UDP) | Phase 3 | wtransport integration, ALPN routing |
|
||||||
|
| DNS interface | Phase 3+ | Uses `MessageInterface` trait, own listener |
|
||||||
|
| OpenAPI spec generation from registry | Phase 3+ | `GET /v1/schema` or `GET /.well-known/alknet/schema` |
|
||||||
|
| ALPN multiplexing on UDP 443 | Phase 3+ | Quinn ALPN routing between iroh and wtransport |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [stealth.rs](../../../crates/alknet-core/src/server/stealth.rs) — Current protocol detection implementation
|
||||||
|
- [auth.md](../../architecture/auth.md) — AuthToken format, IdentityProvider, unified auth
|
||||||
|
- [interface-model.md](interface-model.md) — StreamInterface / MessageInterface trait design
|
||||||
|
- [credential-provider.md](credential-provider.md) — CredentialProvider, outbound auth
|
||||||
|
- [call-protocol.md](../../architecture/call-protocol.md) — OperationRegistry, OperationEnv
|
||||||
|
- [services.md](../../architecture/services.md) — irpc service definitions, OperationContext
|
||||||
|
- [ADR-026](../../architecture/decisions/026-transport-interface-separation.md) — Three-layer model
|
||||||
|
- [wtransport](/workspace/wtransport/) — WebTransport server implementation (QUIC/HTTP3, ALPN h3)
|
||||||
|
- [iroh-relay](/workspace/iroh/iroh-relay/) — HTTP + WebSocket relay (hyper, MaybeTlsStream)
|
||||||
|
- [hickory-dns](/workspace/hickory-dns/) — DNS server with DoT/DoH/DoQ/DoH3
|
||||||
|
- [tonic](/workspace/tonic/) — gRPC framework (axum + hyper integration, ALPN h2)
|
||||||
Reference in New Issue
Block a user