diff --git a/tasks/integration/phase2/api-keys-dynamic-config.md b/tasks/integration/phase2/api-keys-dynamic-config.md new file mode 100644 index 0000000..3ea0a5d --- /dev/null +++ b/tasks/integration/phase2/api-keys-dynamic-config.md @@ -0,0 +1,73 @@ +--- +id: api-keys-dynamic-config +name: Add API keys to DynamicConfig.auth and extend IdentityProvider token resolution +status: pending +depends_on: [credential-provider-trait] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Add `[[auth.api_keys]]` support to `DynamicConfig` and extend `ConfigIdentityProvider::resolve_from_token()` to verify API keys alongside existing AuthTokens. API keys are shorter, simpler bearer strings (hash-verified, with optional TTL and scopes) for service accounts and automation — they don't require Ed25519 key pairs like AuthTokens do. + +Per ADR-037 and research/phase2/interface-model.md (Config section): + +**API Key format**: `alk__` (or similar). Storage uses SHA-256 hash of the full key. Lookup is by prefix (first N characters), then hash verification of the full key. + +**Config format**: +```toml +[[auth.api_keys]] +prefix = "alk_dGhl" +hash = "sha256:abc123..." +scopes = ["relay:connect"] +description = "dashboard service account" +``` + +**Key changes**: +- Add `ApiKeyEntry` struct: `prefix`, `hash`, `scopes`, `description`, `optional ttl/expires_at` +- Add `api_keys: Vec` to `AuthPolicy` (or a separate section on `DynamicConfig`) +- Extend `ConfigIdentityProvider::resolve_from_token()` to check API keys: prefix match → hash verification → return `Identity` +- API keys produce `Identity { id: "", scopes: , resources: {} }` +- The `AuthToken` path (Ed25519 signed timestamp) is unchanged — both go through the same `resolve_from_token()` method, discriminated by format/prefix + +**Why this is Phase 2**: The HTTP interface (task 2.7) needs bearer token auth, and API keys are the simplest mechanism for `IdentityProvider::resolve_from_token()`. Without this, HTTP auth has no config-based auth mechanism. + +## Acceptance Criteria + +- [ ] `ApiKeyEntry` struct defined with `prefix`, `hash`, `scopes`, `description`, `expires_at: Option` fields +- [ ] `AuthPolicy` gains an `api_keys: Vec` field (or `DynamicConfig` gains a separate `api_keys` section) +- [ ] `ConfigIdentityProvider::resolve_from_token()` checks API keys: matches prefix, verifies SHA-256 hash of the full token, returns `Identity` on success +- [ ] API key lookup: tokens starting with `alk_` (or configured prefix) are treated as API keys; others go through the `AuthToken` verification path +- [ ] Expired API keys (where `expires_at` is set and in the past) are rejected +- [ ] API key scopes propagate to the returned `Identity.scopes` field +- [ ] `DynamicConfig::default()` includes an empty `api_keys` list (no behavioral change) +- [ ] `ConfigReloadHandle` reloads API keys along with the rest of `AuthPolicy` +- [ ] Unit test: valid API key authenticates via `resolve_from_token()` +- [ ] Unit test: expired API key is rejected +- [ ] Unit test: wrong hash is rejected +- [ ] Unit test: unknown prefix is rejected (falls through to AuthToken path) +- [ ] Unit test: API key scopes appear in the resolved `Identity` +- [ ] All existing auth tests continue to pass (no behavioral change for SSH key auth) + +## References + +- docs/architecture/decisions/037-api-keys-dynamic-config.md — ADR-037 +- docs/research/phase2/interface-model.md — API keys in config, auth table +- docs/research/integration-plan.md — Phase 2.6 +- crates/alknet-core/src/config/dynamic_config.rs — DynamicConfig, AuthPolicy +- crates/alknet-core/src/auth/identity.rs — ConfigIdentityProvider, IdentityProvider trait + +## Notes + +> The prefix match approach means we don't store the full API key in config — just the first ~8 chars for fast lookup and the SHA-256 hash for verification. This mirrors how GitHub/personal access tokens work. + +> Consider whether `api_keys` should live on `AuthPolicy` or be a separate section. Putting it on `AuthPolicy` keeps all auth-related config together and ensures atomic reloads. The `ConfigIdentityProvider` already has access to `Arc>` so it can read both `authorized_keys` and `api_keys` from the same reload. + +> The `resolve_from_token()` method currently takes `&AuthToken` — API keys are NOT AuthTokens (they're simple bearer strings). The method signature may need to accept a generic `&str` or a new enum that can be either an AuthToken string or an API key string. Alternatively, `resolve_from_token()` can accept `&str` and internally discriminate by prefix/format. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/axum-http-router-scaffold.md b/tasks/integration/phase2/axum-http-router-scaffold.md new file mode 100644 index 0000000..c097c24 --- /dev/null +++ b/tasks/integration/phase2/axum-http-router-scaffold.md @@ -0,0 +1,69 @@ +--- +id: axum-http-router-scaffold +name: Axum HTTP router scaffold with auth middleware and stealth handoff +status: pending +depends_on: [api-keys-dynamic-config, listenconfig-http-dns-stubs] +scope: moderate +risk: low +impact: component +level: implementation +--- + +## Description + +Create an axum HTTP router scaffold behind the `http` feature flag, with auth middleware that extracts `Authorization: Bearer ` and calls `IdentityProvider::resolve_from_token()`, and a stealth mode handoff that replaces `send_fake_nginx_404` with routing detected HTTP traffic to the axum router. + +Per the integration plan section 2.7 and research/phase2/tls-transport.md: + +This task creates the structural scaffold for HTTP — auth middleware and stealth handoff only. No operational routes (no `POST /v1/{namespace}/{op}` handlers). The question of how HTTP paths map to operation invocations is intentionally deferred to Phase 5. + +**Key components**: +1. **Auth middleware**: Extract `Authorization: Bearer ` from HTTP request headers. Call `IdentityProvider::resolve_from_token()`. Attach resolved `Identity` to request extensions. Reject with 401 if token is missing or invalid. Both AuthTokens (Ed25519 signed) and API keys (hash-verified) go through this path. +2. **Stealth handoff**: When `ListenerConfig::Http { stealth: true }`, replace `send_fake_nginx_404` with routing the detected-HTTP `BufReader` to the axum router. The existing `ProtocolDetection` enum already has `Ssh` vs `Http` — the `Http` path currently sends a fake 404 and disconnects. +3. **Default 404 handler**: Any unmatched route returns 404. No `/v1/*` routes are registered yet. +4. **Dependency**: Add `axum` dependency behind `http` feature flag in `Cargo.toml`. + +**Current state**: +- `stealth.rs` has `detect_protocol()` returning `ProtocolDetection::Ssh` or `ProtocolDetection::Http` +- `send_fake_nginx_404()` currently sends a fake nginx 404 response +- No `axum` dependency exists yet +- `IdentityProvider::resolve_from_token()` exists (will be extended with API keys by task 2.6) + +## Acceptance Criteria + +- [ ] `axum` dependency added to `Cargo.toml` behind `http` feature flag +- [ ] `crates/alknet-core/src/http/` module created (behind `http` feature flag) +- [ ] Auth middleware function: extracts `Authorization: Bearer `, calls `IdentityProvider::resolve_from_token()`, attaches `Identity` to axum request extensions, returns 401 on missing/invalid token +- [ ] Auth middleware supports both AuthTokens and API keys (via `resolve_from_token()` which dispatches based on format/prefix) +- [ ] Stealth handoff: `stealth.rs` `send_fake_nginx_404` replaced with axum router handoff when `http` feature is enabled. When `http` feature is disabled, the fake 404 behavior remains. +- [ ] Default 404 handler for unmatched routes (returns `404 Not Found`) +- [ ] Axum `Router` scaffold constructed with auth middleware layer and default 404 fallback +- [ ] `HttpInterface` struct from task 1 (stream/message interface split) gets its internal `Router` reference and `IdentityProvider` wired +- [ ] `http` feature flag in `Cargo.toml` correctly gates the `axum` dependency and `http` module +- [ ] Unit test: auth middleware extracts bearer token from `Authorization` header +- [ ] Unit test: auth middleware returns 401 for missing token +- [ ] Unit test: auth middleware returns 401 for invalid token +- [ ] Unit test: auth middleware attaches `Identity` to request extensions on valid token +- [ ] Integration test: stealth mode detection routes HTTP traffic to axum (not fake 404) +- [ ] All existing server/stealth tests continue to pass (no behavioral change when `http` feature is disabled) + +## References + +- docs/research/integration-plan.md — Phase 2.7 +- docs/research/phase2/tls-transport.md — Axum integration, stealth handoff, auth middleware +- crates/alknet-core/src/server/stealth.rs — Current ProtocolDetection, send_fake_nginx_404 +- crates/alknet-core/src/auth/identity.rs — IdentityProvider::resolve_from_token() + +## Notes + +> The integration plan explicitly states: "No operational routes yet — the question of how HTTP paths map to operation invocations depends on the from_openapi / spec-generation work and is deferred to Phase 5." This task is a scaffold: auth middleware, stealth handoff, default 404. Full route registrations come later. + +> For the stealth handoff, consider a compile-time approach: the `http` feature flag determines whether `send_fake_nginx_404` or the axum handoff is used. When `http` is disabled, the existing fake 404 behavior should remain unchanged. + +> The axum router is created per-server (not per-request). It holds references to the `IdentityProvider` and `OperationEnv`/`OperationRegistry`. + +> `send_fake_nginx_404` should NOT be deleted — just conditionally bypassed when the `http` feature is enabled and a `ListenerConfig::Http` listener is configured. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/credential-provider-trait.md b/tasks/integration/phase2/credential-provider-trait.md new file mode 100644 index 0000000..93ebc9e --- /dev/null +++ b/tasks/integration/phase2/credential-provider-trait.md @@ -0,0 +1,61 @@ +--- +id: credential-provider-trait +name: Define CredentialProvider trait, CredentialSet enum, and ConfigCredentialProvider implementation +status: pending +depends_on: [stream-interface-message-interface-split] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Define the `CredentialProvider` trait and `CredentialSet` enum in `alknet_core::credentials`, implementing the outbound authentication abstraction that complements the inbound `IdentityProvider`. This is the "Phase A" / "Phase 2.4a" work — the trait and enum must exist in core before alknet-secret (Phase 3) can wire `SecretStoreCredentialProvider` against them. + +Per ADR-036 and research/phase2/credential-provider.md: + +- `CredentialProvider` resolves **outbound** credentials: "how does alknet authenticate TO external services?" +- `CredentialSet` is a structured enum of credential types: `ApiKey`, `Basic`, `Bearer`, `S3AccessKey`, `OidcToken`, `Custom` +- `ConfigCredentialProvider` reads API keys and static credentials from `DynamicConfig` — the Phase 2 default (simple, no secret service dependency) +- `SecretStoreCredentialProvider` is a **stub** that returns `None` for all lookups until Phase 3 provides the alknet-secret dependency +- Wire `CredentialProvider` into `OperationEnv`/`OperationContext` so handlers can access credentials + +**Relationship to IdentityProvider**: These are opposite-direction abstractions. `IdentityProvider` resolves inbound auth (who is calling alknet). `CredentialProvider` resolves outbound auth (how alknet calls others). Both live at the same architectural layer. + +**Relationship to OperationEnv**: Handlers compose through `context.env`. The `OperationEnv` needs access to `CredentialProvider` so that handlers calling external services can resolve credentials. This could be a dedicated field on `OperationContext` or accessible through the `env` — the implementation detail is flexible, but the behavioral contract must match: given a service name, return credentials for that service. + +## Acceptance Criteria + +- [ ] `CredentialProvider` trait defined in `crates/alknet-core/src/credentials/mod.rs` with `get_credentials(&self, service: &str) -> Option` and `refresh_credentials(&self, service: &str) -> Option` +- [ ] `CredentialSet` enum defined with variants: `ApiKey { header_name, token }`, `Basic { username, password }`, `Bearer { token }`, `S3AccessKey { access_key, secret_key, session_token }`, `OidcToken { access_token, refresh_token, expires_at }`, `Custom { scheme, params }` +- [ ] `ConfigCredentialProvider` struct implemented — reads credentials from `DynamicConfig.auth` (or a new `DynamicConfig.credentials` section). For Phase 2, this is a simple config-backed lookup returning `CredentialSet::Bearer` or `CredentialSet::ApiKey` entries. +- [ ] `SecretStoreCredentialProvider` struct defined as a stub — `get_credentials()` always returns `None`. Full implementation deferred to Phase 3. +- [ ] `CredentialProvider` wired into `OperationContext` or `OperationEnv` so handlers can access outbound credentials +- [ ] `credentials` module re-exported from `crates/alknet-core/src/lib.rs` +- [ ] Unit test: `ConfigCredentialProvider` returns configured credentials for a service name +- [ ] Unit test: `ConfigCredentialProvider` returns `None` for unknown service names +- [ ] Unit test: `SecretStoreCredentialProvider` stub returns `None` for all service names +- [ ] Unit test: `OperationEnv`/`OperationContext` provides access to `CredentialProvider` from handler context +- [ ] `CredentialSet` derives `Clone`, `Debug`, `serde::Serialize`, `serde::Deserialize` + +## References + +- docs/architecture/decisions/036-credentialprovider-core-type.md — ADR-036 +- docs/research/phase2/credential-provider.md — Full design rationale +- docs/research/integration-plan.md — Phase 2.4 +- crates/alknet-core/src/auth/identity.rs — IdentityProvider (opposite direction, same pattern) +- crates/alknet-core/src/call/env.rs — OperationEnv +- crates/alknet-core/src/call/context.rs — OperationContext + +## Notes + +> This task is "2.4a" — the core types and config-backed implementation. "2.4b" (SecretStoreCredentialProvider backed by SecretProtocol::Decrypt) is deferred to Phase 3 when alknet-secret exists. + +> For `ConfigCredentialProvider`, consider whether to add a `[[credentials]]` section to `DynamicConfig` or to reuse a subsection. The simplest Phase 2 approach is a new `credentials: HashMap` field on `DynamicConfig` that stores static bearer tokens/API keys from config. + +> The `OperationEnv`/`OperationContext` wiring can follow either pattern: (a) a `credential_provider: Arc` field on `OperationContext`, or (b) `CredentialProvider` accessible through a registry-style `env.credentials(service)` method. The integration plan says "wire into OperationEnv so handlers can access credentials through context.env" — approach (b) aligns with the OperationEnv composition model. This is an implementation detail to resolve during implementation. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/listenconfig-http-dns-stubs.md b/tasks/integration/phase2/listenconfig-http-dns-stubs.md new file mode 100644 index 0000000..61b7105 --- /dev/null +++ b/tasks/integration/phase2/listenconfig-http-dns-stubs.md @@ -0,0 +1,76 @@ +--- +id: listenconfig-http-dns-stubs +name: Update ListenerConfig with Http/Dns variants, add TransportKind::WebTransport tag, restructure InterfaceConfig +status: pending +depends_on: [stream-interface-message-interface-split] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Add `ListenerConfig::Http` and `ListenerConfig::Dns` variants for message-based interfaces, add `TransportKind::WebTransport` as a tag-only variant, and restructure `InterfaceConfig` into `StreamInterfaceConfig` and `MessageInterfaceConfig` to align with the `StreamInterface`/`MessageInterface` split. + +Per the integration plan section 2.5 and research/phase2/interface-model.md and tls-transport.md: + +**Current state**: +- `ListenerConfig` likely has a single variant or is not yet fully defined (Phase 1 added `TransportKind` variants and `InterfaceConfig` but the `ListenerConfig` may need updating) +- `TransportKind` has `Tcp`, `Tls`, `Iroh` — no `Dns` (correctly), no `WebTransport` +- `InterfaceConfig` has `Ssh(SshInterfaceConfig)` and `RawFraming(RawFramingConfig)` — needs restructuring to `StreamInterfaceConfig` + +**Key changes**: +- Add `TransportKind::WebTransport` variant — tag-only, no acceptor implementation. This is a trivial addition that prevents a breaking change later when WebTransport lands in Phase 5. +- Confirm `TransportKind::Dns` is NOT in the enum (DNS is a `MessageInterface`, not a transport). If it somehow got added, remove it. (Research confirms it was never added.) +- Rename `InterfaceConfig` → `StreamInterfaceConfig` (aligned with the trait rename from task 1) +- Add `StreamInterfaceConfig::Ssh` and `StreamInterfaceConfig::RawFraming` variants +- Add `MessageInterfaceConfig` enum with `Http` and `Dns` variants (and their config structs) +- Add `HttpListenerConfig` struct: `bind_addr`, `tls: bool`, `stealth: bool` +- Add `DnsListenerConfig` struct: `bind_addr`, `tls: bool` +- Update `ListenerConfig` to have three variants: + - `Stream { transport: TransportKind, interface: StreamInterfaceKind }` (existing pattern, renamed) + - `Http { config: HttpListenerConfig }` + - `Dns { config: DnsListenerConfig }` +- `TransportKind::WebTransport` is a tag-only enum variant — no `WebTransportAcceptor` implementation, no feature flag, just the variant existing so that config parsing can reference it + +**Note on stealth mode**: The `Http` variant's `stealth` field means "if true, do byte-peek protocol detection on incoming TLS connections". This connects to the existing `stealth.rs` protocol detection. The axum router scaffold (task 2.7) handles the routing when stealth mode detects HTTP traffic. This task just defines the config types. + +## Acceptance Criteria + +- [ ] `TransportKind::WebTransport { server_name: Option }` variant added as tag-only (no acceptor impl, compiles but has no effect on server behavior) +- [ ] `TransportKind::Dns` confirmed absent from the enum (DNS is a `MessageInterface`, not a transport) +- [ ] `InterfaceConfig` renamed to `StreamInterfaceConfig` (or `StreamConfig` — aligned with the trait rename) with `Ssh` and `RawFraming` variants +- [ ] `MessageInterfaceConfig` enum added with `Http` and `Dns` variants +- [ ] `HttpListenerConfig` struct defined with `bind_addr: SocketAddr`, `tls: bool`, `stealth: bool` +- [ ] `DnsListenerConfig` struct defined with `bind_addr: SocketAddr`, `tls: bool` +- [ ] `ListenerConfig` enum has three variants: `Stream { transport, interface }`, `Http { config: HttpListenerConfig }`, `Dns { config: DnsListenerConfig }` +- [ ] `StreamInterfaceKind` enum defined (corresponding to `StreamInterface` implementors: `Ssh`, `RawFraming`) +- [ ] `MessageInterfaceKind` enum defined (corresponding to `MessageInterface` implementors: `Http`, `Dns`) +- [ ] `is_valid_pair()` validation updated for `Stream` listener configs (only valid Transport/StreamInterface combos allowed) +- [ ] `Display` implementations added for all new enums +- [ ] Serialization support (`serde::Serialize`/`Deserialize`) for all new config types +- [ ] All existing server/transport tests pass unchanged +- [ ] Unit test: `TransportKind::WebTransport` variant exists and can be constructed +- [ ] Unit test: `ListenerConfig::Http` variant constructs with `HttpListenerConfig` +- [ ] Unit test: `ListenerConfig::Dns` variant constructs with `DnsListenerConfig` + +## References + +- docs/research/integration-plan.md — Phase 2.5 +- docs/research/phase2/interface-model.md — ListenerConfig, TransportKind, InterfaceKind redesign +- docs/research/phase2/tls-transport.md — HTTP listener config, stealth mode +- crates/alknet-core/src/interface/config.rs — Current InterfaceConfig, InterfaceKind +- crates/alknet-core/src/interface/pairs.rs — Valid transport-interface pairs + +## Notes + +> Use `#[non_exhaustive]` on `ListenerConfig`, `StreamInterfaceKind`, `MessageInterfaceKind`, and `MessageInterfaceConfig` so future variants (WebSocket, gRPC) don't break downstream. + +> The `stealth` field on `HttpListenerConfig` controls whether the server does byte-peek protocol detection (first bytes → SSH vs HTTP). When `stealth: true` on a listener sharing port 443 with SSH, the accept loop routes based on protocol detection. When `stealth: false`, the HTTP listener receives all traffic directly. + +> The `tls: bool` field is separate from `stealth`. `tls: true` means "use TLS on this listener". `stealth: true` means "peek first bytes to detect SSH vs HTTP". These are orthogonal: you can have TLS + stealth (port 443), TLS without stealth (port 8443), plain HTTP without stealth (port 8080), etc. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/raw-framing-interface-implementation.md b/tasks/integration/phase2/raw-framing-interface-implementation.md new file mode 100644 index 0000000..59c9419 --- /dev/null +++ b/tasks/integration/phase2/raw-framing-interface-implementation.md @@ -0,0 +1,75 @@ +--- +id: raw-framing-interface-implementation +name: Implement RawFramingInterface accept/recv/send with first-frame auth +status: pending +depends_on: [stream-interface-message-interface-split] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Implement `RawFramingInterface` and `RawFramingSession` to handle length-prefixed `EventEnvelope` frames over a byte stream, with first-frame authentication. Currently `RawFramingInterface::accept()` returns an error and `RawFramingSession` stubs exist. + +Per the integration plan section 2.2 and interface.md: + +**RawFramingInterface**: Reads 4-byte length-prefixed JSON `EventEnvelope` frames from a transport stream (TCP, TLS, iroh, etc.). No SSH wrapping — the raw framing interface carries call protocol events directly. + +**First-frame auth**: The first `InterfaceEvent` on a `RawFramingSession` carries an auth token in the `InterfaceEvent.identity` field or a dedicated auth event type. After `IdentityProvider::resolve_from_token()` verifies the token and produces an `Identity`, the session is authenticated. Subsequent frames are call protocol `EventEnvelope` data. If auth fails, the session is terminated immediately. + +**Current state of the code**: +- `RawFramingInterface` accepts any `TransportStream` but returns an error +- `RawFramingSession` is an empty struct with stub `recv()` (returns `None`) and `send()` (returns error) +- `call::frame::{encode, decode, decode_with_remainder}` already implement the wire format +- `IdentityProvider::resolve_from_token()` exists but is not yet wired to `AuthToken` verification (that's coming in the API keys task) + +**Implementation approach**: +1. `RawFramingInterface::accept()` takes a `TransportStream`, wraps it in a `BufReader` for buffered reading, stores it in `RawFramingSession`. The `RawFramingSession` is created in an "unauthenticated" state. +2. `RawFramingSession::recv()` reads frames from the stream: + - If unauthenticated: read the first frame, extract the auth token, call `IdentityProvider::resolve_from_token()`. On success, transition to "authenticated" with the resolved `Identity`. On failure, return an error (session terminated). + - If authenticated: read `EventEnvelope` frames, wrap in `InterfaceEvent::with_identity(envelope, identity)`. +3. `RawFramingSession::send()` writes `EventEnvelope` frames to the stream using `call::frame::encode`. + +The `RawFramingSession` needs: +- A `BufReader>` for reading framed data +- A `Box` (or WriteHalf) for writing framed data +- An `Option` tracking auth state +- A reference to `IdentityProvider` for token resolution +- A buffer for partial frame reads (`decode_with_remainder` pattern) + +## Acceptance Criteria + +- [ ] `RawFramingInterface::accept()` takes a `TransportStream` and `StreamInterfaceConfig::RawFraming` config, creates a `RawFramingSession` wrapping the stream +- [ ] `RawFramingSession` holds a buffered reader and writer over the transport stream, an auth state (`Option`), and a reference to `IdentityProvider` +- [ ] `RawFramingSession::recv()` reads length-prefixed `EventEnvelope` frames from the stream using `call::frame::decode_with_remainder` +- [ ] First-frame auth: the first `recv()` call resolves the auth token via `IdentityProvider::resolve_from_token()` and stores the resulting `Identity` +- [ ] Subsequent `recv()` calls produce `InterfaceEvent::with_identity(envelope, identity)` using the authenticated identity +- [ ] Auth failure terminates the session: `recv()` returns an error result on bad tokens +- [ ] `RawFramingSession::send()` writes `EventEnvelope` frames to the stream using `call::frame::encode` +- [ ] Unit test: `RawFramingInterface::accept()` succeeds with a valid stream +- [ ] Unit test: `RawFramingSession` round-trips an `EventEnvelope` through `send()` and `recv()` (after mock auth) +- [ ] Unit test: First-frame auth with a valid token transitions to authenticated state +- [ ] Unit test: First-frame auth with an invalid token returns an error +- [ ] Integration test: `RawFramingSession` over a `tokio::io::duplex` stream (simulated TCP) sends and receives multiple frames + +## References + +- docs/research/integration-plan.md — Phase 2.2 +- docs/architecture/interface.md — RawFramingInterface, first-frame auth model +- crates/alknet-core/src/interface/raw_framing.rs — Current stubs +- crates/alknet-core/src/call/frame.rs — Frame encode/decode +- crates/alknet-core/src/auth/identity.rs — IdentityProvider, resolve_from_token + +## Notes + +> The frame format is already implemented and tested in `call::frame`. This task is primarily about wiring the frame reader/writer to the `InterfaceSession` trait and adding first-frame auth logic. + +> Consider using `tokio::io::BufReader` for buffered reading and `tokio::io::BufWriter` for buffered writing. The `decode_with_remainder` function handles partial reads by returning how many bytes were consumed — the session needs to maintain a read buffer for reassembly. + +> The `RawFramingInterface` config should include an `Arc` for first-frame auth. This follows the same pattern as `SshInterfaceConfig`. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/review-core-bridge-phase2.md b/tasks/integration/phase2/review-core-bridge-phase2.md new file mode 100644 index 0000000..0d637a4 --- /dev/null +++ b/tasks/integration/phase2/review-core-bridge-phase2.md @@ -0,0 +1,61 @@ +--- +id: review-core-bridge-phase2 +name: Review all Phase 2 changes for spec conformance and prepare for Phase 3 +status: pending +depends_on: [stream-interface-message-interface-split, ssh-session-call-protocol-bridge, raw-framing-interface-implementation, credential-provider-trait, api-keys-dynamic-config, listenconfig-http-dns-stubs, axum-http-router-scaffold] +scope: narrow +risk: low +impact: phase +level: review +--- + +## Description + +Review all Phase 2 implementation for spec conformance, architectural consistency, and completeness before Phase 3 crate development begins. Per integration plan section 4.5, a second doc sync should capture any deviations between spec and implementation. + +This review covers: +1. **Spec conformance**: Do implementations match the architecture docs and ADRs (035, 036, 037)? +2. **Layer boundary discipline**: Does every component belong to exactly one layer? No call protocol logic in the interface layer, no interface logic in the transport layer. +3. **Terminology consistency**: head/worker everywhere (no hub/spoke), StreamInterface/MessageInterface (no bare "Interface" trait), consistent naming. +4. **Test coverage**: Do all Phase 2 tasks have tests that verify acceptance criteria? +5. **No circular dependencies**: alknet-core doesn't depend on alknet-secret, alknet-storage, or alknet-flowgraph. +6. **Doc sync**: Update architecture docs to reflect Phase 2 implementation state. Specifically: + - `interface.md` — StreamInterface/MessageInterface split, InterfaceRequest/InterfaceResponse + - `auth.md` — API keys, resolve_from_token() changes + - `configuration.md` — DynamicConfig additions (api_keys, credentials) + - `call-protocol.md` — SshSession bridge, RawFraming auth flow + - Any deviations between spec and implementation should be documented + +## Acceptance Criteria + +- [ ] All Phase 2 tasks have acceptance criteria verified (each task's AC checklist is complete) +- [ ] Layer boundaries are clean: interface layer produces/consumes `InterfaceEvent`; protocol layer handles `EventEnvelope`; transport layer provides byte streams +- [ ] No `Interface` trait references remain (all renamed to `StreamInterface`) +- [ ] No `TransportKind::Dns` in the enum (DNS is a `MessageInterface`) +- [ ] `Cargo.toml` dependency check: alknet-core has no circular deps on external crates +- [ ] `http` feature flag correctly gates axum dependency +- [ ] Architecture docs updated for Phase 2 state: + - [ ] `interface.md` reflects StreamInterface/MessageInterface split + - [ ] `auth.md` reflects API keys in DynamicConfig + - [ ] `configuration.md` reflects new DynamicConfig sections + - [ ] `call-protocol.md` reflects functional SshSession bridge +- [ ] All tests pass: `cargo test --all-features` +- [ ] No compiler warnings on Phase 2 code +- [ ] `taskgraph parallel --path tasks/integration/phase2` shows all tasks completed + +## References + +- docs/research/integration-plan.md — Phase 4.5 doc sync +- All Phase 2 ADRs: 035, 036, 037 +- All Phase 2 implementation tasks (2.1–2.7) +- docs/architecture/ — architecture docs to update + +## Notes + +> This is a quality gate before Phase 3. The review should be thorough but shouldn't block on minor documentation phrasing. Focus on structural conformance: are layers respected, are traits correct, are dependencies acyclic? + +> If any deviations between spec and implementation are found, document them in the relevant architecture doc with a "Deviation from spec" note explaining why. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/ssh-session-call-protocol-bridge.md b/tasks/integration/phase2/ssh-session-call-protocol-bridge.md new file mode 100644 index 0000000..bbba12d --- /dev/null +++ b/tasks/integration/phase2/ssh-session-call-protocol-bridge.md @@ -0,0 +1,72 @@ +--- +id: ssh-session-call-protocol-bridge +name: Bridge SshSession recv/send to call protocol via alknet-control:0 channel +status: pending +depends_on: [stream-interface-message-interface-split] +scope: moderate +risk: medium +impact: component +level: implementation +--- + +## Description + +Implement `SshSession::recv()` and `SshSession::send()` to bridge SSH channel data to and from the call protocol's `InterfaceEvent`/`EventEnvelope` frames. Currently both methods are stubs: `recv()` always returns `None` and `send()` silently discards. + +Per the integration plan section 2.1 and interface.md (OQ-IF-01, resolved): + +The bridge works as follows: +- When `SshHandler::channel_open_direct_tcpip` detects a destination starting with `alknet-`, it currently accepts the channel but doesn't bridge the data. The `ControlChannelRouter` exists in `control_channel.rs` but has no handler wired. +- `SshSession::recv()` should read `EventEnvelope` frames from the `alknet-control:0` channel stream (using the 4-byte length prefix + JSON wire format from `call::frame::{encode, decode}`), wrap them in `InterfaceEvent` with the session's `Identity` (obtained during SSH auth). +- `SshSession::send()` should write `EventEnvelope` frames to the `alknet-control:0` channel stream using the same framing format. +- The `ControlChannelRouter` should be wired to bridge incoming channel data to the call protocol handler. + +**Current state of the code**: +- `SshSession::recv()` returns `None` (stub) +- `SshSession::send()` discards silently (stub) +- `ControlChannelRouter` in `control_channel.rs` has `route()` and `has_handler()` but no handler is registered +- `call::frame::{encode, decode}` functions exist and are well-tested (4-byte BE length prefix + JSON) +- `SshHandler` detects `alknet-*` destinations in `channel_open_direct_tcpip` but doesn't bridge data +- `SshHandler` stores `authenticated_identity: Option` from SSH auth +- `InterfaceEvent` struct carries `EventEnvelope` + `Option` — already defined + +**Key design considerations**: +- The `SshSession` needs access to the SSH channel's data stream to read/write `EventEnvelope` frames. This requires getting the `russh::Channel` data stream and framing it. +- The `ControlChannelRouter` currently uses `Box` — it can be wired as a `ControlChannelHandler` that reads frames from the stream and produces `InterfaceEvent`s. +- The `alknet-control:0` channel is the first SSH direct-tcpip channel with the `alknet-control` destination. Additional `alknet-*` channels may follow. +- The session's `Identity` (from SSH auth) must be attached to every `InterfaceEvent` produced by `recv()`. + +## Acceptance Criteria + +- [ ] `SshSession::recv()` reads `EventEnvelope` frames from the SSH channel data stream and produces `InterfaceEvent` with the session's `Identity` +- [ ] `SshSession::send()` writes `EventEnvelope` frames to the SSH channel data stream using `call::frame::encode` +- [ ] `ControlChannelRouter` is wired as the handler for `alknet-control:0` channels, bridging SSH channel data to the call protocol +- [ ] Frame encoding matches `call::frame::{encode, decode}` — 4-byte big-endian length prefix + UTF-8 JSON body +- [ ] The session's `Identity` (from `SshHandler::authenticated_identity`) is attached to every `InterfaceEvent` produced by `recv()` +- [ ] `SshHandler::channel_open_direct_tcpip` correctly routes `alknet-control:0` channels to the `ControlChannelRouter` handler +- [ ] Unit test: `SshSession` can round-trip an `EventEnvelope` through `send()` and `recv()` (using a mock channel stream) +- [ ] Unit test: `ControlChannelRouter.with_handler()` successfully routes channel data +- [ ] All existing server/auth/transport tests continue to pass +- [ ] No behavioral changes for non-`alknet-*` channel forwarding (port proxy logic unchanged) + +## References + +- docs/research/integration-plan.md — Phase 2.1 +- docs/architecture/interface.md — OQ-IF-01 resolution, InterfaceEvent model +- docs/architecture/call-protocol.md — EventEnvelope, frame encoding +- crates/alknet-core/src/interface/ssh.rs — SshSession stubs (recv/send) +- crates/alknet-core/src/server/control_channel.rs — ControlChannelRouter +- crates/alknet-core/src/call/frame.rs — frame encode/decode +- crates/alknet-core/src/interface/session.rs — InterfaceEvent, InterfaceSession traits + +## Notes + +> This is the highest-risk task in Phase 2. The `russh` channel data stream API needs careful handling — getting a `Channel`'s data stream for async reading/writing is non-trivial and may require understanding russh's `data()` callback pattern vs. the `Channel::into_stream()` method. + +> Consider implementing incrementally: first wire the `ControlChannelRouter` handler to produce `InterfaceEvent`s from raw channel data, then connect that to `SshSession::recv()`/`send()`. Each step should have passing tests before proceeding. + +> The `SshSession` struct currently holds a `server::Handle` and a `JoinHandle`. It may need additional fields to track the control channel stream and the authenticated identity for producing `InterfaceEvent`s with identity attached. + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/integration/phase2/stream-interface-message-interface-split.md b/tasks/integration/phase2/stream-interface-message-interface-split.md new file mode 100644 index 0000000..b6e93c8 --- /dev/null +++ b/tasks/integration/phase2/stream-interface-message-interface-split.md @@ -0,0 +1,78 @@ +--- +id: stream-interface-message-interface-split +name: Rename Interface → StreamInterface, add MessageInterface trait and restructure config types +status: pending +depends_on: [] +scope: moderate +risk: medium +impact: phase +level: implementation +--- + +## Description + +Rename the current `Interface` trait to `StreamInterface` and add a `MessageInterface` trait for request/response interfaces (HTTP, DNS). This is the most impactful structural change in Phase 2 because all subsequent tasks reference the new trait names and config types. + +Per ADR-035 and research/phase2/interface-model.md: + +- `Interface` → `StreamInterface` (the current trait becomes the stream-specific variant; persistent byte streams) +- `InterfaceSession` stays as-is (it's already stream-specific) +- Add `MessageInterface` trait: `handle_request(&self, request: InterfaceRequest) -> Result` (stateless request/response) +- Add `InterfaceRequest` and `InterfaceResponse` types that normalize calls across message interfaces +- Add `HttpInterface` stub (struct definition, no route implementations yet) +- Add `DnsInterface` stub (struct definition only) +- Restructure `InterfaceConfig` into `StreamInterfaceConfig` and `MessageInterfaceConfig` +- Update `ListenerConfig` to include `Stream`, `Http`, and `Dns` variants per the research docs +- Add `TransportKind::WebTransport` as a tag-only variant (no acceptor implementation) +- Remove any references to `TransportKind::Dns` (it was never added, so no removal needed — just ensure it's not added) + +**Why this must go first**: Every other Phase 2 task imports and references these types. The rename and new traits must land before SshSession bridge, RawFraming implementation, or HTTP scaffold work begins. The integration plan section 2.3 explicitly states: "This task should be done early in Phase 2 because all subsequent tasks reference the new trait names." + +**Current state of the code**: +- `Interface` trait in `crates/alknet-core/src/interface/mod.rs` with `accept()` → `Self::Session` +- `InterfaceSession` trait in `crates/alknet-core/src/interface/session.rs` with `recv()` / `send()` +- `InterfaceConfig` enum with `Ssh(SshInterfaceConfig)` and `RawFraming(RawFramingConfig)` variants +- `InterfaceKind` enum with `Ssh` and `RawFraming` variants +- `TransportKind` has `Tcp`, `Tls`, `Iroh` (no `Dns`) +- `SshInterface` implements `Interface`, `SshSession` implements `InterfaceSession` +- `RawFramingInterface`/`RawFramingSession` are stubs (Phase 1 left them) + +## Acceptance Criteria + +- [ ] `Interface` trait renamed to `StreamInterface` throughout alknet-core (mod.rs, ssh.rs, raw_framing.rs, and all import sites) +- [ ] `MessageInterface` trait defined in `crates/alknet-core/src/interface/mod.rs` with `handle_request(&self, request: InterfaceRequest) -> Result` +- [ ] `InterfaceRequest` struct defined with `operation_path`, `input`, `auth_token`, `metadata` fields per interface-model.md +- [ ] `InterfaceResponse` struct defined with `result`, `status`, `headers` fields per interface-model.md +- [ ] `HttpInterface` stub struct defined (identity_provider, registry, env fields) — no route implementations +- [ ] `DnsInterface` stub struct defined (domain, identity_provider, registry, env fields) — no implementation +- [ ] `InterfaceConfig` restructured: `StreamInterfaceConfig::Ssh` and `StreamInterfaceConfig::RawFraming` replace current variants; `MessageInterfaceConfig` enum added with `Http` and `Dns` variants (or the variant types are defined) +- [ ] `ListenerConfig` enum updated with `Stream { transport, interface }`, `Http { bind_addr, tls, stealth }`, and `Dns { bind_addr, tls }` variants +- [ ] `TransportKind::WebTransport` added as tag-only variant (no acceptor, no feature flag beyond the enum variant) +- [ ] `is_valid_pair()` / `TransportKindBase` validation updated for StreamInterface pairs only +- [ ] All existing tests pass after the rename (SshInterface and RawFramingInterface still compile and pass) +- [ ] New `StreamInterface` implementors still use `InterfaceSession` for `type Session` +- [ ] `MessageInterface` has at least one compilation test (e.g., a mock struct implements it) +- [ ] `HttpInterface` and `DnsInterface` stubs compile and exist in the type system +- [ ] `lib.rs` re-exports all new types (`StreamInterface`, `MessageInterface`, `InterfaceRequest`, `InterfaceResponse`, `HttpInterface`, `DnsInterface`) + +## References + +- docs/architecture/decisions/035-streaminterface-messageinterface-split.md — ADR-035 +- docs/research/phase2/interface-model.md — Full design rationale +- docs/research/integration-plan.md — Phase 2.3 +- crates/alknet-core/src/interface/mod.rs — Current Interface trait +- crates/alknet-core/src/interface/session.rs — InterfaceSession, InterfaceEvent +- crates/alknet-core/src/interface/config.rs — Current InterfaceConfig +- crates/alknet-core/src/interface/pairs.rs — Valid transport-interface pairs + +## Notes + +> This is the most mechanically invasive change in Phase 2 due to the rename, but it's low-risk behaviorally. The `Interface` → `StreamInterface` rename is a find-and-replace operation. The new `MessageInterface` trait and stubs are purely additive. The `ListenerConfig` restructuring is additive since no existing code uses `ListenerConfig::Http` or `ListenerConfig::Dns` yet. + +> The integration plan section 2.3 notes: "Existing `SshInterface` and `RawFramingInterface` become `StreamInterface` implementations. No behavior change for stream-based interfaces." + +> Consider using `#[non_exhaustive]` on the new enums (`MessageInterfaceConfig`, `ListenerConfig`) so future variants (WebSocket, etc.) don't break downstream. + +## Summary + +> To be filled on completion \ No newline at end of file